[
  {
    "path": ".cspell.json",
    "content": "{\n  \"version\": \"0.2\",\n  \"language\": \"en\",\n  \"dictionaries\": [\n    // language\n    \"en_us\",\n    // code\n    \"go\",\n    \"node\"\n  ],\n  \"words\": [\n    \"abool\",\n    \"addgroup\",\n    \"adduser\",\n    \"agentscan\",\n    \"anbraten\",\n    \"antfu\",\n    \"apimachinery\",\n    \"appleboy\",\n    \"aquasec\",\n    \"Archlinux\",\n    \"autoincr\",\n    \"automerge\",\n    \"autoscaler\",\n    \"backporting\",\n    \"backports\",\n    \"binutils\",\n    \"bitbucketdatacenter\",\n    \"Bluesky\",\n    \"Boguslawski\",\n    \"bradrydzewski\",\n    \"buildkit\",\n    \"BUILDPLATFORM\",\n    \"buildx\",\n    \"caddyfile\",\n    \"ccmenu\",\n    \"CERTDIR\",\n    \"certmagic\",\n    \"charmbracelet\",\n    \"checkmake\",\n    \"cicd\",\n    \"ciphertext\",\n    \"Cloudron\",\n    \"Codeberg\",\n    \"compatiblelicenses\",\n    \"corepack\",\n    \"cpuset\",\n    \"creativecommons\",\n    \"Curr\",\n    \"datacenter\",\n    \"DATASOURCE\",\n    \"Debugf\",\n    \"dejavusans\",\n    \"Demilestoned\",\n    \"desaturate\",\n    \"devx\",\n    \"dind\",\n    \"Dockle\",\n    \"doublestar\",\n    \"emojify\",\n    \"envsubst\",\n    \"errgroup\",\n    \"estree\",\n    \"evenodd\",\n    \"excalidraw\",\n    \"favicons\",\n    \"Fediverse\",\n    \"Feishu\",\n    \"Fogas\",\n    \"forbidigo\",\n    \"Forgejo\",\n    \"fsnotify\",\n    \"Geeklab\",\n    \"Georgiana\",\n    \"gitea\",\n    \"gitmodules\",\n    \"GOARCH\",\n    \"GOBIN\",\n    \"gocritic\",\n    \"GODEBUG\",\n    \"godoc\",\n    \"Gogs\",\n    \"golangci\",\n    \"gomod\",\n    \"gonic\",\n    \"GOPATH\",\n    \"Gource\",\n    \"handlebargh\",\n    \"HEALTHCHECK\",\n    \"healthz\",\n    \"Hetzner\",\n    \"HETZNERCLOUD\",\n    \"homelab\",\n    \"hostmatcher\",\n    \"HTMLURL\",\n    \"HTTPFS\",\n    \"httpsign\",\n    \"HTTPURL\",\n    \"httputil\",\n    \"ianvs\",\n    \"iconify\",\n    \"inetutils\",\n    \"Infima\",\n    \"Infof\",\n    \"Informatyka\",\n    \"intlify\",\n    \"Ionescu\",\n    \"Jetpack\",\n    \"Kaniko\",\n    \"Keyfunc\",\n    \"kyvg\",\n    \"lafriks\",\n    \"LASTEXITCODE\",\n    \"Laszlo\",\n    \"laszlocph\",\n    \"letsencrypt\",\n    \"loadbalancer\",\n    \"logfile\",\n    \"loglevel\",\n    \"LONGBLOB\",\n    \"LONGTEXT\",\n    \"lonix1\",\n    \"mapstructure\",\n    \"markdownlint\",\n    \"mdbook\",\n    \"memswap\",\n    \"Metas\",\n    \"mhmxs\",\n    \"Milestoned\",\n    \"moby\",\n    \"Msgf\",\n    \"mstruebing\",\n    \"multiarch\",\n    \"multierr\",\n    \"narqo\",\n    \"netdns\",\n    \"Netrc\",\n    \"Nextcloud\",\n    \"nfpm\",\n    \"nixos\",\n    \"nixpkgs\",\n    \"nocolor\",\n    \"nolint\",\n    \"nologin\",\n    \"norunningpipelines\",\n    \"nosniff\",\n    \"ntfy\",\n    \"octocat\",\n    \"openapi\",\n    \"opensource\",\n    \"opentype\",\n    \"Pacman\",\n    \"picus\",\n    \"Pinia\",\n    \"pkce\",\n    \"pnpx\",\n    \"Polyform\",\n    \"posix\",\n    \"ppid\",\n    \"Println\",\n    \"prismjs\",\n    \"promauto\",\n    \"promhttp\",\n    \"proto\",\n    \"protobuf\",\n    \"protoc\",\n    \"PROTOC\",\n    \"protoimpl\",\n    \"protoreflect\",\n    \"pullrequest\",\n    \"pullrequests\",\n    \"pwsh\",\n    \"Redirections\",\n    \"Refspec\",\n    \"regcred\",\n    \"repology\",\n    \"reslimit\",\n    \"Reviewdog\",\n    \"Rieter\",\n    \"riscv\",\n    \"rundll32\",\n    \"Rydzewski\",\n    \"seccomp\",\n    \"secprofile\",\n    \"selfhosted\",\n    \"sess\",\n    \"sfnt\",\n    \"shellescape\",\n    \"shopt\",\n    \"sigstore\",\n    \"Sonatype\",\n    \"SSHURL\",\n    \"sslmode\",\n    \"stepbuilder\",\n    \"stretchr\",\n    \"structs\",\n    \"sublicensable\",\n    \"swaggo\",\n    \"syscalls\",\n    \"TARGETARCH\",\n    \"TARGETOS\",\n    \"techknowlogick\",\n    \"termenv\",\n    \"testdata\",\n    \"threadcreate\",\n    \"tink\",\n    \"tinycolor\",\n    \"tmole\",\n    \"tmpfs\",\n    \"tmpl\",\n    \"tolerations\",\n    \"Traefik\",\n    \"tseslint\",\n    \"ttlcache\",\n    \"TUNEIT\",\n    \"Tunnelmole\",\n    \"typecheck\",\n    \"Typeflag\",\n    \"unplugin\",\n    \"unsanitize\",\n    \"Upsert\",\n    \"urfave\",\n    \"usecase\",\n    \"useragent\",\n    \"varchar\",\n    \"varz\",\n    \"vcsurl\",\n    \"Vieter\",\n    \"virtualisation\",\n    \"visualisation\",\n    \"vite\",\n    \"vueuse\",\n    \"waivable\",\n    \"Warnf\",\n    \"webhookd\",\n    \"Weblate\",\n    \"windi\",\n    \"windicss\",\n    \"woodpeckerci\",\n    \"WORKDIR\",\n    \"Wrapf\",\n    \"x-enum-varnames\",\n    \"xlink\",\n    \"xlog\",\n    \"xorm\",\n    \"xormigrate\",\n    \"xoxys\",\n    \"xyaml\",\n    \"yamls\",\n    \"Yuno\",\n    \"zerolog\",\n    \"zerologger\"\n  ],\n  \"ignorePaths\": [\n    \".cspell.json\",\n    \"e2e/**\",\n    \".git/**/*\",\n    \".gitignore\",\n    \".golangci.yaml\",\n    \".vscode/extensions.json\",\n    \"*_test.go\",\n    \"*.excalidraw\",\n    \"*.svg\",\n    \"**/*.pb.go\",\n    \"**/fixtures/**\",\n    \"**/testdata/**\",\n    \"CHANGELOG.md\",\n    \"docs/versioned_docs/\",\n    \"flake.nix\",\n    \"go.mod\",\n    \"Makefile\",\n    \"package.json\",\n    \"server/store/datastore/migration/**/*\",\n    \"web/components.d.ts\",\n    \"web/src/assets/locales/**/*\",\n    // generated\n    \"**/mocks/**\",\n    \"**/node_modules/**/*\",\n    \"cmd/server/openapi/docs.go\",\n    \"flake.lock\",\n    \"go.sum\",\n    \"pnpm-lock.yaml\",\n    \"renovate.json\",\n    // TODO: remove the following\n    \"docs/**/*.js\",\n    \"docs/**/*.ts\"\n  ],\n  // Exclude imports, because they are also strings.\n  \"ignoreRegExpList\": [\n    // ignore mulltiline imports\n    \"import\\\\s*\\\\((.|[\\r\\n])*?\\\\)\",\n    // ignore single line imports\n    \"import\\\\s*.*\\\".*?\\\"\",\n    // ignore go generate directive\n    \"//\\\\s*go:generate.*\",\n    // ignore nolint directive\n    \"//\\\\s*nolint:.*\",\n    // ignore docker image names\n    \"\\\\s*docker\\\\.io/.*\",\n    // ignore inline svg in css\n    \"\\\\s*url\\\\(\\\"data:image/svg\\\\+xml.*\"\n  ],\n  \"enableFiletypes\": [\"dockercompose\"]\n}\n"
  },
  {
    "path": ".ecrc",
    "content": "{\n  \"Exclude\": [\n    \".git\",\n    \"go.mod\",\n    \"go.sum\",\n    \"vendor\",\n    \"fixtures\",\n    \"LICENSE\",\n    \"node_modules\",\n    \"server/store/datastore/migration/test-files/sqlite.db\",\n    \"server/store/datastore/migration/test-files/postgres.sql\",\n    \"server/store/datastore/feed.go\",\n    \"cmd/server/openapi/docs.go\",\n    \"_test.go\",\n    \"Makefile\"\n  ]\n}\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\ntab_width = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.go]\nindent_style = tab\n\n[*.md]\ntrim_trailing_whitespace = false\nindent_size = 1\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "# Credits to: https://github.com/vitejs/vite/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml\nname: \"\\U0001F41E Bug report\"\ndescription: Report an issue with Woodpecker\nlabels: ['bug']\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n  - type: dropdown\n    id: component\n    attributes:\n      label: Component\n      description: Which component of Woodpecker is affected by the issue?\n      multiple: true\n      options:\n        - server\n        - agent\n        - cli\n        - web-ui\n        - other\n    validations:\n      required: true\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!\n      placeholder: Bug description\n    validations:\n      required: true\n  - type: textarea\n    id: steps-to-reproduce\n    attributes:\n      label: Steps to reproduce\n      description: Steps to reproduce the behavior.\n      placeholder: |\n        1. Install Woodpecker Server with the following configuration: ...\n        2. Install Woodpecker Agent with the configuration below: ...\n        3. Besides, set some settings in the forge: ...\n        4. Run them all by the commands: ...\n        5. Go to ..., click here and there, see next error: ...\n        6. Also, check the logs and find this: ...\n    validations:\n      required: true\n  - type: textarea\n    id: expected-behavior\n    attributes:\n      label: Expected behavior\n      description: A clear and concise description of what you expected to happen.\n      placeholder: |\n        When I click here and there, there should not be an error, but a successful operation.\n        There should not be the errors in the logs, but the messages, that indicate a process: ...\n    validations:\n      required: false\n  - type: textarea\n    id: system-info\n    attributes:\n      label: System Info\n      description: Output of `https://<your-woodpecker-instance>/version`\n      render: shell\n      placeholder: Version info, docker-compose config, Kubernetes manifests\n    validations:\n      required: true\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: |\n        Logs? Screenshots? Anything that will give us more context about the issue you are encountering!\n        Sometimes a picture is worth a thousand words, but please try not to insert an image of logs / text\n        and copy paste the text instead.\n\n        Tip: You can attach images by clicking this area to highlight it and then dragging files in.\n    validations:\n      required: false\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please make sure you do the following\n      options:\n        - label: Read the [docs](https://woodpecker-ci.org/docs/intro).\n          required: true\n        - label: Check that there isn't [already an issue](https://github.com/woodpecker-ci/woodpecker/issues) that reports the same bug to avoid creating a duplicate.\n          required: true\n        - label: Checked that the bug isn't fixed in the `next` version already [https://woodpecker-ci.org/versions]\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Start a discussion\n    about: Our preferred starting point if you have any questions, suggestions or feature proposals.\n    url: https://github.com/woodpecker-ci/woodpecker/discussions/new/choose\n  - name: Frequently Asked Questions\n    url: https://woodpecker-ci.org/faq\n    about: Check the FAQs for common questions.\n  - name: Support\n    url: https://github.com/woodpecker-ci/.github/blob/main/SUPPORT.md\n    about: Information about how you can get support.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "# Credits to: https://github.com/vitejs/vite/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml\nname: \"\\U0001F680 New feature proposal\"\ndescription: Propose a new feature to be added to Woodpecker\nlabels: ['feature']\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for your interest in the project and taking the time to fill out this feature report!\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: Clear and concise description of the problem\n      description: 'As a user of Woodpecker I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description.'\n    validations:\n      required: true\n  - type: textarea\n    id: suggested-solution\n    attributes:\n      label: Suggested solution\n      description: 'In web-ui / config we could provide following functionality...'\n    validations:\n      required: true\n  - type: textarea\n    id: alternative\n    attributes:\n      label: Alternative\n      description: Clear and concise description of any alternative solutions or features you've considered.\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Any other context or screenshots about the feature request here.\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please make sure you do the following\n      options:\n        - label: Checked that the feature isn't part of the `next` version already [https://woodpecker-ci.org/versions]\n          required: true\n        - label: Read the [docs](https://woodpecker-ci.org/docs/intro).\n          required: true\n        - label: Check that there isn't already an [issue](https://github.com/woodpecker-ci/woodpecker/issues) that request the same feature to avoid creating a duplicate.\n          required: true\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "<!--\n\nPlease check the following tips:\n1. Avoid using force-push and commands that require it (such as `commit --amend` and `rebase origin/main`). This makes it more difficult for the maintainers to review your work. Add new commits on top of the current branch, and merge the new state of `main` into your branch with plain `merge`.\n2. Provide a meaningful title for this pull request. It will be used as the commit message when this pull request is merged. Add as many commits as you like with any messages you like, they will be squashed into one commit.\n3. If this pull request fixes an issue, refer to the issue with messages like `Closes #1234`, or `Fixes #1234` in the pull description.\n4. Please check that you are targeting the `main` branch. Pull requests on release branches are only allowed for backports.\n5. Make sure you have read contribution guidelines: https://woodpecker-ci.org/docs/development/getting-started\n6. It is recommended to enable \"Allow edits by maintainers\", so maintainers can help you more easily.\n\n-->\n"
  },
  {
    "path": ".github/release_template.md",
    "content": "<!-- markdownlint-disable MD041 -->\n\n### Prerequisites\n\n- [ ] MAJOR: Check `docs/src/pages/migrations.md`\n  - [ ] Check whether it contains all the necessary migration steps and recommended actions for users and administrators\n  - [ ] Check whether the steps refer to the associated pull requests or issues\n  - [ ] Ensure that the steps are clear and describe the actions required for the migration\n    - Good: \"Rename your `branch` configuration option to `when.branch` (PR#123)\"\n    - Bad: \"Remove the `branch` configuration option in favor of `when.branch`\"\n    - If possible, provide background information so users can understand the change\n- [ ] MAJOR: Create a blog entry in `docs/blog/` that highlights the most important changes and includes a link to the release notes.\n- [ ] Prepare docs PR for new version and delete old versions (keep only the last three minor versions for the current major version)\n  - [ ] Run `make generate` locally to update the automatically generated CLI documentation\n  - [ ] Copy `docs/docs` to `docs/versioned_docs/version-<version>` and delete old versions\n  - [ ] Create `docs/versioned_sidebars/version-<version>-sidebars.json` and delete old ones\n  - [ ] Add new version to `docs/versions.json` and delete old versions\n  - [ ] Add new version to the version list in `docs/src/pages/versions.md`\n- [ ] Announce the release in the maintainer chat and ask for pending blockers\n\n### Release\n\n- [ ] Test the latest container images to make sure they work as expected\n- [ ] Update `https://ci.woodpecker.org` to the latest version of `next` and verify that it works as expected\n- [ ] Merge documentation PR (shortly before release)\n- [ ] Merge the release PR to start the release pipeline\n\n### Post-release\n\n- [ ] Release the Helm Chart. If renovate has not created the upgrade PR already, manually trigger it from the Dependency Dashboard.\n- [ ] Announce release in relevant chats and on social media platforms\n  - [ ] Mastodon (check if already posted from the release pipeline)\n  - [ ] Bluesky (check if already posted from the release pipeline)\n  - [ ] Matrix\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\"github>woodpecker-ci/renovate-config\"],\n  \"automergeType\": \"pr\",\n  \"customManagers\": [\n    {\n      \"customType\": \"regex\",\n      \"managerFilePatterns\": [\"/^shared/constant/constant.go$/\"],\n      \"matchStrings\": [\n        \"//\\\\s*renovate:\\\\s*datasource=(?<datasource>.*?) depName=(?<depName>.*?)( versioning=(?<versioning>.*?))?\\\\s+DefaultClonePlugin = \\\"docker.io/woodpeckerci/plugin-git:(?<currentValue>.*)\\\"\"\n      ],\n      \"versioningTemplate\": \"{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}\"\n    }\n  ],\n  \"packageRules\": [\n    {\n      \"matchCurrentVersion\": \"<1.0.0\",\n      \"matchPackageNames\": [\"github.com/distribution/reference\"],\n      \"matchUpdateTypes\": [\"major\", \"minor\"],\n      \"dependencyDashboardApproval\": true\n    },\n    {\n      \"matchPackageNames\": [\"github.com/charmbracelet/huh/spinner\"],\n      \"enabled\": false\n    },\n    {\n      \"matchManagers\": [\"docker-compose\"],\n      \"matchFileNames\": [\"docker-compose.gitpod.yaml\"],\n      \"addLabels\": [\"devx\"]\n    },\n    {\n      \"groupName\": \"golang-lang\",\n      \"matchUpdateTypes\": [\"minor\", \"patch\"],\n      \"matchPackageNames\": [\"/^golang$/\", \"/xgo/\"]\n    },\n    {\n      \"groupName\": \"golang-packages\",\n      \"matchManagers\": [\"gomod\"],\n      \"matchUpdateTypes\": [\"minor\", \"patch\"]\n    },\n    {\n      \"matchManagers\": [\"npm\"],\n      \"matchFileNames\": [\"web/package.json\"],\n      \"addLabels\": [\"ui\"]\n    },\n    {\n      \"matchManagers\": [\"npm\"],\n      \"matchFileNames\": [\"docs/**/package.json\"],\n      \"addLabels\": [\"documentation\"]\n    },\n    {\n      \"groupName\": \"web npm deps non-major\",\n      \"matchManagers\": [\"npm\"],\n      \"matchUpdateTypes\": [\"minor\", \"patch\"],\n      \"matchFileNames\": [\"web/package.json\"]\n    },\n    {\n      \"groupName\": \"docs npm deps non-major\",\n      \"matchManagers\": [\"npm\"],\n      \"matchUpdateTypes\": [\"minor\", \"patch\"],\n      \"matchFileNames\": [\"docs/**/package.json\"]\n    },\n    {\n      \"description\": \"Extract version from xgo container tags\",\n      \"matchDatasources\": [\"docker\"],\n      \"versioning\": \"regex:^go-(?<major>\\\\d+)\\\\.(?<minor>\\\\d+)\\\\.x$\",\n      \"matchPackageNames\": [\"/techknowlogick/xgo/\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "### IDEs ###\n.idea/\n.vscode/*\n!.vscode/settings.json\n!.vscode/launch.json\n!.vscode/extensions.json\n\n### GO ###\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\nvendor/\n__debug_bin*\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n### Frontend ###\nweb/dist/**\n!web/dist/.gitkeep\nweb/node_modules/\nweb/*.log\nweb/.env\n.pnpm-store\n\n### Docker ###\ndocker-compose.yml\n\n### Other ##\n# runetime or build relicts\n*.sqlite\n*.out\n/.env\n/.direnv\n/.envrc\nextras/\n/build/\n/dist/\n/data/\ndatastore/migration/testfiles/\n\ndocs/venv\n\n# helm charts\n.cr-index/\n.cr-release-packages/\n\n### Generated by CI ###\ndocs/docs/40-cli.md\ndocs/openapi.json\n\n# Removed once v3.0.x is minimum version to be touched\ndocs/swagger.json\n"
  },
  {
    "path": ".gitpod.yml",
    "content": "tasks:\n  - name: Server\n    env:\n      WOODPECKER_OPEN: true\n      WOODPECKER_ADMIN: woodpecker\n      WOODPECKER_EXPERT_WEBHOOK_HOST: http://host.docker.internal:8000\n      WOODPECKER_AGENT_SECRET: '1234'\n      WOODPECKER_GITEA: true\n      WOODPECKER_DEV_WWW_PROXY: http://localhost:8010\n      WOODPECKER_BACKEND_DOCKER_NETWORK: ci_default\n    init: |\n      # renovate: datasource=golang-version depName=golang\n      GO_VERSION=1.26.3\n      rm -rf ~/go\n      curl -fsSL https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz | tar xzs -C ~/\n      go mod tidy\n      mkdir -p web/dist\n      touch web/dist/index.html\n      make build-server\n    command: |\n      grep \"WOODPECKER_GITEA_URL=\" .env \\\n        && sed \"s,^WOODPECKER_GITEA_URL=.*,WOODPECKER_GITEA_URL=$(gp url 3000),\" .env \\\n        || echo WOODPECKER_GITEA_URL=$(gp url 3000) >> .env\n      grep \"WOODPECKER_HOST=\" .env \\\n        && sed \"s,^WOODPECKER_HOST=.*,WOODPECKER_HOST=$(gp url 8000),\" .env \\\n        || echo WOODPECKER_HOST=$(gp url 8000) >> .env\n      gp sync-await gitea\n      gp sync-done woodpecker-server\n      go run go.woodpecker-ci.org/woodpecker/v3/cmd/server\n  - name: Agent\n    env:\n      WOODPECKER_SERVER: localhost:9000\n      WOODPECKER_AGENT_SECRET: '1234'\n      WOODPECKER_MAX_WORKFLOWS: 1\n      WOODPECKER_HEALTHCHECK: false\n    command: |\n      gp sync-await woodpecker-server\n      go run go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n  - name: Gitea\n    command: |\n      export DOCKER_COMPOSE_CMD=\"docker-compose -f docker-compose.gitpod.yaml -p woodpecker\"\n      export GITEA_CLI_CMD=\"$DOCKER_COMPOSE_CMD exec -u git gitea gitea\"\n      $DOCKER_COMPOSE_CMD up -d\n      until curl --output /dev/null --silent --head --fail http://localhost:3000; do printf '.'; sleep 1; done\n      $GITEA_CLI_CMD admin user create --username woodpecker --password password --email woodpecker@localhost --admin\n      export GITEA_TOKEN=$($GITEA_CLI_CMD admin user generate-access-token -u woodpecker --scopes write:repository,write:user --raw | tail -n 1 | awk 'NF{ print $NF }')\n      GITEA_OAUTH_APP=$(curl -X 'POST' 'http://localhost:3000/api/v1/user/applications/oauth2' \\\n        -H 'accept: application/json' -H 'Content-Type: application/json' -H \"Authorization: token ${GITEA_TOKEN}\" \\\n        -d \"{ \\\"name\\\": \\\"Woodpecker CI\\\", \\\"confidential_client\\\": true, \\\"redirect_uris\\\": [ \\\"https://8000-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST}/authorize\\\" ] }\")\n      touch .env\n      grep \"WOODPECKER_GITEA_CLIENT=\" .env \\\n        && sed \"s,^WOODPECKER_GITEA_CLIENT=.*,WOODPECKER_GITEA_CLIENT=$(echo $GITEA_OAUTH_APP | jq -r .client_id),\" .env \\\n        || echo WOODPECKER_GITEA_CLIENT=$(echo $GITEA_OAUTH_APP | jq -r .client_id) >> .env\n      grep \"WOODPECKER_GITEA_SECRET=\" .env \\\n        && sed \"s,^WOODPECKER_GITEA_SECRET=.*,WOODPECKER_GITEA_SECRET=$(echo $GITEA_OAUTH_APP | jq -r .client_secret),\" .env \\\n        || echo WOODPECKER_GITEA_SECRET=$(echo $GITEA_OAUTH_APP | jq -r .client_secret) >> .env\n      curl -X 'POST' \\\n        'http://localhost:3000/api/v1/user/repos' \\\n        -H 'accept: application/json' \\\n        -H 'Content-Type: application/json' \\\n        -H \"Authorization: token ${GITEA_TOKEN}\" \\\n        -d '{ \"auto_init\": false, \"name\": \"woodpecker-test\", \"private\": true, \"template\": false, \"trust_model\": \"default\" }'\n      cd contrib/woodpecker-test-repo\n      git init\n      git checkout -b main\n      git remote add origin http://woodpecker:${GITEA_TOKEN}@localhost:3000/woodpecker/woodpecker-test.git\n      git add .\n      git commit -m \"Initial commit\"\n      git push -u origin main\n      cd ../..\n      gp sync-done gitea\n      $DOCKER_COMPOSE_CMD logs -f\n  - name: App\n    before: |\n      cd web/\n    init: |\n      pnpm install\n    command: |\n      pnpm start\n  - name: Docs\n    before: |\n      cd docs/\n    init: |\n      pnpm install\n      pnpm build:woodpecker-plugins\n    command: |\n      pnpm start --port 4000\n\nports:\n  - port: 3000\n    name: Gitea\n    onOpen: ignore\n    visibility: public # TODO: https://github.com/woodpecker-ci/woodpecker/issues/856\n  - port: 8000\n    name: Woodpecker\n    onOpen: notify\n    visibility: public # TODO: https://github.com/woodpecker-ci/woodpecker/issues/856\n  - port: 9000\n    name: Woodpecker GRPC\n    onOpen: ignore\n  - port: 8010\n    description: Do not use! Access woodpecker on port 8000\n    onOpen: ignore\n  - port: 4000\n    name: Docs\n    onOpen: notify\n\nvscode:\n  extensions:\n    # cSpell:disable\n    - 'golang.go'\n    - 'EditorConfig.EditorConfig'\n    - 'dbaeumer.vscode-eslint'\n    - 'esbenp.prettier-vscode'\n    - 'bradlc.vscode-tailwindcss'\n    - 'Vue.volar'\n    - 'redhat.vscode-yaml'\n    - 'davidanson.vscode-markdownlint'\n    - 'streetsidesoftware.code-spell-checker'\n    - 'stivo.tailwind-fold'\n    # cSpell:enable\n"
  },
  {
    "path": ".golangci.yaml",
    "content": "version: '2'\nrun:\n  timeout: 15m\n  build-tags:\n    - test\nlinters:\n  default: none\n  enable:\n    - asciicheck\n    - bidichk\n    - bodyclose\n    - contextcheck\n    - depguard\n    - dogsled\n    - durationcheck\n    - errcheck\n    - errchkjson\n    - errorlint\n    - forbidigo\n    - forcetypeassert\n    - gochecknoinits\n    - gocritic\n    - godot\n    - goheader\n    - gomoddirectives\n    - gomodguard_v2\n    - goprintffuncname\n    - govet\n    - importas\n    - ineffassign\n    - makezero\n    - misspell\n    - mnd\n    - nolintlint\n    - revive\n    - rowserrcheck\n    - sqlclosecheck\n    - staticcheck\n    - unconvert\n    - unparam\n    - unused\n    - usetesting\n    - wastedassign\n    - whitespace\n    - zerologlint\n  settings:\n    depguard:\n      rules:\n        agent:\n          list-mode: lax\n          files:\n            - '**/agent/*.go'\n            - '**/agent/**/*.go'\n            - '**/cmd/agent/*.go'\n            - '**/cmd/agent/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\n        cli:\n          list-mode: lax\n          files:\n            - '**/cli/*.go'\n            - '**/cli/**/*.go'\n            - '**/cmd/cli/*.go'\n            - '**/cmd/cli/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n        pipeline:\n          list-mode: lax\n          files:\n            - '!**/cli/pipeline/*.go'\n            - '!**/cli/pipeline/**/*.go'\n            - '!**/server/pipeline/*.go'\n            - '!**/server/pipeline/**/*.go'\n            - '**/pipeline/*.go'\n            - '**/pipeline/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n        server:\n          list-mode: lax\n          files:\n            - '**/cmd/server/*.go'\n            - '**/cmd/server/**/*.go'\n            - '**/server/*.go'\n            - '**/server/**/*.go'\n            - '**/web/*.go'\n            - '**/web/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\n        rpc:\n          list-mode: lax\n          files:\n            - '!**/agent/rpc/*.go'\n            - '!**/agent/rpc/**/*.go'\n            - '!**/server/rpc/*.go'\n            - '!**/server/rpc/**/*.go'\n            - '**/rpc/*.go'\n            - '**/rpc/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n        shared:\n          list-mode: lax\n          files:\n            - '!**/pipeline/shared/*.go'\n            - '!**/pipeline/shared/**/*.go'\n            - '**/shared/*.go'\n            - '**/shared/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n        woodpecker-go:\n          list-mode: lax\n          files:\n            - '**/woodpecker-go/woodpecker/*.go'\n            - '**/woodpecker-go/woodpecker/**/*.go'\n          deny:\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/agent\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cli\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/server\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/shared\n            - pkg: go.woodpecker-ci.org/woodpecker/v3/web\n    errorlint:\n      errorf-multi: true\n    forbidigo:\n      forbid:\n        - pattern: context\\.WithCancel$\n        - pattern: ^print.*$\n        - pattern: panic\n        - pattern: ^log.Fatal().*$\n    godot:\n      scope: toplevel\n      exclude:\n        - '^\\s*cSpell:'\n        - '^\\s*TODO:'\n      capital: true\n      period: true\n    importas:\n      no-extra-aliases: true\n      alias:\n        # stdlib\n        - pkg: log\n          alias: std_log\n\n        # grpc / protobuf\n        - pkg: google.golang.org/grpc/metadata\n          alias: grpc_metadata\n        - pkg: google.golang.org/grpc/credentials\n          alias: grpc_credentials\n        - pkg: google.golang.org/protobuf/proto\n          alias: grpc_proto\n\n        # woodpecker internal\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\n          alias: backend_types\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\n          alias: pipeline_errors\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime\n          alias: pipeline_runtime\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/utils\n          alias: pipeline_utils\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/store/types\n          alias: store_types\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/forge/types\n          alias: forge_types\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/log\n          alias: service_log\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/rpc\n          alias: server_rpc\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/agent/rpc\n          alias: agent_rpc\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/shared/utils\n          alias: shared_utils\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\n          alias: pipeline_metadata\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\n          alias: yaml_base_types\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\n          alias: yaml_types\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/cron\n          alias: cron_scheduler\n\n        # mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks\n          alias: secret_service_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks\n          alias: registry_service_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\n          alias: manager_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\n          alias: forge_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks\n          alias: tracer_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks\n          alias: queue_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\n          alias: store_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks\n          alias: config_service_mocks\n        - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks\n          alias: log_mocks\n\n        # kubernetes\n        - pkg: k8s.io/api/core/v1\n          alias: kube_core_v1\n        - pkg: k8s.io/apimachinery/pkg/apis/meta/v1\n          alias: kube_meta_v1\n        - pkg: k8s.io/apimachinery/pkg/api/errors\n          alias: kube_errors\n        - pkg: k8s.io/client-go/tools/clientcmd\n          alias: kube_client_cmd\n\n        # docker\n        - pkg: github.com/docker/cli/cli/config/types\n          alias: docker_config_types\n\n        # misc third-party\n        - pkg: github.com/swaggo/files\n          alias: swaggo_files\n        - pkg: github.com/swaggo/gin-swagger\n          alias: swaggo_gin_swagger\n        - pkg: xorm.io/xorm/log\n          alias: xlog\n        - pkg: github.com/tink-crypto/tink-go/v2/insecurecleartextkeyset\n          alias: insecure_clear_text_keyset\n        - pkg: github.com/migueleliasweb/go-github-mock/src/mock\n          alias: github_mock\n        - pkg: gitlab.com/gitlab-org/api/client-go/v2\n          alias: gitlab\n    misspell:\n      locale: US\n    mnd:\n      ignored-numbers:\n        - '0o600'\n        - '0o660'\n        - '0o644'\n        - '0o755'\n        - '0o700'\n      ignored-functions:\n        - make\n        - time.*\n        - strings.Split\n        - callerName\n        - random.GetRandomBytes\n    revive:\n      rules:\n        - name: var-naming\n          arguments:\n            - []\n            - []\n            - - skipPackageNameChecks: true\n  exclusions:\n    generated: lax\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    rules:\n      - linters:\n          - mnd\n        path: fixtures|cmd/agent/flags.go|cmd/server/flags.go|pipeline/backend/kubernetes/flags.go|_test.go\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\nformatters:\n  enable:\n    - gci\n    - gofmt\n    - gofumpt\n  settings:\n    gci:\n      sections:\n        - standard\n        - default\n        - prefix(go.woodpecker-ci.org/woodpecker)\n      custom-order: true\n    gofmt:\n      simplify: true\n      rewrite-rules:\n        - pattern: interface{}\n          replacement: any\n    gofumpt:\n      extra-rules: true\n  exclusions:\n    generated: lax\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": ".hadolint.yaml",
    "content": "ignored:\n  - DL3018 # pin versions in Dockerfile\n"
  },
  {
    "path": ".lycheeignore",
    "content": "https://stackoverflow.com/*\n"
  },
  {
    "path": ".markdownlint.yaml",
    "content": "# markdownlint YAML configuration\n# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml\n\n# Default state for all rules\ndefault: true\n\n# Path to configuration file to extend\nextends: null\n\n# MD003/heading-style/header-style - Heading style\nMD003:\n  # Heading style\n  style: 'atx'\n\n# MD004/ul-style - Unordered list style\nMD004:\n  style: 'dash'\n\n# MD007/ul-indent - Unordered list indentation\nMD007:\n  # Spaces for indent\n  indent: 2\n  # Whether to indent the first level of the list\n  start_indented: false\n\n# MD009/no-trailing-spaces - Trailing spaces\nMD009:\n  # Spaces for line break\n  br_spaces: 2\n  # Allow spaces for empty lines in list items\n  list_item_empty_lines: false\n  # Include unnecessary breaks\n  strict: false\n\n# MD010/no-hard-tabs - Hard tabs\nMD010:\n  # Include code blocks\n  code_blocks: true\n\n# MD012/no-multiple-blanks - Multiple consecutive blank lines\nMD012:\n  # Consecutive blank lines\n  maximum: 1\n\n# MD013/line-length - Line length\nMD013:\n  # Number of characters\n  line_length: 500\n  # Number of characters for headings\n  heading_line_length: 100\n  # Number of characters for code blocks\n  code_block_line_length: 80\n  # Include code blocks\n  code_blocks: false\n  # Include tables\n  tables: false\n  # Include headings\n  headings: true\n  # Strict length checking\n  strict: false\n  # Stern length checking\n  stern: false\n\n# MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines\nMD022:\n  # Blank lines above heading\n  lines_above: 1\n  # Blank lines below heading\n  lines_below: 1\n\n# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content\nMD024:\n  # Only check sibling headings\n  siblings_only: true\n\n# MD025/single-title/single-h1 - Multiple top-level headings in the same document\nMD025:\n  # Heading level\n  level: 1\n  # RegExp for matching title in front matter\n  front_matter_title: \"^\\\\s*title\\\\s*[:=]\"\n\n# MD026/no-trailing-punctuation - Trailing punctuation in heading\nMD026:\n  # Punctuation characters\n  punctuation: '.,;:!。，；：！'\n\n# MD029/ol-prefix - Ordered list item prefix\nMD029:\n  # List style\n  style: 'one_or_ordered'\n\n# MD030/list-marker-space - Spaces after list markers\nMD030:\n  # Spaces for single-line unordered list items\n  ul_single: 1\n  # Spaces for single-line ordered list items\n  ol_single: 1\n  # Spaces for multi-line unordered list items\n  ul_multi: 1\n  # Spaces for multi-line ordered list items\n  ol_multi: 1\n\n# MD033/no-inline-html - Inline HTML\nMD033:\n  # Allowed elements\n  allowed_elements: [details, summary, img, a, br, p]\n\n# MD035/hr-style - Horizontal rule style\nMD035:\n  # Horizontal rule style\n  style: '---'\n\n# MD036/no-emphasis-as-heading/no-emphasis-as-header - Emphasis used instead of a heading\nMD036:\n  # Punctuation characters\n  punctuation: '.,;:!?。，；：！？'\n\n# MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading\nMD041:\n  # Heading level\n  level: 1\n  # RegExp for matching title in front matter\n  front_matter_title: \"^\\\\s*title\\\\s*[:=]\"\n\n# MD044/proper-names - Proper names should have the correct capitalization\nMD044:\n  # List of proper names\n  # names:\n  # Include code blocks\n  code_blocks: false\n\n# MD046/code-block-style - Code block style\nMD046:\n  # Block style\n  style: 'fenced'\n\n# MD048/code-fence-style - Code fence style\nMD048:\n  # Code fence style\n  style: 'backtick'\n\nMD059: false\n"
  },
  {
    "path": ".mockery.yaml",
    "content": "---\nall: true\ndir: '{{.InterfaceDir}}/mocks'\nfilename: mock_{{.InterfaceName}}.go\npkgname: mocks\nrecursive: true\npackages:\n  go.woodpecker-ci.org/woodpecker/v3/rpc:\n    config:\n      recursive: false\n  go.woodpecker-ci.org/woodpecker/v3/server/forge:\n  go.woodpecker-ci.org/woodpecker/v3/server/queue:\n  go.woodpecker-ci.org/woodpecker/v3/server/services:\n    config:\n      exclude-subpkg-regex:\n        - types\n  go.woodpecker-ci.org/woodpecker/v3/server/services/config:\n  go.woodpecker-ci.org/woodpecker/v3/server/services/environment:\n  go.woodpecker-ci.org/woodpecker/v3/server/services/registry:\n  go.woodpecker-ci.org/woodpecker/v3/server/services/secret:\n  go.woodpecker-ci.org/woodpecker/v3/server/store:\n  go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker:\n  go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing:\n  go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types:\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# cSpell:ignore checkmake hadolint autofix autoupdate\nrepos:\n  - repo: meta\n    hooks:\n      - id: check-hooks-apply\n      - id: check-useless-excludes\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: end-of-file-fixer\n        exclude: '\\.sql$'\n      - id: trailing-whitespace\n        exclude: ^docs/versioned_docs/.+/40-cli.md$\n  - repo: https://github.com/golangci/golangci-lint\n    rev: v2.12.2\n    hooks:\n      - id: golangci-lint\n  - repo: https://github.com/igorshubovych/markdownlint-cli\n    rev: v0.48.0\n    hooks:\n      - id: markdownlint\n        exclude: '^(docs/versioned_docs/.*|CHANGELOG.md)$'\n        language_version: 24.14.0\n  - repo: https://github.com/mrtazz/checkmake\n    rev: v0.3.2\n    hooks:\n      - id: checkmake\n        exclude: '^docker/Dockerfile.make$' # actually a Dockerfile and not a makefile\n  - repo: https://github.com/hadolint/hadolint\n    rev: v2.14.0\n    hooks:\n      - id: hadolint\n  - repo: https://github.com/rbubley/mirrors-prettier\n    rev: v3.8.3\n    hooks:\n      - id: prettier\n  - repo: https://github.com/adrienverge/yamllint.git\n    rev: v1.38.0\n    hooks:\n      - id: yamllint\n        args: [--strict, -c=.yamllint.yaml]\n  - repo: local\n    hooks:\n      - id: yaml-file-extension\n        name: Check if YAML files has *.yaml extension.\n        entry: YAML filenames must have .yaml extension.\n        language: fail\n        files: .yml$\n        exclude: '^(.gitpod.yml|.github/ISSUE_TEMPLATE/config.yml)$'\n\nci:\n  autofix_commit_msg: |\n    [pre-commit.ci] auto fixes from pre-commit.com hooks [CI SKIP]\n\n    for more information, see https://pre-commit.ci\n  autofix_prs: true\n  autoupdate_branch: ''\n  autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate'\n  autoupdate_schedule: quarterly\n  # NB: hadolint not included in pre-commit.ci\n  skip: [check-hooks-apply, check-useless-excludes, hadolint, prettier, golangci-lint]\n  submodules: false\n"
  },
  {
    "path": ".prettierignore",
    "content": "build/\ndist/\nCHANGELOG.md\n\n# web/ and docs/ must be directly formatted from there\n# to prevent conflicts with different prettier version\nweb/\ndocs/\n"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"trailingComma\": \"all\",\n  \"singleQuote\": true,\n  \"printWidth\": 120,\n  \"tabWidth\": 2,\n  \"endOfLine\": \"lf\"\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  // List of extensions which should be recommended for users of this workspace.\n  \"recommendations\": [\n    \"golang.go\",\n    \"EditorConfig.EditorConfig\",\n    \"dbaeumer.vscode-eslint\",\n    \"esbenp.prettier-vscode\",\n    \"bradlc.vscode-tailwindcss\",\n    \"Vue.volar\",\n    \"redhat.vscode-yaml\",\n    \"davidanson.vscode-markdownlint\",\n    \"stivo.tailwind-fold\"\n  ],\n  // List of extensions recommended by VS Code that should not be recommended for users of this workspace.\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"compounds\": [\n    {\n      \"name\": \"Woodpecker CI\",\n      \"configurations\": [\"Woodpecker UI\", \"Woodpecker server\", \"Woodpecker agent\"],\n      \"stopAll\": true\n    }\n  ],\n  \"configurations\": [\n    {\n      \"name\": \"Woodpecker server\",\n      \"type\": \"go\",\n      \"request\": \"launch\",\n      \"mode\": \"debug\",\n      \"program\": \"${workspaceFolder}/cmd/server/\",\n      \"cwd\": \"${workspaceFolder}\"\n    },\n    {\n      \"name\": \"Woodpecker agent\",\n      \"type\": \"go\",\n      \"request\": \"launch\",\n      \"mode\": \"debug\",\n      \"program\": \"${workspaceFolder}/cmd/agent/\",\n      \"cwd\": \"${workspaceFolder}\"\n    },\n    {\n      \"name\": \"Go: current file\",\n      \"type\": \"go\",\n      \"request\": \"launch\",\n      \"mode\": \"debug\",\n      \"console\": \"integratedTerminal\",\n      \"envFile\": \"${workspaceFolder}/.env\",\n      \"cwd\": \"${workspaceFolder}\",\n      \"program\": \"${file}\"\n    },\n    {\n      \"name\": \"Woodpecker UI\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"pnpm\",\n      \"runtimeArgs\": [\"start\"],\n      \"cwd\": \"${workspaceFolder}/web\",\n      \"resolveSourceMapLocations\": [\"${workspaceFolder}/web/**\", \"!**/node_modules/**\"],\n      \"envFile\": \"${workspaceFolder}/.env\",\n      \"skipFiles\": [\"<node_internals>/**\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"git.ignoreLimitWarning\": true,\n  \"search.exclude\": {\n    \"**/node_modules\": true,\n    \"**/bower_components\": true,\n    \"**/*.code-search\": true,\n    \"vendor/\": true\n  },\n  \"go.lintTool\": \"golangci-lint\",\n  \"go.lintFlags\": [\"--fast\"],\n  \"go.buildTags\": \"test\",\n  \"eslint.workingDirectories\": [\"./web\"],\n  \"prettier.ignorePath\": \"./web/.prettierignore\",\n  // Auto fix\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": \"explicit\",\n    \"source.organizeImports\": \"never\"\n  }\n}\n"
  },
  {
    "path": ".woodpecker/binaries.yaml",
    "content": "when:\n  - event: tag\n  - event: pull_request\n    branch: ${CI_REPO_DEFAULT_BRANCH}\n    path:\n      - Makefile\n      - .woodpecker/binaries.yaml\n\nvariables:\n  - &golang_image 'docker.io/golang:1.26'\n  - &node_image 'docker.io/node:24-alpine'\n  - &xgo_image 'docker.io/techknowlogick/xgo:go-1.26.x'\n\n# cspell:words bindata netgo\n\nsteps:\n  build-web:\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm install --frozen-lockfile\n      - pnpm build\n\n  vendor:\n    image: *golang_image\n    commands:\n      - go mod vendor\n\n  cross-compile-server:\n    depends_on:\n      - vendor\n      - build-web\n    image: *xgo_image\n    pull: true\n    commands:\n      - apt update\n      - apt install -y tree\n      - make cross-compile-server\n    environment:\n      PLATFORMS: linux|arm64/v8;linux|amd64;linux|riscv64;windows|amd64\n      TAGS: bindata sqlite sqlite_unlock_notify netgo\n      ARCHIVE_IT: '1'\n\n  build-tarball:\n    depends_on:\n      - vendor\n      - build-web\n    image: *golang_image\n    commands:\n      - make build-tarball\n\n  build-agent:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - apt update\n      - apt install -y zip\n      - make release-agent\n\n  build-cli:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - apt update\n      - apt install -y zip\n      - make release-cli\n\n  build-deb-rpm:\n    depends_on:\n      - cross-compile-server\n      - build-agent\n      - build-cli\n    image: *golang_image\n    commands:\n      - make bundle\n\n  checksums:\n    depends_on:\n      - cross-compile-server\n      - build-agent\n      - build-cli\n      - build-deb-rpm\n      - build-tarball\n    image: *golang_image\n    commands:\n      - make release-checksums\n\n  release-dryrun:\n    depends_on:\n      - checksums\n    image: *golang_image\n    commands:\n      - ls -la dist/*.*\n      - cat dist/checksums.txt\n\n  release:\n    depends_on:\n      - checksums\n    image: woodpeckerci/plugin-release:0.3.1\n    settings:\n      api_key:\n        from_secret: github_token\n      files:\n        - dist/*.tar.gz\n        - dist/*.zip\n        - dist/*.deb\n        - dist/*.rpm\n        - dist/checksums.txt\n      title: ${CI_COMMIT_TAG##v}\n    when:\n      event: tag\n"
  },
  {
    "path": ".woodpecker/check-feature-docs.sh",
    "content": "#!/bin/sh\nDOCS_CHANGED=$(echo \"$CI_PIPELINE_FILES\" | jq -r '.[]' | grep -c '^docs/docs/' || true)\nif [ \"$DOCS_CHANGED\" -gt 0 ]; then\n  echo \"✅ OK: docs/docs/ has changes\"\n  exit 0\nfi\nNON_CLI=$(echo \"$CI_PIPELINE_FILES\" | jq -r '.[]' | grep -v '^cli/' | grep -v '^cmd/cli/' | grep -v '^docs/' || true)\nif [ -z \"$NON_CLI\" ]; then\n  echo \"✅ OK: CLI-only feature, docs are auto-generated\"\n  exit 0\nfi\necho \"🚨 ERROR: PR has 'feature' label but no changes in docs/docs/\"\necho \"Please add documentation for the new feature.\"\nexit 1\n"
  },
  {
    "path": ".woodpecker/docker.yaml",
    "content": "variables:\n  - &golang_image 'docker.io/golang:1.26'\n  - &node_image 'docker.io/node:24-alpine'\n  - &xgo_image 'docker.io/techknowlogick/xgo:go-1.26.x'\n  - &buildx_plugin 'docker.io/woodpeckerci/plugin-docker-buildx:6.1.0'\n  - &platforms_release 'linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/386,linux/amd64,linux/ppc64le,linux/riscv64,linux/s390x,freebsd/arm64,freebsd/amd64,openbsd/arm64,openbsd/amd64'\n  - &platforms_server 'linux/arm/v7,linux/arm64/v8,linux/amd64,linux/ppc64le,linux/riscv64'\n  - &platforms_preview 'linux/amd64'\n  - &platforms_alpine 'linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64,linux/ppc64le'\n  - &build_args 'CI_COMMIT_SHA=${CI_COMMIT_SHA},CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH},CI_COMMIT_TAG=${CI_COMMIT_TAG}'\n\n  # cspell:words woodpeckerbot netgo\n\n  # vars used on push / tag events only\n  - publish_logins: &publish_logins # Default DockerHub login\n      - registry: https://index.docker.io/v1/\n        username: woodpeckerbot\n        password:\n          from_secret: docker_password\n      # Additional Quay.IO login\n      - registry: https://quay.io\n        username: 'woodpeckerci+wp_ci'\n        password:\n          from_secret: QUAY_IO_TOKEN\n  - &publish_repos_server 'woodpeckerci/woodpecker-server,quay.io/woodpeckerci/woodpecker-server'\n  - &publish_repos_agent 'woodpeckerci/woodpecker-agent,quay.io/woodpeckerci/woodpecker-agent'\n  - &publish_repos_cli 'woodpeckerci/woodpecker-cli,quay.io/woodpeckerci/woodpecker-cli'\n  - path: &when_path # web source code\n      - 'web/**'\n      # api source code\n      - 'server/api/**'\n      # go source code\n      - '**/*.go'\n      - 'go.*'\n      # schema changes\n      - 'pipeline/schema/**'\n      # Dockerfile changes\n      - 'docker/**'\n      # pipeline config changes\n      - '.woodpecker/docker.yaml'\n\nwhen:\n  - event: [pull_request, tag]\n  - event: push\n    branch: ${CI_REPO_DEFAULT_BRANCH}\n    path: *when_path\n  - event: pull_request_metadata\n    evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\"'\n\nsteps:\n  vendor:\n    image: *golang_image\n    pull: true\n    commands:\n      - go mod vendor\n    when:\n      - event: [pull_request, pull_request_metadata]\n        evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\"'\n      - event: pull_request\n        path: *when_path\n      - branch:\n          - ${CI_REPO_DEFAULT_BRANCH}\n        event: [push, tag]\n        path: *when_path\n\n  ###############\n  # S e r v e r #\n  ###############\n  build-web:\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm install --frozen-lockfile\n      - pnpm build\n    when:\n      - event: [pull_request, pull_request_metadata]\n        evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\"'\n      - event: pull_request\n        path: *when_path\n      - branch:\n          - ${CI_REPO_DEFAULT_BRANCH}\n        event: [push, tag]\n        path: *when_path\n\n  cross-compile-server-preview:\n    depends_on:\n      - vendor\n      - build-web\n    image: *xgo_image\n    pull: true\n    commands:\n      - apt update\n      - apt install -y tree\n      - make cross-compile-server\n    environment:\n      PLATFORMS: linux|amd64\n      TAGS: sqlite sqlite_unlock_notify netgo\n    when:\n      - event: [pull_request, pull_request_metadata]\n        evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\"'\n      - event: pull_request\n        path: *when_path\n\n  cross-compile-server:\n    depends_on:\n      - vendor\n      - build-web\n    image: *xgo_image\n    pull: true\n    commands:\n      - apt update\n      - apt install -y tree\n      - make cross-compile-server\n    environment:\n      PLATFORMS: linux|arm/v7;linux|arm64/v8;linux|amd64;linux|ppc64le;linux|riscv64\n      TAGS: sqlite sqlite_unlock_notify netgo\n    when:\n      branch:\n        - ${CI_REPO_DEFAULT_BRANCH}\n      event: [push, tag]\n      path: *when_path\n\n  publish-server-alpine-preview:\n    depends_on:\n      - cross-compile-server-preview\n    image: *buildx_plugin\n    settings:\n      repo: woodpeckerci/woodpecker-server\n      dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine\n      logins: *publish_logins\n    when: &when-preview\n      evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\"'\n      event: [pull_request, pull_request_metadata]\n\n  build-server-dryrun:\n    depends_on:\n      - vendor\n      - build-web\n      - cross-compile-server-preview\n    image: *buildx_plugin\n    settings:\n      dry_run: true\n      repo: woodpeckerci/woodpecker-server\n      dockerfile: docker/Dockerfile.server.multiarch.rootless\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}\n    when: &when-dryrun\n      - evaluate: 'not (CI_COMMIT_PULL_REQUEST_LABELS contains \"build_pr_images\")'\n        event: pull_request\n        path: *when_path\n\n  publish-next-server:\n    depends_on:\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_server\n      dockerfile: docker/Dockerfile.server.multiarch.rootless\n      platforms: *platforms_server\n      tag: [next, 'next-${CI_COMMIT_SHA:0:10}']\n      logins: *publish_logins\n    when: &when-publish-next\n      branch: ${CI_REPO_DEFAULT_BRANCH}\n      event: push\n      path: *when_path\n\n  publish-next-server-alpine:\n    depends_on:\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_server\n      dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless\n      platforms: *platforms_alpine\n      tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine']\n      logins: *publish_logins\n    when: *when-publish-next\n\n  release-server:\n    depends_on:\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_server\n      dockerfile: docker/Dockerfile.server.multiarch.rootless\n      platforms: *platforms_server\n      tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}']\n      logins: *publish_logins\n    when: &when-release\n      event: tag\n\n  release-server-alpine:\n    depends_on:\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_server\n      dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless\n      platforms: *platforms_alpine\n      tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine']\n      logins: *publish_logins\n    when: *when-release\n\n  #############\n  # A g e n t #\n  #############\n\n  publish-agent-preview-alpine:\n    depends_on:\n      - vendor\n    image: *buildx_plugin\n    settings:\n      repo: woodpeckerci/woodpecker-agent\n      dockerfile: docker/Dockerfile.agent.alpine.multiarch\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine\n      build_args: *build_args\n      logins: *publish_logins\n    when: *when-preview\n\n  build-agent-dryrun:\n    depends_on:\n      - vendor\n    image: *buildx_plugin\n    settings:\n      dry_run: true\n      repo: woodpeckerci/woodpecker-agent\n      dockerfile: docker/Dockerfile.agent.multiarch\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}\n      build_args: *build_args\n    when: *when-dryrun\n\n  publish-next-agent:\n    depends_on:\n      - vendor\n      # we also depend on cross-compile-server as we would have to hight\n      # ram usage otherwise\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_agent\n      dockerfile: docker/Dockerfile.agent.multiarch\n      platforms: *platforms_release\n      buildkit_oci_max_parallelism: 6\n      tag: [next, 'next-${CI_COMMIT_SHA:0:10}']\n      logins: *publish_logins\n      build_args: *build_args\n    when:\n      branch: ${CI_REPO_DEFAULT_BRANCH}\n      event: push\n      path: *when_path\n\n  publish-next-agent-alpine:\n    depends_on:\n      - vendor\n      # we also depend on cross-compile-server as we would have to hight\n      # ram usage otherwise\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_agent\n      dockerfile: docker/Dockerfile.agent.alpine.multiarch\n      platforms: *platforms_alpine\n      tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-publish-next\n\n  release-agent:\n    depends_on:\n      - vendor\n      # we also depend on cross-compile-server as we would have to hight\n      # ram usage otherwise\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_agent\n      dockerfile: docker/Dockerfile.agent.multiarch\n      platforms: *platforms_release\n      buildkit_oci_max_parallelism: 6\n      tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-release\n\n  release-agent-alpine:\n    depends_on:\n      - vendor\n      # we also depend on cross-compile-server as we would have to hight\n      # ram usage otherwise\n      - cross-compile-server\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_agent\n      dockerfile: docker/Dockerfile.agent.alpine.multiarch\n      platforms: *platforms_alpine\n      tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-release\n\n  #########\n  # C L I #\n  #########\n\n  build-cli-alpine-preview:\n    depends_on:\n      - vendor\n    image: *buildx_plugin\n    settings:\n      repo: woodpeckerci/woodpecker-cli\n      dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine\n      build_args: *build_args\n      logins: *publish_logins\n    when: *when-preview\n\n  build-cli-dryrun:\n    depends_on:\n      - vendor\n    image: *buildx_plugin\n    settings:\n      dry_run: true\n      repo: woodpeckerci/woodpecker-cli\n      dockerfile: docker/Dockerfile.cli.multiarch.rootless\n      platforms: *platforms_preview\n      tag: pull_${CI_COMMIT_PULL_REQUEST}\n      build_args: *build_args\n    when: *when-dryrun\n\n  publish-next-cli:\n    depends_on:\n      - vendor\n      # we also depend on publish-next-agent as we would have to hight\n      # ram usage otherwise\n      - publish-next-agent\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_cli\n      dockerfile: docker/Dockerfile.cli.multiarch.rootless\n      platforms: *platforms_release\n      buildkit_oci_max_parallelism: 6\n      tag: [next, 'next-${CI_COMMIT_SHA:0:10}']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-publish-next\n\n  publish-next-cli-alpine:\n    depends_on:\n      - vendor\n      # we also depend on publish-next-agent as we would have to hight\n      # ram usage otherwise\n      - publish-next-agent\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_cli\n      dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless\n      platforms: *platforms_alpine\n      tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-publish-next\n\n  release-cli:\n    depends_on:\n      - vendor\n      # we also depend on release-agent as we would have to hight\n      # ram usage otherwise\n      - release-agent\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_cli\n      dockerfile: docker/Dockerfile.cli.multiarch.rootless\n      platforms: *platforms_release\n      buildkit_oci_max_parallelism: 6\n      tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-release\n\n  release-cli-alpine:\n    depends_on:\n      - vendor\n      # we also depend on release-agent as we would have to hight\n      # ram usage otherwise\n      - release-agent\n    image: *buildx_plugin\n    settings:\n      repo: *publish_repos_cli\n      dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless\n      platforms: *platforms_alpine\n      tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine']\n      logins: *publish_logins\n      build_args: *build_args\n    when: *when-release\n"
  },
  {
    "path": ".woodpecker/docs.yaml",
    "content": "variables:\n  - &golang_image 'docker.io/golang:1.26'\n  - &node_image 'docker.io/node:24-alpine'\n  - &alpine_image 'docker.io/alpine:3.23'\n  - path: &when_path\n      - 'docs/**'\n      - '.woodpecker/docs.yaml'\n      # since we generate docs for cli tool we have to watch this too\n      - 'cli/**'\n      - 'cmd/cli/**'\n      # api docs\n      - 'server/api/**'\n  - path: &docker_path # web source code\n      - 'web/**'\n      # api source code\n      - 'server/api/**'\n      # go source code\n      - '**/*.go'\n      - 'go.*'\n      # schema changes\n      - 'pipeline/schema/**'\n      # Dockerfile changes\n      - 'docker/**'\n\nwhen:\n  - event: tag\n  - event: pull_request\n  - event: push\n    path:\n      - <<: *when_path\n      - <<: *docker_path\n    branch:\n      - ${CI_REPO_DEFAULT_BRANCH}\n  - event: pull_request_closed\n    path: *when_path\n  - event: manual\n    evaluate: 'TASK == \"docs\"'\n\nsteps:\n  - name: install-dependencies\n    image: *node_image\n    directory: docs/\n    commands:\n      - corepack enable\n      - pnpm install --frozen-lockfile\n    when:\n      - path: *when_path\n        event: [tag, pull_request, push]\n      - event: manual\n\n  - name: format-check\n    image: *node_image\n    directory: docs/\n    commands:\n      - corepack enable\n      - pnpm format:check\n    when:\n      - path: *when_path\n        event: pull_request\n\n  - name: build-cli\n    image: *golang_image\n    commands:\n      - make generate-docs\n    when:\n      - path: *when_path\n        event: [tag, pull_request, push]\n      - event: manual\n\n  - name: build\n    image: *node_image\n    directory: docs/\n    commands:\n      - corepack enable\n      - pnpm build\n    when:\n      - path: *when_path\n        event: [tag, pull_request, push]\n      - event: manual\n\n  - name: deploy-preview\n    image: docker.io/woodpeckerci/plugin-surge-preview:1.4.2\n    settings:\n      path: 'docs/build/'\n      surge_token:\n        from_secret: SURGE_TOKEN\n      forge_repo_token:\n        from_secret: GITHUB_TOKEN_SURGE\n    failure: ignore\n    when:\n      - event: [pull_request, pull_request_closed]\n        path: *when_path\n\n  - name: deploy-prepare\n    image: *alpine_image\n    environment:\n      BOT_PRIVATE_KEY:\n        from_secret: BOT_PRIVATE_KEY\n    commands:\n      - apk add openssh-client git\n      - mkdir -p $HOME/.ssh\n      - ssh-keyscan -t rsa github.com >> $HOME/.ssh/known_hosts\n      - echo \"$BOT_PRIVATE_KEY\" > $HOME/.ssh/id_rsa\n      - chmod 0600 $HOME/.ssh/id_rsa\n      - git clone --depth 1 --single-branch git@github.com:woodpecker-ci/woodpecker-ci.github.io.git ./docs_repo\n    when:\n      - event: push\n        path:\n          - <<: *when_path\n          - <<: *docker_path\n        branch: ${CI_REPO_DEFAULT_BRANCH}\n      - event: [manual, tag]\n\n  # update latest and next version\n  - name: version-next\n    image: *alpine_image\n    commands:\n      - apk add jq\n      - jq '.next = \"next-${CI_COMMIT_SHA:0:10}\"' ./docs_repo/version.json > ./docs_repo/version.json.tmp\n      - mv ./docs_repo/version.json.tmp ./docs_repo/version.json\n    when:\n      - event: push\n        path: *docker_path\n        branch: ${CI_REPO_DEFAULT_BRANCH}\n\n  - name: version-release\n    image: *alpine_image\n    commands:\n      - apk add jq\n      - if [[ \"${CI_COMMIT_TAG}\" != *\"rc\"* ]] ; then jq '.latest = \"${CI_COMMIT_TAG}\"' ./docs_repo/version.json > ./docs_repo/version.json.tmp && mv ./docs_repo/version.json.tmp ./docs_repo/version.json ; fi\n      - jq '.rc = \"${CI_COMMIT_TAG}\"' ./docs_repo/version.json > ./docs_repo/version.json.tmp\n      - mv ./docs_repo/version.json.tmp ./docs_repo/version.json\n    when:\n      - event: tag\n\n  - name: copy-files\n    image: *alpine_image\n    commands:\n      - apk add rsync\n      # copy all docs files and delete all old ones, but leave CNAME, index.yaml and version.json untouched\n      - rsync -r --exclude .git --exclude CNAME --exclude index.yaml --exclude README.md --exclude version.json --delete docs/build/ ./docs_repo\n    when:\n      - event: push\n        path: *when_path\n        branch: ${CI_REPO_DEFAULT_BRANCH}\n      - event: manual\n\n  - name: deploy\n    image: *alpine_image\n    environment:\n      BOT_PRIVATE_KEY:\n        from_secret: BOT_PRIVATE_KEY\n    commands:\n      - apk add openssh-client rsync git\n      - mkdir -p $HOME/.ssh\n      - ssh-keyscan -t rsa github.com >> $HOME/.ssh/known_hosts\n      - echo \"$BOT_PRIVATE_KEY\" > $HOME/.ssh/id_rsa\n      - chmod 0600 $HOME/.ssh/id_rsa\n      - git config --global user.email \"woodpecker-bot@obermui.de\"\n      - git config --global user.name \"woodpecker-bot\"\n      - cd ./docs_repo\n      - git add .\n      # exit successfully if nothing changed\n      - test -n \"$(git status --porcelain)\" || exit 0\n      - git commit -m \"Deploy website - based on ${CI_COMMIT_SHA}\"\n      - git push\n    when:\n      - event: push\n        path:\n          - <<: *when_path\n          - <<: *docker_path\n        branch: ${CI_REPO_DEFAULT_BRANCH}\n      - event: [manual, tag]\n"
  },
  {
    "path": ".woodpecker/links.yaml",
    "content": "when:\n  - event: cron\n    cron: links\n\nsteps:\n  - name: links\n    image: docker.io/lycheeverse/lychee:0.24.2\n    failure: ignore\n    depends_on: []\n    commands:\n      - lychee pipeline/frontend/yaml/linter/schema/schema.json > links.md\n      - lychee --exclude localhost docs/docs/ >> links.md\n      - lychee --exclude localhost docs/src/pages/ >> links.md\n      - echo -e \"\\nLast checked:$(date)\" >> links.md\n\n  - name: Update issue\n    image: docker.io/alpine:3.23\n    depends_on: links\n    environment:\n      GITHUB_TOKEN:\n        from_secret: github_token\n    commands:\n      - apk add -q --no-cache jq curl\n      - export ISSUE_NUMBER=5326\n      - export DESCRIPTION=$(cat links.md)\n      - |\n        curl -X PATCH \\\n          -H \"Authorization: token $GITHUB_TOKEN\" \\\n          -H \"Accept: application/vnd.github.v3+json\" \\\n          https://api.github.com/repos/${CI_REPO}/issues/$ISSUE_NUMBER \\\n          -d \"$(jq -n --arg body \"$DESCRIPTION\" '{body: $body}')\"\n"
  },
  {
    "path": ".woodpecker/release-helper.yaml",
    "content": "when:\n  - event: push\n    branch:\n      - ${CI_REPO_DEFAULT_BRANCH}\n      - release/*\n\nsteps:\n  - name: release-helper\n    image: docker.io/woodpeckerci/plugin-ready-release-go:4.1.1\n    settings:\n      release_branch: ${CI_COMMIT_BRANCH}\n      forge_type: github\n      git_email: woodpecker-bot@obermui.de\n      github_token:\n        from_secret: GITHUB_TOKEN\n"
  },
  {
    "path": ".woodpecker/securityscan.yaml",
    "content": "when:\n  - event: [pull_request]\n  - event: push\n    branch:\n      - ${CI_REPO_DEFAULT_BRANCH}\n\nvariables:\n  - &trivy_plugin docker.io/woodpeckerci/plugin-trivy:1.4.5\n\nsteps:\n  backend:\n    depends_on: []\n    image: *trivy_plugin\n    settings:\n      server: server\n      skip-dirs: web/,docs/\n\n  docs:\n    depends_on: []\n    image: *trivy_plugin\n    settings:\n      server: server\n      skip-dirs: node_modules/,plugins/woodpecker-plugins/node_modules/\n      dir: docs/\n\n  web:\n    depends_on: []\n    image: *trivy_plugin\n    settings:\n      server: server\n      skip-dirs: node_modules/\n      dir: web/\n\nservices:\n  server:\n    image: *trivy_plugin\n    failure: ignore # as we don't care about the exit code\n    settings:\n      service: true\n      db-repository: mirror.gcr.io/aquasec/trivy-db:2\n    ports:\n      - 10000\n"
  },
  {
    "path": ".woodpecker/social.yaml",
    "content": "depends_on:\n  - docker\n  - binaries\n\nwhen:\n  - event: tag\n    evaluate: 'CI_COMMIT_TAG matches \"^v?[0-9]+\\\\\\\\.[0-9]+\\\\\\\\.[0-9]+$\"'\n\nsteps:\n  - name: mastodon-toot\n    image: docker.io/woodpeckerci/plugin-mastodon-post\n    settings:\n      server_url: https://floss.social\n      access_token:\n        from_secret: mastodon_token\n      visibility: public\n      ai_token:\n        from_secret: openai_token\n      ai_prompt: |\n        We want to present the next version of our app on Mastodon.\n        Therefore we want to post a catching text, so users will know why they should\n        update to the newest version. Highlight the most special features. If there is no special feature\n        included just summarize the changes in a few sentences. The whole text should not be longer than 240\n        characters. Avoid naming contributors from. Use #WoodpeckerCI, #release and\n        additional fitting hashtags and emojis to make the post more appealing\n\n        The changelog entry: {{ changelog }}\n\n  - name: bluesky-post\n    image: docker.io/woodpeckerci/plugin-bluesky-post\n    settings:\n      app_password:\n        from_secret: bluesky_token\n      identifier: woodpecker-ci.org\n      ai_token:\n        from_secret: openai_token\n      ai_prompt: |\n        We want to present the next version of our app on Mastodon.\n        Therefore we want to post a catching text, so users will know why they should\n        update to the newest version. Highlight the most special features. If there is no special feature\n        included just summarize the changes in a few sentences. The whole text should not be longer than 240\n        characters. Avoid naming contributors from. Use #WoodpeckerCI, #release and\n        additional fitting hashtags and emojis to make the post more appealing\n\n        The changelog entry: {{ changelog }}\n"
  },
  {
    "path": ".woodpecker/static.yaml",
    "content": "when:\n  - event: pull_request\n\nsteps:\n  - name: lint-editorconfig\n    image: docker.io/woodpeckerci/plugin-editorconfig-checker:0.3.3\n    depends_on: []\n    when:\n      - event: pull_request\n\n  - name: spellcheck\n    image: docker.io/node:24-alpine\n    depends_on: []\n    commands:\n      - corepack enable\n      - pnpx cspell lint --no-progress --gitignore '{**,.*}/{*,.*}'\n      - apk add --no-cache -U tree # busybox tree don't understand \"-I\"\n      # cspell:disable-next-line\n      - tree --gitignore -I 012_columns_rename_procs_to_steps.go -I versioned_docs -I '*opensource.svg'| pnpx cspell lint --no-progress stdin\n\n  - name: prettier\n    image: docker.io/woodpeckerci/plugin-prettier:next\n    pull: true\n    depends_on: []\n    settings:\n      version: 3.6.2\n\n  - name: check-feature-docs\n    image: docker.io/alpine:3.23\n    depends_on: []\n    commands:\n      - apk add --no-cache -q jq && ./.woodpecker/check-feature-docs.sh\n    when:\n      - event: pull_request\n        evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"feature\"'\n\n  - name: agentscan\n    image: docker.io/woodpeckerci/plugin-agentscan:latest\n    pull: true\n    depends_on: []\n    settings:\n      github_token:\n        from_secret: GITHUB_TOKEN_SURGE\n      allowlist:\n        - woodpecker-bot\n        - 'renovate[bot]'\n        - 6543\n        - anbraten\n        - lafriks\n        - qwerty287\n        - xoxys\n    when:\n      - event: pull_request\n"
  },
  {
    "path": ".woodpecker/test.yaml",
    "content": "variables:\n  - &golang_image 'docker.io/golang:1.26'\n  - &when\n    - path: &when_path # related config files\n        - '.woodpecker/test.yaml'\n        - '.golangci.yaml'\n        # go source code\n        - '**/*.go'\n        - 'go.*'\n        # schema changes\n        - 'pipeline/schema/**'\n        # tools updates\n        - Makefile\n        - 'codecov.yaml'\n      event: pull_request\n\nwhen:\n  - event: pull_request\n  - event: push\n    branch: ${CI_REPO_DEFAULT_BRANCH}\n    path: *when_path\n\nsteps:\n  vendor:\n    image: *golang_image\n    commands:\n      - go mod vendor\n    when:\n      path:\n        - <<: *when_path\n        - '.woodpecker/**'\n\n  lint-pipeline:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - go run go.woodpecker-ci.org/woodpecker/v3/cmd/cli lint\n    environment:\n      WOODPECKER_DISABLE_UPDATE_CHECK: true\n      WOODPECKER_LINT_STRICT: true\n      WOODPECKER_PLUGINS_PRIVILEGED: 'docker.io/woodpeckerci/plugin-docker-buildx'\n    when:\n      - event: pull_request\n        path:\n          - '.woodpecker/**'\n\n  dummy-web:\n    image: *golang_image\n    commands:\n      - mkdir -p web/dist/\n      - echo \"test\" > web/dist/index.html\n    when:\n      - path: *when_path\n\n  lint:\n    depends_on:\n      - vendor\n    image: golangci/golangci-lint:v2.12.2\n    commands:\n      - make lint\n    when: *when\n\n  check-openapi:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - 'make generate-openapi'\n      - 'DIFF=$(git diff | head)'\n      - '[ -n \"$DIFF\" ] && { echo \"openapi not up to date, exec `make generate-openapi` and commit\"; exit 1; } || true'\n    when: *when\n\n  lint-license-header:\n    image: *golang_image\n    commands:\n      - make install-addlicense # cspell:words addlicense\n      - bash -c 'shopt -s globstar; addlicense -check -ignore \"vendor/**\" -ignore cmd/server/openapi/docs.go **/*.go'\n    when: *when\n\n  test:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - make test-agent\n      - make test-server\n      - make test-cli\n      - make test-lib\n    when:\n      - path: *when_path\n\n  test-e2e:\n    depends_on:\n      - vendor\n    image: *golang_image\n    commands:\n      - make test-e2e\n    when:\n      - path: *when_path\n\n  sqlite:\n    depends_on:\n      - vendor\n    image: *golang_image\n    environment:\n      WOODPECKER_DATABASE_DRIVER: sqlite3\n    commands:\n      - make test-server-datastore-coverage\n    when:\n      - path: *when_path\n\n  postgres:\n    depends_on:\n      - vendor\n    image: *golang_image\n    environment:\n      WOODPECKER_DATABASE_DRIVER: postgres\n      WOODPECKER_DATABASE_DATASOURCE: 'host=postgres user=postgres dbname=postgres sslmode=disable' # cspell:disable-line\n    commands:\n      - make test-server-datastore\n    when: *when\n\n  mysql:\n    depends_on:\n      - vendor\n    image: *golang_image\n    environment:\n      WOODPECKER_DATABASE_DRIVER: mysql\n      WOODPECKER_DATABASE_DATASOURCE: root@tcp(mysql:3306)/test?parseTime=true\n    commands:\n      - make test-server-datastore\n    when: *when\n\n  codecov:\n    depends_on:\n      - test\n      - sqlite\n    pull: true\n    image: docker.io/woodpeckerci/plugin-codecov:2.3.1\n    settings:\n      files:\n        - agent-coverage.out\n        - cli-coverage.out\n        - coverage.out\n        - server-coverage.out\n        - datastore-coverage.out\n        - e2e-coverage.out\n      token:\n        from_secret: codecov_token\n    when:\n      - path: *when_path\n    failure: ignore\n\nservices:\n  postgres:\n    image: docker.io/postgres:18\n    ports: ['5432']\n    environment:\n      POSTGRES_USER: postgres\n      POSTGRES_HOST_AUTH_METHOD: trust\n    when: *when\n\n  mysql:\n    image: docker.io/mysql:9.7.0\n    ports: ['3306']\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'\n    when: *when\n"
  },
  {
    "path": ".woodpecker/web.yaml",
    "content": "when:\n  - event: pull_request\n  - event: push\n    branch:\n      - release/*\n\nvariables:\n  - &node_image 'docker.io/node:24-alpine'\n  - &when\n    path:\n      # related config files\n      - '.woodpecker/web.yaml'\n      # web source code\n      - 'web/**'\n      # api source code\n      - 'server/api/**'\n\nsteps:\n  install-dependencies:\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm install --frozen-lockfile\n    when: *when\n\n  lint:\n    depends_on:\n      - install-dependencies\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm lint\n    when: *when\n\n  format-check:\n    depends_on:\n      - install-dependencies\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm format:check\n    when: *when\n\n  typecheck:\n    depends_on:\n      - install-dependencies\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm typecheck\n    when: *when\n\n  test:\n    depends_on:\n      - install-dependencies\n      - format-check # wait for it else test artifacts are falsely detected as wrong\n    image: *node_image\n    directory: web/\n    commands:\n      - corepack enable\n      - pnpm test\n    when: *when\n"
  },
  {
    "path": ".yamllint.yaml",
    "content": "extends: default\n\nignore-from-file:\n  - docs/.gitignore\n  - docs/plugins/woodpecker-plugins/.gitignore\n  - .gitignore\n  - server/store/datastore/migration/test-files/.gitignore\n  - web/.gitignore\n  - web/.yamlignore\n\nrules:\n  line-length: disable\n  document-start: disable\n  comments: disable\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [3.14.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.14.1) - 2026-05-12\n\n### ❤️ Special thanks the security researchers and those who fixed them ❤️\n\n- Thanks to **Shivam Kumar ([@shivamkumarcyber](https://github.com/shivamkumarcyber))** and\n  **Ranganatha Rao Sridhar (Praetorian)** _independently finding and reporting the bug_\n- And [@6543](https://github.com/6543) _fixing the bugs and orchestrating the communication_\n\n### 🔒 Security\n\n- Server: make sure agent_id can not be spoofed by agent [[#6567](https://github.com/woodpecker-ci/woodpecker/pull/6567)]\n\n## [3.14.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.14.0) - 2026-05-01\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Aex12, @AhmadNajiKam, @CrimsonFez, @LUKIEYF, @LoricAndre, @M31ancholy, @MartinSchmidt, @Pnkcaht, @Sim-hu, @TumbleOwlee, @api2062, @bclermont, @brainbaking, @cliffmccarthy, @confusedsushi, @dccdis, @hhamalai, @hnb2, @lephuongbg, @mehrdadbn9, @mofr93, @myers, @myselfghost, @njaaazi, @packrat386, @paulovitorbal, @qwerty287, @rfinnie, @rhafer, @samoli, @savv, @stardothosting, @utafrali, @wucm667\n\n### 🔒 Security\n\n- docs: bump follow-redirects [[#6441](https://github.com/woodpecker-ci/woodpecker/pull/6441)]\n- chore(deps): update dependency axios to v1.15.0 [security] [[#6417](https://github.com/woodpecker-ci/woodpecker/pull/6417)]\n- fix(deps): update go.opentelemetry.io/otel to v1.43.0 [[#6416](https://github.com/woodpecker-ci/woodpecker/pull/6416)]\n- WebUI: remove \"lodash\" dep [[#6369](https://github.com/woodpecker-ci/woodpecker/pull/6369)]\n- Sanitize agent introduced pipeline/workflow/step state changes and log streaming [[#6308](https://github.com/woodpecker-ci/woodpecker/pull/6308)]\n- Send 404 if logs are not allowed to access [[#6349](https://github.com/woodpecker-ci/woodpecker/pull/6349)]\n- Prevent registering as arbitrary agents with system token [[#6283](https://github.com/woodpecker-ci/woodpecker/pull/6283)]\n- Update `fast-xml-parser` [[#6258](https://github.com/woodpecker-ci/woodpecker/pull/6258)]\n- Update `dompurify` and `svgo` [[#6198](https://github.com/woodpecker-ci/woodpecker/pull/6198)]\n- Update edwards25519 [[#6143](https://github.com/woodpecker-ci/woodpecker/pull/6143)]\n\n### ✨ Features\n\n- Support one-shot agent execution mode [[#6150](https://github.com/woodpecker-ci/woodpecker/pull/6150)]\n- Add external secret extension implementation [[#6252](https://github.com/woodpecker-ci/woodpecker/pull/6252)]\n- Allow disabling isolated home directory for local agents [[#6251](https://github.com/woodpecker-ci/woodpecker/pull/6251)]\n- Add Container Registry credential extension [[#5993](https://github.com/woodpecker-ci/woodpecker/pull/5993)]\n- Support exclusive config extensions [[#5978](https://github.com/woodpecker-ci/woodpecker/pull/5978)]\n\n### 📈 Enhancement\n\n- Kubernetes: precreate workingDir as nonroot when required [[#6322](https://github.com/woodpecker-ci/woodpecker/pull/6322)]\n- Kubernetes: Support allowPrivilegeEscalation and capabilities backend_options [[#6307](https://github.com/woodpecker-ci/woodpecker/pull/6307)]\n- Refactor: remove Auth() from Forge interface [[#6505](https://github.com/woodpecker-ci/woodpecker/pull/6505)]\n- Move wait for log uploads logic out of logger and tracer into pipeline runtime [[#6471](https://github.com/woodpecker-ci/woodpecker/pull/6471)]\n- Make agent reconnect retry timeout configurable [[#6470](https://github.com/woodpecker-ci/woodpecker/pull/6470)]\n- Handle re-created forge repos gracefully [[#6370](https://github.com/woodpecker-ci/woodpecker/pull/6370)]\n- Cleanup server store step interface [[#6476](https://github.com/woodpecker-ci/woodpecker/pull/6476)]\n- Docker/K8s: add config for stop timeout [[#6445](https://github.com/woodpecker-ci/woodpecker/pull/6445)]\n- Docker backend should retry to delete volume on \"in use\" error [[#6381](https://github.com/woodpecker-ci/woodpecker/pull/6381)]\n- Move skip pipeline by commit message into pipeline/frontend package [[#6437](https://github.com/woodpecker-ci/woodpecker/pull/6437)]\n- Init `server/scheduler` package and use it as proxy for queue & pubsub [[#6418](https://github.com/woodpecker-ci/woodpecker/pull/6418)]\n- Unify server API parameters to snake_case [[#6404](https://github.com/woodpecker-ci/woodpecker/pull/6404)]\n- Add netrc option for config/registry extension [[#6333](https://github.com/woodpecker-ci/woodpecker/pull/6333)]\n- Docker backend: replace docker SDK with moby SDK [[#6357](https://github.com/woodpecker-ci/woodpecker/pull/6357)]\n- Deprecate commit avatar envs [[#6356](https://github.com/woodpecker-ci/woodpecker/pull/6356)]\n- Refactor server/pubsub into interface [[#6318](https://github.com/woodpecker-ci/woodpecker/pull/6318)]\n- Separate cron field [[#6346](https://github.com/woodpecker-ci/woodpecker/pull/6346)]\n- Refactor pipeline runtime code [[#6166](https://github.com/woodpecker-ci/woodpecker/pull/6166)]\n- Show Woodpecker version on pipeline details [[#6316](https://github.com/woodpecker-ci/woodpecker/pull/6316)]\n- Unify import aliases [[#6328](https://github.com/woodpecker-ci/woodpecker/pull/6328)]\n- Improve linter warning when step has no when block [[#6314](https://github.com/woodpecker-ci/woodpecker/pull/6314)]\n- Improve error message when no workflows for manual were found [[#6313](https://github.com/woodpecker-ci/woodpecker/pull/6313)]\n- Server return conflict status when stale repo causes duplicate insert [[#6276](https://github.com/woodpecker-ci/woodpecker/pull/6276)]\n- Show global/org registries in org/repo registries tab [[#6291](https://github.com/woodpecker-ci/woodpecker/pull/6291)]\n- Report skipped step state as soon as it's determined [[#6295](https://github.com/woodpecker-ci/woodpecker/pull/6295)]\n- Only add compatibility environment variables for drone-ci to plugins [[#6271](https://github.com/woodpecker-ci/woodpecker/pull/6271)]\n- Refactor: pass backend explicitly when creating pipeline engine runtime [[#6268](https://github.com/woodpecker-ci/woodpecker/pull/6268)]\n- Compare admins case-insensitively [[#6261](https://github.com/woodpecker-ci/woodpecker/pull/6261)]\n- Allow to cancel on failure [[#6158](https://github.com/woodpecker-ci/woodpecker/pull/6158)]\n- Refactor so storage detects if Insert fails because of unique constraint [[#6259](https://github.com/woodpecker-ci/woodpecker/pull/6259)]\n- Add server config for maximum log lines shown in web UI [[#6250](https://github.com/woodpecker-ci/woodpecker/pull/6250)]\n- Add \"Load more\" pagination to pipeline list [[#6200](https://github.com/woodpecker-ci/woodpecker/pull/6200)]\n- Use upstream slices.Concat and remove utils.MergeSlices [[#6185](https://github.com/woodpecker-ci/woodpecker/pull/6185)]\n- Add enhanced function for error message handling in http request for configuration fetching [[#5712](https://github.com/woodpecker-ci/woodpecker/pull/5712)]\n- Remove fixed badge width in UI [[#6157](https://github.com/woodpecker-ci/woodpecker/pull/6157)]\n- Improve Debian packages [[#6085](https://github.com/woodpecker-ci/woodpecker/pull/6085)]\n- Refactor pipeline engine [[#6073](https://github.com/woodpecker-ci/woodpecker/pull/6073)]\n- Show cancellation reason in pipeline details [[#6072](https://github.com/woodpecker-ci/woodpecker/pull/6072)]\n- Document required forge methods [[#6049](https://github.com/woodpecker-ci/woodpecker/pull/6049)]\n- Dynamic log following [[#6036](https://github.com/woodpecker-ci/woodpecker/pull/6036)]\n- Per-Workflow and Per-Workflow-Step badge generation [[#5977](https://github.com/woodpecker-ci/woodpecker/pull/5977)]\n- Render MD in pipeline titles [[#5999](https://github.com/woodpecker-ci/woodpecker/pull/5999)]\n- Simplify and Fix server task queue [[#6017](https://github.com/woodpecker-ci/woodpecker/pull/6017)]\n- Update Architecture: move `pipeline/rpc` => `rpc` & `server/{grpc => rpc}` [[#6012](https://github.com/woodpecker-ci/woodpecker/pull/6012)]\n- Implement retry logic in HTTP Send method [[#5857](https://github.com/woodpecker-ci/woodpecker/pull/5857)]\n- CLI: Allow single output template [[#5882](https://github.com/woodpecker-ci/woodpecker/pull/5882)]\n- Improve service syntax related docs and tests nits [[#5991](https://github.com/woodpecker-ci/woodpecker/pull/5991)]\n- Remove deactivated secrets type from container definition [[#5983](https://github.com/woodpecker-ci/woodpecker/pull/5983)]\n\n### 🐛 Bug Fixes\n\n- fix(web): escape HTML in commit messages to prevent XSS [[#6523](https://github.com/woodpecker-ci/woodpecker/pull/6523)]\n- fix(cli,server): fix trusted flags copy-paste bug and server nil pointer panic [[#6501](https://github.com/woodpecker-ci/woodpecker/pull/6501)]\n- Add refname to bitbucket commit status [[#6482](https://github.com/woodpecker-ci/woodpecker/pull/6482)]\n- Fix send on closed channel panic in SSE stream handlers [[#6456](https://github.com/woodpecker-ci/woodpecker/pull/6456)]\n- Add `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE` config to preserve non-breaking behavior by default [[#6448](https://github.com/woodpecker-ci/woodpecker/pull/6448)]\n- Fix race in pipeline runtime [[#6451](https://github.com/woodpecker-ci/woodpecker/pull/6451)]\n- Fix race in server LogEntry logger [[#6449](https://github.com/woodpecker-ci/woodpecker/pull/6449)]\n- Kubernetes: detached steps are no services [[#6435](https://github.com/woodpecker-ci/woodpecker/pull/6435)]\n- Support dots in image names [[#6431](https://github.com/woodpecker-ci/woodpecker/pull/6431)]\n- Fix erroneous linter error for plugin privileges [[#6424](https://github.com/woodpecker-ci/woodpecker/pull/6424)]\n- Add connection timeout and graceful shutdown to agent RPC client [[#6414](https://github.com/woodpecker-ci/woodpecker/pull/6414)]\n- Fix Windows container exit code handling and error checks [[#6411](https://github.com/woodpecker-ci/woodpecker/pull/6411)]\n- Bitbucket: Remove usage of deprecated /user/permissions/repositories [[#6401](https://github.com/woodpecker-ci/woodpecker/pull/6401)]\n- Bitbucket: Fix parsing /user/workspaces response [[#6396](https://github.com/woodpecker-ci/woodpecker/pull/6396)]\n- Fix CLI exec with workflow matrix feature, where variables are not substituted. [[#6162](https://github.com/woodpecker-ci/woodpecker/pull/6162)]\n- Fix enable repo with same name and owner on second forge [[#6375](https://github.com/woodpecker-ci/woodpecker/pull/6375)]\n- Fix workflow being skipped and marked as failed when agent starts before server [[#6361](https://github.com/woodpecker-ci/woodpecker/pull/6361)]\n- Only redirect after login [[#6348](https://github.com/woodpecker-ci/woodpecker/pull/6348)]\n- Set workflow services stuck in running state to finished [[#6337](https://github.com/woodpecker-ci/woodpecker/pull/6337)]\n- Fix bitbucket api deprecations [[#6324](https://github.com/woodpecker-ci/woodpecker/pull/6324)]\n- Fix workflow serialize to omit skip_clone if false [[#6319](https://github.com/woodpecker-ci/woodpecker/pull/6319)]\n- Fix build deb rpm packages [[#6309](https://github.com/woodpecker-ci/woodpecker/pull/6309)]\n- Enable crons if created via CLI [[#6228](https://github.com/woodpecker-ci/woodpecker/pull/6228)]\n- Fix message on gitlab tag event [[#6196](https://github.com/woodpecker-ci/woodpecker/pull/6196)]\n- Bitbucket DC: resolve annotated tag SHA to commit SHA before posting build status [[#6203](https://github.com/woodpecker-ci/woodpecker/pull/6203)]\n- Prevent leaking goroutines on canceled steps [[#6186](https://github.com/woodpecker-ci/woodpecker/pull/6186)]\n- Fix `when.status` filter evaluation and add workflow-level support [[#6183](https://github.com/woodpecker-ci/woodpecker/pull/6183)]\n- Fix status merging with skipped pipelines [[#6176](https://github.com/woodpecker-ci/woodpecker/pull/6176)]\n- Update pipeline config schema [[#6156](https://github.com/woodpecker-ci/woodpecker/pull/6156)]\n- Fix OAuth token refresh race condition with singleflight [[#6153](https://github.com/woodpecker-ci/woodpecker/pull/6153)]\n- Use priority-based merging to determine pipeline and workflow status [[#6119](https://github.com/woodpecker-ci/woodpecker/pull/6119)]\n- Only set tag env on tags [[#6142](https://github.com/woodpecker-ci/woodpecker/pull/6142)]\n- Fix bitbucket email [[#6102](https://github.com/woodpecker-ci/woodpecker/pull/6102)]\n- Report status for detached steps and services [[#6039](https://github.com/woodpecker-ci/woodpecker/pull/6039)]\n- Don't propagate workflow error from agent back to agent [[#6056](https://github.com/woodpecker-ci/woodpecker/pull/6056)]\n- Fix pipeline cancellation status handling and step state synchronization [[#6011](https://github.com/woodpecker-ci/woodpecker/pull/6011)]\n- Add retry logic for CreatePipeline with backoff [[#6067](https://github.com/woodpecker-ci/woodpecker/pull/6067)]\n- Fix OAuth token refresh in webhook handling for Bitbucket and GitHub [[#6059](https://github.com/woodpecker-ci/woodpecker/pull/6059)]\n- Refresh token before forge calls [[#6035](https://github.com/woodpecker-ci/woodpecker/pull/6035)]\n- Local backend: cleanup generated script for cmd.exe shell [[#6029](https://github.com/woodpecker-ci/woodpecker/pull/6029)]\n- Local backend: setup clone step respects context [[#6030](https://github.com/woodpecker-ci/woodpecker/pull/6030)]\n- Fix: Agent now gracefully handles running containers when killed [[#6018](https://github.com/woodpecker-ci/woodpecker/pull/6018)]\n- Local backend: handle canceled steps case [[#6008](https://github.com/woodpecker-ci/woodpecker/pull/6008)]\n\n### 🧪 Tests\n\n- e2e test wait for grpc server teardown and stop agents [[#6479](https://github.com/woodpecker-ci/woodpecker/pull/6479)]\n- Add more test cases for rpc label filter [[#6483](https://github.com/woodpecker-ci/woodpecker/pull/6483)]\n- Fix flaky TestJWTManager [[#6478](https://github.com/woodpecker-ci/woodpecker/pull/6478)]\n- Add e2e pipeline restart test [[#6469](https://github.com/woodpecker-ci/woodpecker/pull/6469)]\n- Init minimal e2e tests [[#6391](https://github.com/woodpecker-ci/woodpecker/pull/6391)]\n- Enhance datastore DB test setup [[#6450](https://github.com/woodpecker-ci/woodpecker/pull/6450)]\n- Dummy backend support cancel [[#6390](https://github.com/woodpecker-ci/woodpecker/pull/6390)]\n- Extend workflow integration tests [[#6272](https://github.com/woodpecker-ci/woodpecker/pull/6272)]\n- Add registry service tests [[#6330](https://github.com/woodpecker-ci/woodpecker/pull/6330)]\n- Add workflow integration test [[#6270](https://github.com/woodpecker-ci/woodpecker/pull/6270)]\n- Increase timeout for migration tests [[#6206](https://github.com/woodpecker-ci/woodpecker/pull/6206)]\n- Ignore fixtures for coverage [[#6197](https://github.com/woodpecker-ci/woodpecker/pull/6197)]\n- Use tabs for indentation in embedded JSON [[#6103](https://github.com/woodpecker-ci/woodpecker/pull/6103)]\n- Add tests for CLI output formatting and pipeline metadata environment variables [[#6076](https://github.com/woodpecker-ci/woodpecker/pull/6076)]\n- Ignore mocks for coverage [[#6074](https://github.com/woodpecker-ci/woodpecker/pull/6074)]\n\n### 📚 Documentation\n\n- docs: better description for when.status filter [[#6517](https://github.com/woodpecker-ci/woodpecker/pull/6517)]\n- docs: Add woodpecker-shellcheck lint to awesome list [[#6521](https://github.com/woodpecker-ci/woodpecker/pull/6521)]\n- Lock file maintenance [[#6508](https://github.com/woodpecker-ci/woodpecker/pull/6508)]\n- Update docs npm deps non-major [[#6496](https://github.com/woodpecker-ci/woodpecker/pull/6496)]\n- Add Laravel Forge plugin [[#6491](https://github.com/woodpecker-ci/woodpecker/pull/6491)]\n- Add 'entrypoint' property to service in schema [[#6487](https://github.com/woodpecker-ci/woodpecker/pull/6487)]\n- Lock file maintenance [[#6472](https://github.com/woodpecker-ci/woodpecker/pull/6472)]\n- Update dependency axios to v1.15.1 [[#6468](https://github.com/woodpecker-ci/woodpecker/pull/6468)]\n- Update dependency marked to v18.0.2 [[#6465](https://github.com/woodpecker-ci/woodpecker/pull/6465)]\n- Update docs npm deps non-major [[#6463](https://github.com/woodpecker-ci/woodpecker/pull/6463)]\n- Update dependency marked to v18 [[#6425](https://github.com/woodpecker-ci/woodpecker/pull/6425)]\n- Update docs npm deps non-major [[#6422](https://github.com/woodpecker-ci/woodpecker/pull/6422)]\n- chore(deps): update dependency fuse.js to v7.3.0 [[#6382](https://github.com/woodpecker-ci/woodpecker/pull/6382)]\n- chore(deps): update docs npm deps non-major [[#6376](https://github.com/woodpecker-ci/woodpecker/pull/6376)]\n- chore(deps): update dependency typescript to v6 [[#6336](https://github.com/woodpecker-ci/woodpecker/pull/6336)]\n- chore(deps): update docs npm deps non-major [[#6335](https://github.com/woodpecker-ci/woodpecker/pull/6335)]\n- Add CI check for docs on feature PRs [[#6315](https://github.com/woodpecker-ci/woodpecker/pull/6315)]\n- chore(deps): update dependency isomorphic-dompurify to v3.6.0 [[#6288](https://github.com/woodpecker-ci/woodpecker/pull/6288)]\n- chore(deps): update dependency yaml to v2.8.3 [[#6287](https://github.com/woodpecker-ci/woodpecker/pull/6287)]\n- Add agentscan to plugin docs [[#6285](https://github.com/woodpecker-ci/woodpecker/pull/6285)]\n- Add opengrep plugin [[#6282](https://github.com/woodpecker-ci/woodpecker/pull/6282)]\n- chore(deps): update docs npm deps non-major [[#6281](https://github.com/woodpecker-ci/woodpecker/pull/6281)]\n- Sort glossary items alphabetically [[#6255](https://github.com/woodpecker-ci/woodpecker/pull/6255)]\n- chore(deps): update docs npm deps non-major [[#6240](https://github.com/woodpecker-ci/woodpecker/pull/6240)]\n- plugin: ascii junit report: renamed gh username [[#6232](https://github.com/woodpecker-ci/woodpecker/pull/6232)]\n- chore(deps): update dependency svgo to v4 [[#6214](https://github.com/woodpecker-ci/woodpecker/pull/6214)]\n- chore(deps): update docs npm deps non-major [[#6210](https://github.com/woodpecker-ci/woodpecker/pull/6210)]\n- Update serialize-javascript [[#6182](https://github.com/woodpecker-ci/woodpecker/pull/6182)]\n- chore(deps): update docs npm deps non-major [[#6173](https://github.com/woodpecker-ci/woodpecker/pull/6173)]\n- chore(deps): update dependency isomorphic-dompurify to v3 [[#6147](https://github.com/woodpecker-ci/woodpecker/pull/6147)]\n- chore(deps): update docs npm deps non-major [[#6137](https://github.com/woodpecker-ci/woodpecker/pull/6137)]\n- Add deprecation policy [[#6068](https://github.com/woodpecker-ci/woodpecker/pull/6068)]\n- fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.55.0 [[#6125](https://github.com/woodpecker-ci/woodpecker/pull/6125)]\n- Improve selinux docs [[#6066](https://github.com/woodpecker-ci/woodpecker/pull/6066)]\n- Document how to ignore failure on services [[#6106](https://github.com/woodpecker-ci/woodpecker/pull/6106)]\n- chore(deps): update docs npm deps non-major [[#6109](https://github.com/woodpecker-ci/woodpecker/pull/6109)]\n- fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.54.0 [[#6091](https://github.com/woodpecker-ci/woodpecker/pull/6091)]\n- chore(deps): update dependency axios to v1.13.5 [[#6090](https://github.com/woodpecker-ci/woodpecker/pull/6090)]\n- chore(deps): update docs npm deps non-major [[#6088](https://github.com/woodpecker-ci/woodpecker/pull/6088)]\n- chore(deps): update dependency isomorphic-dompurify to v2.36.0 [[#6086](https://github.com/woodpecker-ci/woodpecker/pull/6086)]\n- fix(deps): update docs npm deps non-major [[#6052](https://github.com/woodpecker-ci/woodpecker/pull/6052)]\n- Update Module Interaction Diagram [[#6019](https://github.com/woodpecker-ci/woodpecker/pull/6019)]\n- Add Buildah plugin link [[#6050](https://github.com/woodpecker-ci/woodpecker/pull/6050)]\n- chore(deps): update docs npm deps non-major [[#6045](https://github.com/woodpecker-ci/woodpecker/pull/6045)]\n- Add Homebrew package [[#6037](https://github.com/woodpecker-ci/woodpecker/pull/6037)]\n- chore(deps): update dependency axios to v1.13.3 [[#6010](https://github.com/woodpecker-ci/woodpecker/pull/6010)]\n- chore(deps): update docs npm deps non-major [[#6000](https://github.com/woodpecker-ci/woodpecker/pull/6000)]\n- Fix docusaurus md link deprecation [[#5979](https://github.com/woodpecker-ci/woodpecker/pull/5979)]\n- chore(deps): update docs npm deps non-major [[#5982](https://github.com/woodpecker-ci/woodpecker/pull/5982)]\n\n### 📦️ Dependency\n\n- Update golang-packages [[#6524](https://github.com/woodpecker-ci/woodpecker/pull/6524)]\n- Update module github.com/google/go-github/v84 to v85 [[#6500](https://github.com/woodpecker-ci/woodpecker/pull/6500)]\n- Update module github.com/getkin/kin-openapi to v0.136.0 [[#6503](https://github.com/woodpecker-ci/woodpecker/pull/6503)]\n- Update woodpeckerci/plugin-git Docker tag to v2.9.0 [[#6499](https://github.com/woodpecker-ci/woodpecker/pull/6499)]\n- Update docker.io/mysql Docker tag to v9.7.0 [[#6498](https://github.com/woodpecker-ci/woodpecker/pull/6498)]\n- Update docker.io/lycheeverse/lychee Docker tag to v0.24.1 [[#6497](https://github.com/woodpecker-ci/woodpecker/pull/6497)]\n- Update golang-packages to v0.36.0 [[#6485](https://github.com/woodpecker-ci/woodpecker/pull/6485)]\n- Update golang-packages [[#6477](https://github.com/woodpecker-ci/woodpecker/pull/6477)]\n- Update pre-commit hook rbubley/mirrors-prettier to v3.8.3 [[#6462](https://github.com/woodpecker-ci/woodpecker/pull/6462)]\n- Update module k8s.io/client-go to v0.35.4 [[#6460](https://github.com/woodpecker-ci/woodpecker/pull/6460)]\n- Update golang-packages [[#6459](https://github.com/woodpecker-ci/woodpecker/pull/6459)]\n- Update docker.io/woodpeckerci/plugin-trivy Docker tag to v1.4.5 [[#6447](https://github.com/woodpecker-ci/woodpecker/pull/6447)]\n- Update docker.io/woodpeckerci/plugin-ready-release-go Docker tag to v4.1.1 [[#6440](https://github.com/woodpecker-ci/woodpecker/pull/6440)]\n- Update module gitlab.com/gitlab-org/api/client-go/v2 to v2.18.0 [[#6439](https://github.com/woodpecker-ci/woodpecker/pull/6439)]\n- Update docker.io/woodpeckerci/plugin-codecov Docker tag to v2.3.1 [[#6438](https://github.com/woodpecker-ci/woodpecker/pull/6438)]\n- Lock file maintenance [[#6430](https://github.com/woodpecker-ci/woodpecker/pull/6430)]\n- Update dependency dotenv to v17.4.2 [[#6428](https://github.com/woodpecker-ci/woodpecker/pull/6428)]\n- Update dependency simple-icons to v16.16.0 [[#6427](https://github.com/woodpecker-ci/woodpecker/pull/6427)]\n- Update web npm deps non-major [[#6423](https://github.com/woodpecker-ci/woodpecker/pull/6423)]\n- Update pre-commit hook rbubley/mirrors-prettier to v3.8.2 [[#6421](https://github.com/woodpecker-ci/woodpecker/pull/6421)]\n- Update dependency golang to v1.26.2 [[#6420](https://github.com/woodpecker-ci/woodpecker/pull/6420)]\n- fix(deps): update module github.com/docker/cli to v29.4.0+incompatible [[#6403](https://github.com/woodpecker-ci/woodpecker/pull/6403)]\n- fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.41 [[#6397](https://github.com/woodpecker-ci/woodpecker/pull/6397)]\n- chore(deps): lock file maintenance [[#6392](https://github.com/woodpecker-ci/woodpecker/pull/6392)]\n- chore(deps): update dependency dotenv to v17.4.1 [[#6389](https://github.com/woodpecker-ci/woodpecker/pull/6389)]\n- chore(deps): update dependency marked to v17.0.6 [[#6387](https://github.com/woodpecker-ci/woodpecker/pull/6387)]\n- chore(deps): update dependency simple-icons to v16.15.0 [[#6385](https://github.com/woodpecker-ci/woodpecker/pull/6385)]\n- fix(deps): update golang-packages [[#6384](https://github.com/woodpecker-ci/woodpecker/pull/6384)]\n- chore(deps): update dependency fuse.js to v7.3.0 [[#6383](https://github.com/woodpecker-ci/woodpecker/pull/6383)]\n- chore(deps): update dependency @antfu/eslint-config to v8 [[#6378](https://github.com/woodpecker-ci/woodpecker/pull/6378)]\n- chore(deps): update web npm deps non-major [[#6377](https://github.com/woodpecker-ci/woodpecker/pull/6377)]\n- fix(deps): update module github.com/lib/pq to v1.12.2 [[#6371](https://github.com/woodpecker-ci/woodpecker/pull/6371)]\n- fix(deps): update module google.golang.org/grpc to v1.80.0 [[#6363](https://github.com/woodpecker-ci/woodpecker/pull/6363)]\n- fix(deps): update golang-packages [[#6343](https://github.com/woodpecker-ci/woodpecker/pull/6343)]\n- chore(deps): lock file maintenance [[#6344](https://github.com/woodpecker-ci/woodpecker/pull/6344)]\n- chore(deps): update dependency simple-icons to v16.14.0 [[#6341](https://github.com/woodpecker-ci/woodpecker/pull/6341)]\n- chore(deps): update web npm deps non-major [[#6334](https://github.com/woodpecker-ci/woodpecker/pull/6334)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v4.1.0 [[#6331](https://github.com/woodpecker-ci/woodpecker/pull/6331)]\n- fix(deps): update module code.gitea.io/sdk/gitea to v0.24.1 [[#6321](https://github.com/woodpecker-ci/woodpecker/pull/6321)]\n- chore(deps): lock file maintenance [[#6306](https://github.com/woodpecker-ci/woodpecker/pull/6306)]\n- fix(deps): update module github.com/charmbracelet/huh to v2 [[#6243](https://github.com/woodpecker-ci/woodpecker/pull/6243)]\n- chore(deps): update dependency golangci/golangci-lint to v2.11.4 [[#6301](https://github.com/woodpecker-ci/woodpecker/pull/6301)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.11.4 [[#6302](https://github.com/woodpecker-ci/woodpecker/pull/6302)]\n- chore(deps): update web npm deps non-major [[#6279](https://github.com/woodpecker-ci/woodpecker/pull/6279)]\n- fix(deps): update module github.com/zalando/go-keyring to v0.2.7 [[#6280](https://github.com/woodpecker-ci/woodpecker/pull/6280)]\n- fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.37 [[#6253](https://github.com/woodpecker-ci/woodpecker/pull/6253)]\n- chore(deps): update dependency jsdom to v29 [[#6246](https://github.com/woodpecker-ci/woodpecker/pull/6246)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.3.0 [[#6241](https://github.com/woodpecker-ci/woodpecker/pull/6241)]\n- chore(deps): update dependency vite to v8 [[#6242](https://github.com/woodpecker-ci/woodpecker/pull/6242)]\n- chore(deps): update pre-commit non-major [[#6212](https://github.com/woodpecker-ci/woodpecker/pull/6212)]\n- chore(deps): update dependency vue-i18n to v11.3.0 [[#6217](https://github.com/woodpecker-ci/woodpecker/pull/6217)]\n- chore(deps): update dependency golang to v1.26.1 [[#6207](https://github.com/woodpecker-ci/woodpecker/pull/6207)]\n- fix(deps): update module github.com/docker/cli to v29.3.0+incompatible [[#6201](https://github.com/woodpecker-ci/woodpecker/pull/6201)]\n- fix(deps): update module github.com/yaronf/httpsign to v0.4.2 [[#6188](https://github.com/woodpecker-ci/woodpecker/pull/6188)]\n- chore(deps): update dependency eslint-plugin-vue-scoped-css to v3 [[#6178](https://github.com/woodpecker-ci/woodpecker/pull/6178)]\n- chore(deps): update dependency @intlify/eslint-plugin-vue-i18n to v4.3.0 [[#6177](https://github.com/woodpecker-ci/woodpecker/pull/6177)]\n- fix(deps): update module github.com/google/go-github/v83 to v84 [[#6172](https://github.com/woodpecker-ci/woodpecker/pull/6172)]\n- chore(deps): update postgres docker tag to v18.3 [[#6169](https://github.com/woodpecker-ci/woodpecker/pull/6169)]\n- fix(deps): update golang-packages [[#6160](https://github.com/woodpecker-ci/woodpecker/pull/6160)]\n- chore(deps): update dependency vue-tsc to v3.2.5 [[#6141](https://github.com/woodpecker-ci/woodpecker/pull/6141)]\n- chore(deps): update docker.io/golang docker tag to v1.26 [[#6121](https://github.com/woodpecker-ci/woodpecker/pull/6121)]\n- chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.23.0 [[#6122](https://github.com/woodpecker-ci/woodpecker/pull/6122)]\n- chore(deps): update dependency @types/node to v24.10.12 [[#6087](https://github.com/woodpecker-ci/woodpecker/pull/6087)]\n- chore(deps): update eslint monorepo to v10 (major) [[#6083](https://github.com/woodpecker-ci/woodpecker/pull/6083)]\n- chore(deps): update dependency @antfu/eslint-config to v7.3.0 [[#6084](https://github.com/woodpecker-ci/woodpecker/pull/6084)]\n- chore(deps): update dependency @vueuse/core to v14.2.0 [[#6048](https://github.com/woodpecker-ci/woodpecker/pull/6048)]\n- fix(deps): update dependency vue-router to v5 [[#6046](https://github.com/woodpecker-ci/woodpecker/pull/6046)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.8.1 [[#6006](https://github.com/woodpecker-ci/woodpecker/pull/6006)]\n- chore(deps): update docker.io/mysql docker tag to v9.6.0 [[#6002](https://github.com/woodpecker-ci/woodpecker/pull/6002)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.6.2 [[#5989](https://github.com/woodpecker-ci/woodpecker/pull/5989)]\n\n### Misc\n\n- Add s3 cache plugin to docs [[#6467](https://github.com/woodpecker-ci/woodpecker/pull/6467)]\n- Fix license headers [[#6205](https://github.com/woodpecker-ci/woodpecker/pull/6205)]\n- Add agentscan plugin [[#6284](https://github.com/woodpecker-ci/woodpecker/pull/6284)]\n\n## [3.13.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.13.0) - 2026-01-14\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Javex, @KhalidAlansary, @MartinSchmidt, @abhiyerra, @anbraten, @bentasker, @gjuoun, @gsaslis, @henkka, @jolheiser, @mogsie, @qwerty287, @sloonz, @sugar700, @tuxmainy, @xoxys\n\n### 🔒 Security\n\n- Update quic-go/qpack & quic-go/quic-go [[#5885](https://github.com/woodpecker-ci/woodpecker/pull/5885)]\n- fix: updateRepoPermissions to cleanup old permissions [[#5790](https://github.com/woodpecker-ci/woodpecker/pull/5790)]\n\n### ✨ Features\n\n- Add cli contexts [[#5929](https://github.com/woodpecker-ci/woodpecker/pull/5929)]\n\n### 📈 Enhancement\n\n- Allow to add a note to secrets [[#5898](https://github.com/woodpecker-ci/woodpecker/pull/5898)]\n- Log addon errors [[#5923](https://github.com/woodpecker-ci/woodpecker/pull/5923)]\n- Custom vars for crons [[#5897](https://github.com/woodpecker-ci/woodpecker/pull/5897)]\n- Allow to disable a cron [[#5896](https://github.com/woodpecker-ci/woodpecker/pull/5896)]\n- Add background to status icons [[#5880](https://github.com/woodpecker-ci/woodpecker/pull/5880)]\n- Fix dead page and cleanup router [[#5519](https://github.com/woodpecker-ci/woodpecker/pull/5519)]\n- feat(kubernetes): add support for pod affinity and anti-affinity configurations [[#5854](https://github.com/woodpecker-ci/woodpecker/pull/5854)]\n- Public key endpoint [[#5860](https://github.com/woodpecker-ci/woodpecker/pull/5860)]\n- Allow untrusted repo to still drop network for steps [[#5820](https://github.com/woodpecker-ci/woodpecker/pull/5820)]\n- Add support for headless Kubernetes services [[#5764](https://github.com/woodpecker-ci/woodpecker/pull/5764)]\n- server/forge: rename var to be more descriptive and test value [[#5806](https://github.com/woodpecker-ci/woodpecker/pull/5806)]\n- add events query parameter to badge url [[#5728](https://github.com/woodpecker-ci/woodpecker/pull/5728)]\n- Extract default step-builder options into server [[#5785](https://github.com/woodpecker-ci/woodpecker/pull/5785)]\n- feat: include CI_COMMIT_TAG env in deployment events [[#5773](https://github.com/woodpecker-ci/woodpecker/pull/5773)]\n\n### 🐛 Bug Fixes\n\n- Use repo-user for api call of cron [[#5967](https://github.com/woodpecker-ci/woodpecker/pull/5967)]\n- Close opened file on LogFind [[#5961](https://github.com/woodpecker-ci/woodpecker/pull/5961)]\n- Delete/Deactivate repo ignores missing repo at forge [[#5953](https://github.com/woodpecker-ci/woodpecker/pull/5953)]\n- Correctly update repo permissions [[#5928](https://github.com/woodpecker-ci/woodpecker/pull/5928)]\n- Revert repos pagination for GH and BB [[#5924](https://github.com/woodpecker-ci/woodpecker/pull/5924)]\n- fix: send correct argument to rpc call for name/url [[#5922](https://github.com/woodpecker-ci/woodpecker/pull/5922)]\n- fix: secrets-file flag [[#5909](https://github.com/woodpecker-ci/woodpecker/pull/5909)]\n- Do not run crons for disabled repos [[#5884](https://github.com/woodpecker-ci/woodpecker/pull/5884)]\n- Show warning if there is no workflow to run [[#5883](https://github.com/woodpecker-ci/woodpecker/pull/5883)]\n- fix(datastore): fix pagination bug in workflowsDelete skipping records [[#5881](https://github.com/woodpecker-ci/woodpecker/pull/5881)]\n- Remove rounded corners in fullscreen log view [[#5879](https://github.com/woodpecker-ci/woodpecker/pull/5879)]\n- Fix some ListItems and Queue view background in dark mode [[#5878](https://github.com/woodpecker-ci/woodpecker/pull/5878)]\n- Make disabled checkboxes match overall style [[#5869](https://github.com/woodpecker-ci/woodpecker/pull/5869)]\n- Fix CLI trusted updating [[#5861](https://github.com/woodpecker-ci/woodpecker/pull/5861)]\n- Send configuration as part of the request for external configuration [[#5831](https://github.com/woodpecker-ci/woodpecker/pull/5831)]\n- fix(bitbucketdatacenter): fix CI_COMMIT_PULL_REQUEST [[#5769](https://github.com/woodpecker-ci/woodpecker/pull/5769)]\n- On set/get of repo make sure forge_id is set and on fetch respected [[#5717](https://github.com/woodpecker-ci/woodpecker/pull/5717)]\n- Improve repair endpoints [[#5767](https://github.com/woodpecker-ci/woodpecker/pull/5767)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#5963](https://github.com/woodpecker-ci/woodpecker/pull/5963)]\n- chore(deps): update dependency @types/node to v24.10.7 [[#5954](https://github.com/woodpecker-ci/woodpecker/pull/5954)]\n- chore(deps): update dependency @types/react to v19.2.8 [[#5941](https://github.com/woodpecker-ci/woodpecker/pull/5941)]\n- chore(deps): update dependency @types/node to v24.10.6 [[#5935](https://github.com/woodpecker-ci/woodpecker/pull/5935)]\n- chore(deps): update dependency @types/node to v24.10.5 [[#5933](https://github.com/woodpecker-ci/woodpecker/pull/5933)]\n- fix(docs): update woodpecker-cli secret command [[#5927](https://github.com/woodpecker-ci/woodpecker/pull/5927)]\n- Update Docs and nix-flake to reflect current dev environment [[#5926](https://github.com/woodpecker-ci/woodpecker/pull/5926)]\n- Update Helm chart installation command [[#5872](https://github.com/woodpecker-ci/woodpecker/pull/5872)]\n- docs: add BunnyCDN Cache Purge Plugin [[#5906](https://github.com/woodpecker-ci/woodpecker/pull/5906)]\n- chore(deps): update dependency isomorphic-dompurify to v2.35.0 [[#5904](https://github.com/woodpecker-ci/woodpecker/pull/5904)]\n- chore(deps): update dependency @types/node to v24.10.4 [[#5862](https://github.com/woodpecker-ci/woodpecker/pull/5862)]\n- chore(deps): update docs npm deps non-major [[#5855](https://github.com/woodpecker-ci/woodpecker/pull/5855)]\n- chore(deps): update docs npm deps non-major [[#5829](https://github.com/woodpecker-ci/woodpecker/pull/5829)]\n- Update link for Codeberg Pages Deploy plugin [[#5811](https://github.com/woodpecker-ci/woodpecker/pull/5811)]\n- chore(deps): update dependency yaml to v2.8.2 [[#5803](https://github.com/woodpecker-ci/woodpecker/pull/5803)]\n- chore(deps): update dependency prettier to v3.7.3 [[#5799](https://github.com/woodpecker-ci/woodpecker/pull/5799)]\n- chore(deps): update docs npm deps non-major [[#5791](https://github.com/woodpecker-ci/woodpecker/pull/5791)]\n- chore(deps): update dependency isomorphic-dompurify to v2.33.0 [[#5778](https://github.com/woodpecker-ci/woodpecker/pull/5778)]\n- chore(deps): update docs npm deps non-major [[#5774](https://github.com/woodpecker-ci/woodpecker/pull/5774)]\n\n### 📦️ Dependency\n\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.14.0 [[#5969](https://github.com/woodpecker-ci/woodpecker/pull/5969)]\n- fix(deps): update golang-packages [[#5966](https://github.com/woodpecker-ci/woodpecker/pull/5966)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.12.0 [[#5962](https://github.com/woodpecker-ci/woodpecker/pull/5962)]\n- chore(deps): update dependency simple-icons to v16.5.0 [[#5957](https://github.com/woodpecker-ci/woodpecker/pull/5957)]\n- fix(deps): update golang-packages [[#5956](https://github.com/woodpecker-ci/woodpecker/pull/5956)]\n- chore(deps): update dependency @types/node to v24.10.7 [[#5955](https://github.com/woodpecker-ci/woodpecker/pull/5955)]\n- fix(deps): update module github.com/google/go-github/v80 to v81 [[#5946](https://github.com/woodpecker-ci/woodpecker/pull/5946)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.8.0 [[#5945](https://github.com/woodpecker-ci/woodpecker/pull/5945)]\n- chore(deps): update golangci/golangci-lint docker tag to v2.8.0 [[#5944](https://github.com/woodpecker-ci/woodpecker/pull/5944)]\n- chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.2.0 [[#5943](https://github.com/woodpecker-ci/woodpecker/pull/5943)]\n- chore(deps): update web npm deps non-major [[#5942](https://github.com/woodpecker-ci/woodpecker/pull/5942)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.2 [[#5938](https://github.com/woodpecker-ci/woodpecker/pull/5938)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.4.1 [[#5937](https://github.com/woodpecker-ci/woodpecker/pull/5937)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.4 [[#5936](https://github.com/woodpecker-ci/woodpecker/pull/5936)]\n- chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.3 [[#5934](https://github.com/woodpecker-ci/woodpecker/pull/5934)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.11.0 [[#5919](https://github.com/woodpecker-ci/woodpecker/pull/5919)]\n- chore(deps): lock file maintenance [[#5916](https://github.com/woodpecker-ci/woodpecker/pull/5916)]\n- chore(deps): update dependency simple-icons to v16.4.0 [[#5915](https://github.com/woodpecker-ci/woodpecker/pull/5915)]\n- fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.33 [[#5910](https://github.com/woodpecker-ci/woodpecker/pull/5910)]\n- chore(deps): lock file maintenance [[#5913](https://github.com/woodpecker-ci/woodpecker/pull/5913)]\n- chore(deps): lock file maintenance [[#5907](https://github.com/woodpecker-ci/woodpecker/pull/5907)]\n- chore(deps): update dependency simple-icons to v16.3.0 [[#5905](https://github.com/woodpecker-ci/woodpecker/pull/5905)]\n- chore(deps): update web npm deps non-major [[#5903](https://github.com/woodpecker-ci/woodpecker/pull/5903)]\n- fix(deps): update module google.golang.org/grpc to v1.78.0 [[#5901](https://github.com/woodpecker-ci/woodpecker/pull/5901)]\n- chore(deps): lock file maintenance [[#5895](https://github.com/woodpecker-ci/woodpecker/pull/5895)]\n- fix(deps): update module github.com/tink-crypto/tink-go/v2 to v2.6.0 [[#5894](https://github.com/woodpecker-ci/woodpecker/pull/5894)]\n- chore(deps): update dependency @antfu/eslint-config to v6.7.2 [[#5893](https://github.com/woodpecker-ci/woodpecker/pull/5893)]\n- chore(deps): update dependency vue-i18n to v11.2.7 [[#5892](https://github.com/woodpecker-ci/woodpecker/pull/5892)]\n- chore(deps): update dependency vue-tsc to v3.2.0 [[#5891](https://github.com/woodpecker-ci/woodpecker/pull/5891)]\n- Migrate to maintained tink-go [[#5886](https://github.com/woodpecker-ci/woodpecker/pull/5886)]\n- chore(deps): update web npm deps non-major [[#5887](https://github.com/woodpecker-ci/woodpecker/pull/5887)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.10.0 [[#5888](https://github.com/woodpecker-ci/woodpecker/pull/5888)]\n- fix(deps): update golang-packages [[#5877](https://github.com/woodpecker-ci/woodpecker/pull/5877)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.9.0 [[#5873](https://github.com/woodpecker-ci/woodpecker/pull/5873)]\n- fix(deps): update golang-packages [[#5870](https://github.com/woodpecker-ci/woodpecker/pull/5870)]\n- chore(deps): lock file maintenance [[#5868](https://github.com/woodpecker-ci/woodpecker/pull/5868)]\n- fix(deps): update module github.com/gdgvda/cron to v0.6.0 [[#5867](https://github.com/woodpecker-ci/woodpecker/pull/5867)]\n- chore(deps): update dependency @intlify/unplugin-vue-i18n to v11.0.3 [[#5866](https://github.com/woodpecker-ci/woodpecker/pull/5866)]\n- chore(deps): update dependency @antfu/eslint-config to v6.7.1 [[#5865](https://github.com/woodpecker-ci/woodpecker/pull/5865)]\n- chore(deps): update web npm deps non-major [[#5864](https://github.com/woodpecker-ci/woodpecker/pull/5864)]\n- chore(deps): update dependency @types/node to v24.10.4 [[#5863](https://github.com/woodpecker-ci/woodpecker/pull/5863)]\n- chore(deps): update web npm deps non-major [[#5859](https://github.com/woodpecker-ci/woodpecker/pull/5859)]\n- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.47.0 [[#5858](https://github.com/woodpecker-ci/woodpecker/pull/5858)]\n- fix(deps): update golang-packages [[#5856](https://github.com/woodpecker-ci/woodpecker/pull/5856)]\n- fix(deps): update golang-packages [[#5851](https://github.com/woodpecker-ci/woodpecker/pull/5851)]\n- fix(deps): update golang-packages [[#5849](https://github.com/woodpecker-ci/woodpecker/pull/5849)]\n- chore(deps): lock file maintenance [[#5847](https://github.com/woodpecker-ci/woodpecker/pull/5847)]\n- chore(deps): update web npm deps non-major [[#5837](https://github.com/woodpecker-ci/woodpecker/pull/5837)]\n- chore(deps): update dependency golangci/golangci-lint to v2.7.2 [[#5845](https://github.com/woodpecker-ci/woodpecker/pull/5845)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.7.2 [[#5846](https://github.com/woodpecker-ci/woodpecker/pull/5846)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.7.0 [[#5840](https://github.com/woodpecker-ci/woodpecker/pull/5840)]\n- fix(deps): update module github.com/google/go-github/v79 to v80 [[#5838](https://github.com/woodpecker-ci/woodpecker/pull/5838)]\n- chore(deps): update pre-commit non-major [[#5836](https://github.com/woodpecker-ci/woodpecker/pull/5836)]\n- chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.22.0 [[#5833](https://github.com/woodpecker-ci/woodpecker/pull/5833)]\n- chore(deps): update dependency golangci/golangci-lint to v2.7.1 [[#5832](https://github.com/woodpecker-ci/woodpecker/pull/5832)]\n- chore(deps): update docker.io/alpine docker tag to v3.23 [[#5830](https://github.com/woodpecker-ci/woodpecker/pull/5830)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.4 [[#5828](https://github.com/woodpecker-ci/woodpecker/pull/5828)]\n- chore(deps): update dependency golang to v1.25.5 [[#5827](https://github.com/woodpecker-ci/woodpecker/pull/5827)]\n- fix(deps): update golang-packages [[#5816](https://github.com/woodpecker-ci/woodpecker/pull/5816)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.3.1 [[#5812](https://github.com/woodpecker-ci/woodpecker/pull/5812)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.3.0 [[#5807](https://github.com/woodpecker-ci/woodpecker/pull/5807)]\n- chore(deps): lock file maintenance [[#5808](https://github.com/woodpecker-ci/woodpecker/pull/5808)]\n- chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.3 [[#5804](https://github.com/woodpecker-ci/woodpecker/pull/5804)]\n- fix(deps): update dependency simple-icons to v16 [[#5802](https://github.com/woodpecker-ci/woodpecker/pull/5802)]\n- fix(deps): update module github.com/docker/cli to v29.1.1+incompatible [[#5801](https://github.com/woodpecker-ci/woodpecker/pull/5801)]\n- chore(deps): update dependency prettier to v3.7.3 [[#5800](https://github.com/woodpecker-ci/woodpecker/pull/5800)]\n- chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.2 [[#5795](https://github.com/woodpecker-ci/woodpecker/pull/5795)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1 [[#5794](https://github.com/woodpecker-ci/woodpecker/pull/5794)]\n- chore(deps): update web npm deps non-major [[#5792](https://github.com/woodpecker-ci/woodpecker/pull/5792)]\n- chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.1 [[#5793](https://github.com/woodpecker-ci/woodpecker/pull/5793)]\n- fix(deps): update module github.com/docker/cli to v29.1.0+incompatible [[#5789](https://github.com/woodpecker-ci/woodpecker/pull/5789)]\n- fix(deps): update golang-packages [[#5787](https://github.com/woodpecker-ci/woodpecker/pull/5787)]\n- chore(deps): lock file maintenance [[#5784](https://github.com/woodpecker-ci/woodpecker/pull/5784)]\n- chore(deps): update dependency simple-icons to v15.22.0 [[#5782](https://github.com/woodpecker-ci/woodpecker/pull/5782)]\n- chore(deps): update dependency vue-tsc to v3.1.5 [[#5781](https://github.com/woodpecker-ci/woodpecker/pull/5781)]\n- chore(deps): update dependency @types/lodash to v4.17.21 [[#5780](https://github.com/woodpecker-ci/woodpecker/pull/5780)]\n- chore(deps): update dependency vue-i18n to v11.2.1 [[#5779](https://github.com/woodpecker-ci/woodpecker/pull/5779)]\n- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.46.0 [[#5776](https://github.com/woodpecker-ci/woodpecker/pull/5776)]\n- chore(deps): update web npm deps non-major [[#5775](https://github.com/woodpecker-ci/woodpecker/pull/5775)]\n- fix(deps): update golang-packages [[#5770](https://github.com/woodpecker-ci/woodpecker/pull/5770)]\n- fix(deps): update golang-packages [[#5765](https://github.com/woodpecker-ci/woodpecker/pull/5765)]\n\n### Misc\n\n- Revert \"Send configuration as part of the request for external configuration\" [[#5835](https://github.com/woodpecker-ci/woodpecker/pull/5835)]\n- Allow packagers to set WebUI root to custom path [[#5809](https://github.com/woodpecker-ci/woodpecker/pull/5809)]\n- fix(queue): force agent cancellation on lease expiration [[#5823](https://github.com/woodpecker-ci/woodpecker/pull/5823)]\n- Extract interval into composition [[#5818](https://github.com/woodpecker-ci/woodpecker/pull/5818)]\n- Fix outdated Makefile target [[#5817](https://github.com/woodpecker-ci/woodpecker/pull/5817)]\n- Makefile: add target to generate man pages [[#5810](https://github.com/woodpecker-ci/woodpecker/pull/5810)]\n- Split make install targets [[#5796](https://github.com/woodpecker-ci/woodpecker/pull/5796)]\n- Use golangci docker image [[#5797](https://github.com/woodpecker-ci/woodpecker/pull/5797)]\n- Clarify envvars documentation [[#5788](https://github.com/woodpecker-ci/woodpecker/pull/5788)]\n\n## [3.12.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.12.0) - 2025-11-18\n\n### ❤️ Thanks to all contributors! ❤️\n\n@1001Josias, @6543, @JohnWalkerx, @LUKIEYF, @MeurillonGuillaume, @Utkarsh9571, @Xuxe, @anbraten, @chamburr, @henkka, @hhamalai, @marcusramberg, @pixelateapotato, @qwerty287, @yyewolf\n\n### 🔒 Security\n\n- chore(deps): update dependency vite to v7.1.11 [security] [[#5660](https://github.com/woodpecker-ci/woodpecker/pull/5660)]\n\n### 📈 Enhancement\n\n- feat(bitbucketserver): get changes from all commits in a single push event [[#5748](https://github.com/woodpecker-ci/woodpecker/pull/5748)]\n- Support for file changes in Bitbucket Cloud [[#5730](https://github.com/woodpecker-ci/woodpecker/pull/5730)]\n- feat(agent): log agent version on startup [[#5724](https://github.com/woodpecker-ci/woodpecker/pull/5724)]\n- Add Header User-Agent for request client [[#5664](https://github.com/woodpecker-ci/woodpecker/pull/5664)]\n- Switch from BoolTrue to optional.Option[bool] [[#5693](https://github.com/woodpecker-ci/woodpecker/pull/5693)]\n- Enhancement log stream reading and writing and handle new lines and max-size [[#5683](https://github.com/woodpecker-ci/woodpecker/pull/5683)]\n- Make local backend work with `cli exec` [[#4102](https://github.com/woodpecker-ci/woodpecker/pull/4102)]\n- Make pipeline/frontend/yaml/* types able to be marshaled back to YAML [[#1835](https://github.com/woodpecker-ci/woodpecker/pull/1835)]\n- Add log service addon [[#5507](https://github.com/woodpecker-ci/woodpecker/pull/5507)]\n- Support multiple users with same login name but different forges [[#5612](https://github.com/woodpecker-ci/woodpecker/pull/5612)]\n- Release linux/riscv64 binaries [[#5663](https://github.com/woodpecker-ci/woodpecker/pull/5663)]\n\n### 🐛 Bug Fixes\n\n- Fix crash when a HTTP/2 client goes away on SSE streams [[#5738](https://github.com/woodpecker-ci/woodpecker/pull/5738)]\n- Add created icon [[#5747](https://github.com/woodpecker-ci/woodpecker/pull/5747)]\n- Fix badge label padding [[#5725](https://github.com/woodpecker-ci/woodpecker/pull/5725)]\n- Fix workflow path filter for GitHub [[#5721](https://github.com/woodpecker-ci/woodpecker/pull/5721)]\n- Fix secret on new forge [[#5715](https://github.com/woodpecker-ci/woodpecker/pull/5715)]\n- Revert to forge internal implementation of pagination for `Repos()` and `Teams()` for gitea/forgejo [[#5679](https://github.com/woodpecker-ci/woodpecker/pull/5679)]\n- fix: panic due to an invalid memory address when injectSecretRecursive encounters nil values [[#5699](https://github.com/woodpecker-ci/woodpecker/pull/5699)]\n- Fix so agents don't need to specify a required label twice [[#5684](https://github.com/woodpecker-ci/woodpecker/pull/5684)]\n- Fix nil pointer dereference during GitHub Hook parsing [[#5681](https://github.com/woodpecker-ci/woodpecker/pull/5681)]\n- Allow username to be used with multiple forges [[#5676](https://github.com/woodpecker-ci/woodpecker/pull/5676)]\n- Create GitHub forge via WebUI fails to be loaded [[#5675](https://github.com/woodpecker-ci/woodpecker/pull/5675)]\n- Bitbucket: ignore push hooks with no changes propperly [[#5672](https://github.com/woodpecker-ci/woodpecker/pull/5672)]\n- fix(bitbucketdatacenter): prevent adding new repos with empty branch [[#5669](https://github.com/woodpecker-ci/woodpecker/pull/5669)]\n- cli: show description of default value for `--backend-local-temp-dir` instead of value [[#5656](https://github.com/woodpecker-ci/woodpecker/pull/5656)]\n\n### 📚 Documentation\n\n- Add docs for 3.12 [[#5763](https://github.com/woodpecker-ci/woodpecker/pull/5763)]\n- chore(deps): lock file maintenance [[#5760](https://github.com/woodpecker-ci/woodpecker/pull/5760)]\n- chore(deps): update docs npm deps non-major [[#5752](https://github.com/woodpecker-ci/woodpecker/pull/5752)]\n- chore(deps): update docs npm deps non-major [[#5733](https://github.com/woodpecker-ci/woodpecker/pull/5733)]\n- Fix typo in about.md [[#5716](https://github.com/woodpecker-ci/woodpecker/pull/5716)]\n- docs: add warning about 27-axis matrix limit [[#5700](https://github.com/woodpecker-ci/woodpecker/pull/5700)]\n- chore(deps): update dependency isomorphic-dompurify to v2.31.0 [[#5709](https://github.com/woodpecker-ci/woodpecker/pull/5709)]\n- chore(deps): update dependency @types/node to v24 [[#5706](https://github.com/woodpecker-ci/woodpecker/pull/5706)]\n- chore(deps): update docs npm deps non-major [[#5701](https://github.com/woodpecker-ci/woodpecker/pull/5701)]\n- Update path to plugins moved to woodpecker-community [[#5698](https://github.com/woodpecker-ci/woodpecker/pull/5698)]\n- chore(deps): update docs npm deps non-major [[#5688](https://github.com/woodpecker-ci/woodpecker/pull/5688)]\n- docs(plugins): add github-app-token and github-comment plugins to repository [[#5671](https://github.com/woodpecker-ci/woodpecker/pull/5671)]\n\n### 📦️ Dependency\n\n- fix(deps): update module github.com/urfave/cli/v3 to v3.6.1 [[#5759](https://github.com/woodpecker-ci/woodpecker/pull/5759)]\n- chore(deps): update dependency vue-tsc to v3.1.4 [[#5758](https://github.com/woodpecker-ci/woodpecker/pull/5758)]\n- fix(deps): update module github.com/google/go-github/v78 to v79 [[#5757](https://github.com/woodpecker-ci/woodpecker/pull/5757)]\n- fix(deps): update module github.com/docker/cli to v29 [[#5756](https://github.com/woodpecker-ci/woodpecker/pull/5756)]\n- chore(deps): update postgres docker tag to v18.1 [[#5755](https://github.com/woodpecker-ci/woodpecker/pull/5755)]\n- chore(deps): update web npm deps non-major [[#5754](https://github.com/woodpecker-ci/woodpecker/pull/5754)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.2 [[#5753](https://github.com/woodpecker-ci/woodpecker/pull/5753)]\n- chore(deps): update dependency golangci/golangci-lint to v2.6.2 [[#5751](https://github.com/woodpecker-ci/woodpecker/pull/5751)]\n- fix(deps): update golang-packages [[#5746](https://github.com/woodpecker-ci/woodpecker/pull/5746)]\n- fix(deps): update golang-packages [[#5745](https://github.com/woodpecker-ci/woodpecker/pull/5745)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.6.0 [[#5743](https://github.com/woodpecker-ci/woodpecker/pull/5743)]\n- chore(deps): lock file maintenance [[#5744](https://github.com/woodpecker-ci/woodpecker/pull/5744)]\n- fix(deps): update golang-packages [[#5741](https://github.com/woodpecker-ci/woodpecker/pull/5741)]\n- chore(deps): update dependency simple-icons to v15.20.0 [[#5742](https://github.com/woodpecker-ci/woodpecker/pull/5742)]\n- fix(deps): update module github.com/google/go-github/v77 to v78 [[#5739](https://github.com/woodpecker-ci/woodpecker/pull/5739)]\n- fix(deps): update module github.com/google/go-github/v76 to v77 [[#5737](https://github.com/woodpecker-ci/woodpecker/pull/5737)]\n- fix(deps): update dependency marked to v17 [[#5736](https://github.com/woodpecker-ci/woodpecker/pull/5736)]\n- chore(deps): update web npm deps non-major [[#5735](https://github.com/woodpecker-ci/woodpecker/pull/5735)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.1 [[#5734](https://github.com/woodpecker-ci/woodpecker/pull/5734)]\n- chore(deps): update dependency golangci/golangci-lint to v2.6.1 [[#5732](https://github.com/woodpecker-ci/woodpecker/pull/5732)]\n- chore(deps): update dependency golang to v1.25.4 [[#5731](https://github.com/woodpecker-ci/woodpecker/pull/5731)]\n- fix(deps): update golang-packages to v28.5.2+incompatible [[#5723](https://github.com/woodpecker-ci/woodpecker/pull/5723)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.159.0 [[#5720](https://github.com/woodpecker-ci/woodpecker/pull/5720)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.158.0 [[#5718](https://github.com/woodpecker-ci/woodpecker/pull/5718)]\n- chore(deps): lock file maintenance [[#5711](https://github.com/woodpecker-ci/woodpecker/pull/5711)]\n- chore(deps): update dependency golangci/golangci-lint to v2.6.0 [[#5702](https://github.com/woodpecker-ci/woodpecker/pull/5702)]\n- chore(deps): update web npm deps non-major [[#5705](https://github.com/woodpecker-ci/woodpecker/pull/5705)]\n- fix(deps): update module github.com/yaronf/httpsign to v0.4.1 [[#5708](https://github.com/woodpecker-ci/woodpecker/pull/5708)]\n- chore(deps): update node.js to v24 [[#5707](https://github.com/woodpecker-ci/woodpecker/pull/5707)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.0 [[#5704](https://github.com/woodpecker-ci/woodpecker/pull/5704)]\n- chore(deps): update gitea/gitea docker tag to v1.25 [[#5703](https://github.com/woodpecker-ci/woodpecker/pull/5703)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.157.1 [[#5697](https://github.com/woodpecker-ci/woodpecker/pull/5697)]\n- chore(deps): lock file maintenance [[#5695](https://github.com/woodpecker-ci/woodpecker/pull/5695)]\n- chore(deps): update web npm deps non-major [[#5694](https://github.com/woodpecker-ci/woodpecker/pull/5694)]\n- fix(deps): update dependency @vueuse/core to v14 [[#5692](https://github.com/woodpecker-ci/woodpecker/pull/5692)]\n- chore(deps): update dependency vitest to v4 [[#5691](https://github.com/woodpecker-ci/woodpecker/pull/5691)]\n- chore(deps): update docker.io/mysql docker tag to v9.5.0 [[#5690](https://github.com/woodpecker-ci/woodpecker/pull/5690)]\n- chore(deps): update web npm deps non-major [[#5689](https://github.com/woodpecker-ci/woodpecker/pull/5689)]\n- chore(deps): update dependency mvdan/gofumpt to v0.9.2 [[#5687](https://github.com/woodpecker-ci/woodpecker/pull/5687)]\n- fix(deps): update github.com/urfave/cli-docs/v3 digest to 72b87d1 [[#5686](https://github.com/woodpecker-ci/woodpecker/pull/5686)]\n- fix(deps): update module code.gitea.io/sdk/gitea to v0.22.1 [[#5682](https://github.com/woodpecker-ci/woodpecker/pull/5682)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.5.0 [[#5668](https://github.com/woodpecker-ci/woodpecker/pull/5668)]\n- fix(deps): update module xorm.io/xorm to v1.3.11 [[#5662](https://github.com/woodpecker-ci/woodpecker/pull/5662)]\n- chore(deps): lock file maintenance [[#5657](https://github.com/woodpecker-ci/woodpecker/pull/5657)]\n\n### Misc\n\n- Also create image preview on label change only [[#5673](https://github.com/woodpecker-ci/woodpecker/pull/5673)]\n- Add migration tests for postgres [[#669](https://github.com/woodpecker-ci/woodpecker/pull/669)]\n\n## [3.11.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.11.0) - 2025-10-19\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Gusted, @MartinSchmidt, @anbraten, @eikemeier, @henkka, @joariasl, @marcusramberg, @qwerty287, @xoxys\n\n### ✨ Features\n\n- Allow to configure a config extension per repo [[#3349](https://github.com/woodpecker-ci/woodpecker/pull/3349)]\n\n### 📈 Enhancement\n\n- Improve log.CopyByLine to be more robust [[#5641](https://github.com/woodpecker-ci/woodpecker/pull/5641)]\n- Add pagination for `Repos()` and `Teams()` in Forge interface [[#5638](https://github.com/woodpecker-ci/woodpecker/pull/5638)]\n- Modernize a couple of loops, fix incorrect function docs [[#5637](https://github.com/woodpecker-ci/woodpecker/pull/5637)]\n- Allow agents to require labels on workflows [[#5633](https://github.com/woodpecker-ci/woodpecker/pull/5633)]\n- Add repo filter options to GetRepos api [[#5631](https://github.com/woodpecker-ci/woodpecker/pull/5631)]\n- Add branch filter to cli pipeline purge [[#5616](https://github.com/woodpecker-ci/woodpecker/pull/5616)]\n- Switch to GitHub REST API to load changed files [[#5618](https://github.com/woodpecker-ci/woodpecker/pull/5618)]\n- Enhance Bitbucket Datacenter build status reporting [[#5611](https://github.com/woodpecker-ci/woodpecker/pull/5611)]\n- List all repos in repository view if user is admin [[#5595](https://github.com/woodpecker-ci/woodpecker/pull/5595)]\n- Add disabled badge to agents [[#5593](https://github.com/woodpecker-ci/woodpecker/pull/5593)]\n- Improve error message when agent fails to connect [[#5587](https://github.com/woodpecker-ci/woodpecker/pull/5587)]\n- local backend: test shells if unknown [[#5570](https://github.com/woodpecker-ci/woodpecker/pull/5570)]\n\n### 🐛 Bug Fixes\n\n- Fix missing background in pipeline deploy popup [[#5630](https://github.com/woodpecker-ci/woodpecker/pull/5630)]\n- Support matrix environ badges only with no key-values [[#5578](https://github.com/woodpecker-ci/woodpecker/pull/5578)]\n- local backend: fix steps having logs form other steps [[#5582](https://github.com/woodpecker-ci/woodpecker/pull/5582)]\n- local backend: fix windows cmd.exe command escaping [[#5569](https://github.com/woodpecker-ci/woodpecker/pull/5569)]\n- Bump buildx and limit max parallel builds [[#5579](https://github.com/woodpecker-ci/woodpecker/pull/5579)]\n- Don't split language if not required [[#5576](https://github.com/woodpecker-ci/woodpecker/pull/5576)]\n\n### 📚 Documentation\n\n- chore(deps): update docs npm deps non-major [[#5649](https://github.com/woodpecker-ci/woodpecker/pull/5649)]\n- Document Forge interface precisely [[#5636](https://github.com/woodpecker-ci/woodpecker/pull/5636)]\n- chore(deps): update dependency @types/node to v22.18.10 [[#5624](https://github.com/woodpecker-ci/woodpecker/pull/5624)]\n- chore(deps): update docs npm deps non-major [[#5622](https://github.com/woodpecker-ci/woodpecker/pull/5622)]\n- chore(deps): lock file maintenance [[#5607](https://github.com/woodpecker-ci/woodpecker/pull/5607)]\n- chore(deps): update dependency @tsconfig/docusaurus to v2.0.4 [[#5605](https://github.com/woodpecker-ci/woodpecker/pull/5605)]\n- chore(deps): update docs npm deps non-major [[#5600](https://github.com/woodpecker-ci/woodpecker/pull/5600)]\n- Fix Kubernetes install docs to use OCI artifacts instead of deprecated helm chart [[#5596](https://github.com/woodpecker-ci/woodpecker/pull/5596)]\n- Document pipeline backend engine interface precisely [[#5583](https://github.com/woodpecker-ci/woodpecker/pull/5583)]\n\n### 📦️ Dependency\n\n- chore(deps): update dependency simple-icons to v15.17.0 [[#5655](https://github.com/woodpecker-ci/woodpecker/pull/5655)]\n- chore(deps): update dependency jsdom to v27.0.1 [[#5653](https://github.com/woodpecker-ci/woodpecker/pull/5653)]\n- fix(deps): update module github.com/google/go-github/v75 to v76 [[#5652](https://github.com/woodpecker-ci/woodpecker/pull/5652)]\n- chore(deps): update dependency @antfu/eslint-config to v6 [[#5651](https://github.com/woodpecker-ci/woodpecker/pull/5651)]\n- chore(deps): update web npm deps non-major [[#5650](https://github.com/woodpecker-ci/woodpecker/pull/5650)]\n- chore(deps): update dependency golang to v1.25.3 [[#5648](https://github.com/woodpecker-ci/woodpecker/pull/5648)]\n- fix(deps): update module github.com/yaronf/httpsign to v0.3.3 [[#5647](https://github.com/woodpecker-ci/woodpecker/pull/5647)]\n- fix(deps): update module github.com/charmbracelet/huh to v0.8.0 [[#5643](https://github.com/woodpecker-ci/woodpecker/pull/5643)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.157.0 [[#5640](https://github.com/woodpecker-ci/woodpecker/pull/5640)]\n- chore(deps): lock file maintenance [[#5634](https://github.com/woodpecker-ci/woodpecker/pull/5634)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.156.0 [[#5626](https://github.com/woodpecker-ci/woodpecker/pull/5626)]\n- chore(deps): lock file maintenance [[#5627](https://github.com/woodpecker-ci/woodpecker/pull/5627)]\n- chore(deps): update dependency @types/node to v22.18.10 [[#5625](https://github.com/woodpecker-ci/woodpecker/pull/5625)]\n- chore(deps): update web npm deps non-major [[#5623](https://github.com/woodpecker-ci/woodpecker/pull/5623)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.3 [[#5621](https://github.com/woodpecker-ci/woodpecker/pull/5621)]\n- chore(deps): update dependency golang to v1.25.2 [[#5620](https://github.com/woodpecker-ci/woodpecker/pull/5620)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.155.0 [[#5617](https://github.com/woodpecker-ci/woodpecker/pull/5617)]\n- fix(deps): update golang-packages [[#5614](https://github.com/woodpecker-ci/woodpecker/pull/5614)]\n- fix(deps): update golang-packages [[#5610](https://github.com/woodpecker-ci/woodpecker/pull/5610)]\n- chore(deps): update dependency simple-icons to v15.16.1 [[#5606](https://github.com/woodpecker-ci/woodpecker/pull/5606)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.151.0 [[#5604](https://github.com/woodpecker-ci/woodpecker/pull/5604)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.7.0 [[#5603](https://github.com/woodpecker-ci/woodpecker/pull/5603)]\n- chore(deps): update web npm deps non-major [[#5602](https://github.com/woodpecker-ci/woodpecker/pull/5602)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.6 [[#5601](https://github.com/woodpecker-ci/woodpecker/pull/5601)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.1 [[#5598](https://github.com/woodpecker-ci/woodpecker/pull/5598)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.2 [[#5599](https://github.com/woodpecker-ci/woodpecker/pull/5599)]\n- fix(deps): update golang-packages [[#5594](https://github.com/woodpecker-ci/woodpecker/pull/5594)]\n- chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.2 [[#5577](https://github.com/woodpecker-ci/woodpecker/pull/5577)]\n- chore(deps): lock file maintenance [[#5566](https://github.com/woodpecker-ci/woodpecker/pull/5566)]\n\n### Misc\n\n- flake.lock: Update [[#5635](https://github.com/woodpecker-ci/woodpecker/pull/5635)]\n- chore(deps): drop `github.com/gorilla/securecookie` [[#5609](https://github.com/woodpecker-ci/woodpecker/pull/5609)]\n- Announce only stable releases [[#5580](https://github.com/woodpecker-ci/woodpecker/pull/5580)]\n\n## [3.10.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.10.0) - 2025-09-28\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Gusted, @da-Kai, @henkka, @hhamalai, @j04n-f, @klausi85, @marcusramberg, @qwerty287, @xoxys, @zhedazijingang\n\n### 🔒 Security\n\n- chore(deps): update dependency vite to v7.1.5 [security] [[#5495](https://github.com/woodpecker-ci/woodpecker/pull/5495)]\n\n### ✨ Features\n\n- New event pull request metadata [[#5214](https://github.com/woodpecker-ci/woodpecker/pull/5214)]\n- Add task UUID label to Kubernetes pods [[#5544](https://github.com/woodpecker-ci/woodpecker/pull/5544)]\n- feat: expose listing available organizations via woodpecker-go / CLI [[#5481](https://github.com/woodpecker-ci/woodpecker/pull/5481)]\n- Add milestone to metadata [[#5174](https://github.com/woodpecker-ci/woodpecker/pull/5174)]\n\n### 📈 Enhancement\n\n- Trace errors during SetupWorkflow, make service step setup errors visible to user [[#5559](https://github.com/woodpecker-ci/woodpecker/pull/5559)]\n- Enable completion support for cli [[#5552](https://github.com/woodpecker-ci/woodpecker/pull/5552)]\n- Add `StepFinished` to log service [[#5530](https://github.com/woodpecker-ci/woodpecker/pull/5530)]\n- Migrate to mockery v3 [[#5547](https://github.com/woodpecker-ci/woodpecker/pull/5547)]\n- Show human readable information in queue info [[#5516](https://github.com/woodpecker-ci/woodpecker/pull/5516)]\n- feat(bitbucketdatacenter): Implement missing OrgMembership method [[#5476](https://github.com/woodpecker-ci/woodpecker/pull/5476)]\n- Cleanup columns in forges table [[#5517](https://github.com/woodpecker-ci/woodpecker/pull/5517)]\n- Allow to get secrets from file [[#5509](https://github.com/woodpecker-ci/woodpecker/pull/5509)]\n- refactor: use slices.Contains to simplify [[#5468](https://github.com/woodpecker-ci/woodpecker/pull/5468)]\n- Hide unsupported forge options [[#5465](https://github.com/woodpecker-ci/woodpecker/pull/5465)]\n- Collapse changed files in file-tree [[#5451](https://github.com/woodpecker-ci/woodpecker/pull/5451)]\n- Simplify queue interface [[#5449](https://github.com/woodpecker-ci/woodpecker/pull/5449)]\n\n### 🐛 Bug Fixes\n\n- Support for pull requests opened events from forked repositories [[#5536](https://github.com/woodpecker-ci/woodpecker/pull/5536)]\n- Add back-off retry for pod log streaming to kubernetes backend [[#5550](https://github.com/woodpecker-ci/woodpecker/pull/5550)]\n- Fix dir not found handling [[#5533](https://github.com/woodpecker-ci/woodpecker/pull/5533)]\n- Show readable error [[#5501](https://github.com/woodpecker-ci/woodpecker/pull/5501)]\n- fix: allow spaces in cli string slices [[#5494](https://github.com/woodpecker-ci/woodpecker/pull/5494)]\n- fix: changed schema definition for \"backend_options.kubernetes.tolerations\" to accept an array of objects [[#5478](https://github.com/woodpecker-ci/woodpecker/pull/5478)]\n- Print execution errors [[#5448](https://github.com/woodpecker-ci/woodpecker/pull/5448)]\n\n### 📚 Documentation\n\n- chore(deps): update dependency @types/react to v19.1.15 [[#5562](https://github.com/woodpecker-ci/woodpecker/pull/5562)]\n- chore(deps): update docs npm deps non-major [[#5554](https://github.com/woodpecker-ci/woodpecker/pull/5554)]\n- Add MCP tool to awesome docs [[#5546](https://github.com/woodpecker-ci/woodpecker/pull/5546)]\n- chore(deps): update docs npm deps non-major [[#5527](https://github.com/woodpecker-ci/woodpecker/pull/5527)]\n- chore(deps): update docs npm deps non-major [[#5512](https://github.com/woodpecker-ci/woodpecker/pull/5512)]\n- Add a blog post [[#5510](https://github.com/woodpecker-ci/woodpecker/pull/5510)]\n- chore(deps): update docs npm deps non-major [[#5503](https://github.com/woodpecker-ci/woodpecker/pull/5503)]\n- docs: add SonarQube to plugins list [[#5502](https://github.com/woodpecker-ci/woodpecker/pull/5502)]\n- Add Bitbucket key limit known issue [[#5497](https://github.com/woodpecker-ci/woodpecker/pull/5497)]\n- chore(deps): update dependency @types/node to v22.18.1 [[#5484](https://github.com/woodpecker-ci/woodpecker/pull/5484)]\n- chore(deps): update docs npm deps non-major [[#5472](https://github.com/woodpecker-ci/woodpecker/pull/5472)]\n- Add ui proxy docs [[#5459](https://github.com/woodpecker-ci/woodpecker/pull/5459)]\n- chore(deps): update dependency @types/react to v19.1.11 [[#5454](https://github.com/woodpecker-ci/woodpecker/pull/5454)]\n- Add easypanel community package [[#5446](https://github.com/woodpecker-ci/woodpecker/pull/5446)]\n- Add some blogs and videos [[#5445](https://github.com/woodpecker-ci/woodpecker/pull/5445)]\n\n### 📦️ Dependency\n\n- chore(deps): update dependency vue-tsc to v3.1.0 [[#5563](https://github.com/woodpecker-ci/woodpecker/pull/5563)]\n- fix(deps): update golang-packages [[#5561](https://github.com/woodpecker-ci/woodpecker/pull/5561)]\n- chore(deps): update postgres docker tag to v18 [[#5557](https://github.com/woodpecker-ci/woodpecker/pull/5557)]\n- chore(deps): update docker.io/postgres docker tag to v18 [[#5556](https://github.com/woodpecker-ci/woodpecker/pull/5556)]\n- chore(deps): update web npm deps non-major [[#5553](https://github.com/woodpecker-ci/woodpecker/pull/5553)]\n- chore(deps): update pre-commit hook hadolint/hadolint to v2.14.0 [[#5555](https://github.com/woodpecker-ci/woodpecker/pull/5555)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.148.0 [[#5548](https://github.com/woodpecker-ci/woodpecker/pull/5548)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.147.1 [[#5541](https://github.com/woodpecker-ci/woodpecker/pull/5541)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.5.0 [[#5535](https://github.com/woodpecker-ci/woodpecker/pull/5535)]\n- fix(deps): update dependency simple-icons to v15.16.0 [[#5532](https://github.com/woodpecker-ci/woodpecker/pull/5532)]\n- fix(deps): update module github.com/gin-gonic/gin to v1.11.0 [[#5531](https://github.com/woodpecker-ci/woodpecker/pull/5531)]\n- fix(deps): update web npm deps non-major [[#5528](https://github.com/woodpecker-ci/woodpecker/pull/5528)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.146.0 [[#5524](https://github.com/woodpecker-ci/woodpecker/pull/5524)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.145.0 [[#5523](https://github.com/woodpecker-ci/woodpecker/pull/5523)]\n- chore(deps): lock file maintenance [[#5514](https://github.com/woodpecker-ci/woodpecker/pull/5514)]\n- fix(deps): update dependency marked to v16.3.0 [[#5513](https://github.com/woodpecker-ci/woodpecker/pull/5513)]\n- fix(deps): update dependency simple-icons to v15.15.0 [[#5508](https://github.com/woodpecker-ci/woodpecker/pull/5508)]\n- chore(deps): update dependency jsdom to v27 [[#5506](https://github.com/woodpecker-ci/woodpecker/pull/5506)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.144.1 [[#5505](https://github.com/woodpecker-ci/woodpecker/pull/5505)]\n- chore(deps): update web npm deps non-major [[#5504](https://github.com/woodpecker-ci/woodpecker/pull/5504)]\n- fix(deps): update golang-packages [[#5499](https://github.com/woodpecker-ci/woodpecker/pull/5499)]\n- fix(deps): update golang-packages [[#5496](https://github.com/woodpecker-ci/woodpecker/pull/5496)]\n- fix(deps): update golang-packages [[#5493](https://github.com/woodpecker-ci/woodpecker/pull/5493)]\n- chore(deps): lock file maintenance [[#5492](https://github.com/woodpecker-ci/woodpecker/pull/5492)]\n- fix(deps): update golang-packages [[#5491](https://github.com/woodpecker-ci/woodpecker/pull/5491)]\n- fix(deps): update dependency simple-icons to v15.14.0 [[#5490](https://github.com/woodpecker-ci/woodpecker/pull/5490)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.23.2 [[#5489](https://github.com/woodpecker-ci/woodpecker/pull/5489)]\n- chore(deps): update dependency @intlify/unplugin-vue-i18n to v11 [[#5487](https://github.com/woodpecker-ci/woodpecker/pull/5487)]\n- fix(deps): update web npm deps non-major [[#5486](https://github.com/woodpecker-ci/woodpecker/pull/5486)]\n- chore(deps): update dependency golang to v1.25.1 [[#5485](https://github.com/woodpecker-ci/woodpecker/pull/5485)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.23.1 [[#5483](https://github.com/woodpecker-ci/woodpecker/pull/5483)]\n- fix(deps): update golang-packages to v28.4.0+incompatible [[#5480](https://github.com/woodpecker-ci/woodpecker/pull/5480)]\n- fix(deps): update golang-packages [[#5479](https://github.com/woodpecker-ci/woodpecker/pull/5479)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.5 [[#5475](https://github.com/woodpecker-ci/woodpecker/pull/5475)]\n- fix(deps): update web npm deps non-major [[#5473](https://github.com/woodpecker-ci/woodpecker/pull/5473)]\n- fix(deps): update golang-packages [[#5467](https://github.com/woodpecker-ci/woodpecker/pull/5467)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.2 [[#5466](https://github.com/woodpecker-ci/woodpecker/pull/5466)]\n- fix(deps): update golang-packages [[#5463](https://github.com/woodpecker-ci/woodpecker/pull/5463)]\n- chore(deps): lock file maintenance [[#5458](https://github.com/woodpecker-ci/woodpecker/pull/5458)]\n- fix(deps): update golang-packages [[#5457](https://github.com/woodpecker-ci/woodpecker/pull/5457)]\n- fix(deps): update dependency simple-icons to v15.12.0 [[#5456](https://github.com/woodpecker-ci/woodpecker/pull/5456)]\n- fix(deps): update web npm deps non-major [[#5455](https://github.com/woodpecker-ci/woodpecker/pull/5455)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.0 [[#5452](https://github.com/woodpecker-ci/woodpecker/pull/5452)]\n- fix(deps): update golang-packages [[#5442](https://github.com/woodpecker-ci/woodpecker/pull/5442)]\n\n### Misc\n\n- Fix prettier configs [[#5529](https://github.com/woodpecker-ci/woodpecker/pull/5529)]\n- eslint ignore html-indent in vue [[#5521](https://github.com/woodpecker-ci/woodpecker/pull/5521)]\n- Remove twitter from release template [[#5447](https://github.com/woodpecker-ci/woodpecker/pull/5447)]\n\n## [3.9.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.9.0) - 2025-08-20\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @anbraten, @clintonsteiner, @henkka, @hhamalai, @hrfee, @ivaltryek, @lilioid, @qwerty287, @scottshotgg, @wgroeneveld, @xoxys\n\n### 🔒 Security\n\n- Remediate webpack vulnerability in webpack-dev-server [[#5264](https://github.com/woodpecker-ci/woodpecker/pull/5264)]\n- fix(deps): update module github.com/docker/docker to v28.3.3+incompatible [security] [[#5373](https://github.com/woodpecker-ci/woodpecker/pull/5373)]\n- Prevent secrets from leaking to Kubernetes API Server logs [[#5305](https://github.com/woodpecker-ci/woodpecker/pull/5305)]\n\n### ✨ Features\n\n- feat(k8s): Kubernetes namespace per organization [[#5309](https://github.com/woodpecker-ci/woodpecker/pull/5309)]\n- Add and edit additional forges in UI [[#5328](https://github.com/woodpecker-ci/woodpecker/pull/5328)]\n\n### 📈 Enhancement\n\n- Rename oauth variables [[#5435](https://github.com/woodpecker-ci/woodpecker/pull/5435)]\n- Add `fsGroupChangePolicy` option to Kubernetes backend [[#5416](https://github.com/woodpecker-ci/woodpecker/pull/5416)]\n- Rework background colors for light/dark theme [[#5411](https://github.com/woodpecker-ci/woodpecker/pull/5411)]\n- Allow to set default approval mode [[#5406](https://github.com/woodpecker-ci/woodpecker/pull/5406)]\n- Add Agent-level Tolerations setting [[#5266](https://github.com/woodpecker-ci/woodpecker/pull/5266)]\n- feat(k8s): k8s priority class name config [[#5391](https://github.com/woodpecker-ci/woodpecker/pull/5391)]\n- Count reopening an pull as opening an pull [[#5370](https://github.com/woodpecker-ci/woodpecker/pull/5370)]\n- Add pipeline log fullscreen [[#5377](https://github.com/woodpecker-ci/woodpecker/pull/5377)]\n- Show changed files as file-tree [[#5379](https://github.com/woodpecker-ci/woodpecker/pull/5379)]\n- Replace header bg with border [[#5380](https://github.com/woodpecker-ci/woodpecker/pull/5380)]\n- Prevent body jump when scrollbar appears [[#5381](https://github.com/woodpecker-ci/woodpecker/pull/5381)]\n- Show oauth host and favicon on login [[#5376](https://github.com/woodpecker-ci/woodpecker/pull/5376)]\n- Support secrets in `cli exec` [[#5374](https://github.com/woodpecker-ci/woodpecker/pull/5374)]\n- Simplify backend types [[#5299](https://github.com/woodpecker-ci/woodpecker/pull/5299)]\n\n### 🐛 Bug Fixes\n\n- Handle empty url and oauth_host on login page [[#5434](https://github.com/woodpecker-ci/woodpecker/pull/5434)]\n- Fix background color of pipeline step list [[#5431](https://github.com/woodpecker-ci/woodpecker/pull/5431)]\n- Fix bitbucket status sending [[#5372](https://github.com/woodpecker-ci/woodpecker/pull/5372)]\n- Correct OpenApi LookupOrg router path [[#5351](https://github.com/woodpecker-ci/woodpecker/pull/5351)]\n- fix(agent): handle context cancellation [[#5323](https://github.com/woodpecker-ci/woodpecker/pull/5323)]\n- woodpecker-go/types: fix time-related struct field tags [[#5343](https://github.com/woodpecker-ci/woodpecker/pull/5343)]\n- Reload repo on hook [[#5324](https://github.com/woodpecker-ci/woodpecker/pull/5324)]\n- Fix loading icons and add missing loading indicators [[#5329](https://github.com/woodpecker-ci/woodpecker/pull/5329)]\n- Use correct parameter for forge selection on login [[#5325](https://github.com/woodpecker-ci/woodpecker/pull/5325)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#5430](https://github.com/woodpecker-ci/woodpecker/pull/5430)]\n- chore(deps): update docs npm deps non-major [[#5420](https://github.com/woodpecker-ci/woodpecker/pull/5420)]\n- Remove X link [[#5412](https://github.com/woodpecker-ci/woodpecker/pull/5412)]\n- fix(deps): update docs npm deps non-major [[#5395](https://github.com/woodpecker-ci/woodpecker/pull/5395)]\n- fix(deps): update docs npm deps non-major [[#5384](https://github.com/woodpecker-ci/woodpecker/pull/5384)]\n- Remove references of kaniko [[#5371](https://github.com/woodpecker-ci/woodpecker/pull/5371)]\n- Add ASCII JUnit Test Report plugin [[#5355](https://github.com/woodpecker-ci/woodpecker/pull/5355)]\n- fix(deps): update docs npm deps non-major [[#5340](https://github.com/woodpecker-ci/woodpecker/pull/5340)]\n- chore(deps): update docs npm deps non-major [[#5316](https://github.com/woodpecker-ci/woodpecker/pull/5316)]\n\n### 📦️ Dependency\n\n- fix(deps): update module google.golang.org/grpc to v1.75.0 [[#5437](https://github.com/woodpecker-ci/woodpecker/pull/5437)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.141.1 [[#5432](https://github.com/woodpecker-ci/woodpecker/pull/5432)]\n- chore(deps): update golang-lang [[#5423](https://github.com/woodpecker-ci/woodpecker/pull/5423)]\n- chore(deps): update docker.io/golang docker tag to v1.25 [[#5422](https://github.com/woodpecker-ci/woodpecker/pull/5422)]\n- fix(deps): update dependency simple-icons to v15.11.0 [[#5427](https://github.com/woodpecker-ci/woodpecker/pull/5427)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.4.0 [[#5425](https://github.com/woodpecker-ci/woodpecker/pull/5425)]\n- chore(deps): update postgres docker tag to v17.6 [[#5424](https://github.com/woodpecker-ci/woodpecker/pull/5424)]\n- fix(deps): update web npm deps non-major [[#5421](https://github.com/woodpecker-ci/woodpecker/pull/5421)]\n- fix(deps): update golang-packages [[#5415](https://github.com/woodpecker-ci/woodpecker/pull/5415)]\n- fix(deps): update golang-packages [[#5413](https://github.com/woodpecker-ci/woodpecker/pull/5413)]\n- fix(deps): update golang-packages [[#5407](https://github.com/woodpecker-ci/woodpecker/pull/5407)]\n- chore(deps): lock file maintenance [[#5404](https://github.com/woodpecker-ci/woodpecker/pull/5404)]\n- chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v6 [[#5399](https://github.com/woodpecker-ci/woodpecker/pull/5399)]\n- fix(deps): update dependency simple-icons to v15.10.0 [[#5400](https://github.com/woodpecker-ci/woodpecker/pull/5400)]\n- fix(deps): update web npm deps non-major [[#5396](https://github.com/woodpecker-ci/woodpecker/pull/5396)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.4.0 [[#5394](https://github.com/woodpecker-ci/woodpecker/pull/5394)]\n- chore(deps): update dependency golang to v1.24.6 [[#5393](https://github.com/woodpecker-ci/woodpecker/pull/5393)]\n- fix(deps): update golang-packages [[#5392](https://github.com/woodpecker-ci/woodpecker/pull/5392)]\n- chore(deps): lock file maintenance [[#5388](https://github.com/woodpecker-ci/woodpecker/pull/5388)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.3.1 [[#5386](https://github.com/woodpecker-ci/woodpecker/pull/5386)]\n- fix(deps): update web npm deps non-major [[#5385](https://github.com/woodpecker-ci/woodpecker/pull/5385)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.23.0 [[#5382](https://github.com/woodpecker-ci/woodpecker/pull/5382)]\n- fix(deps): update golang-packages [[#5375](https://github.com/woodpecker-ci/woodpecker/pull/5375)]\n- chore(deps): lock file maintenance [[#5369](https://github.com/woodpecker-ci/woodpecker/pull/5369)]\n- fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.9.1 [[#5365](https://github.com/woodpecker-ci/woodpecker/pull/5365)]\n- fix(deps): update module github.com/google/go-github/v73 to v74 [[#5363](https://github.com/woodpecker-ci/woodpecker/pull/5363)]\n- chore(deps): update dependency @antfu/eslint-config to v5 [[#5362](https://github.com/woodpecker-ci/woodpecker/pull/5362)]\n- chore(deps): update web npm deps non-major [[#5361](https://github.com/woodpecker-ci/woodpecker/pull/5361)]\n- chore(deps): update docker.io/mysql docker tag to v9.4.0 [[#5359](https://github.com/woodpecker-ci/woodpecker/pull/5359)]\n- fix(deps): update golang-packages [[#5356](https://github.com/woodpecker-ci/woodpecker/pull/5356)]\n- 📦 update web dependencies [[#5352](https://github.com/woodpecker-ci/woodpecker/pull/5352)]\n- chore(config): migrate renovate config [[#5350](https://github.com/woodpecker-ci/woodpecker/pull/5350)]\n- chore(deps): lock file maintenance [[#5348](https://github.com/woodpecker-ci/woodpecker/pull/5348)]\n- fix(deps): update golang-packages [[#5347](https://github.com/woodpecker-ci/woodpecker/pull/5347)]\n- fix(deps): update golang-packages [[#5336](https://github.com/woodpecker-ci/woodpecker/pull/5336)]\n- chore(deps): lock file maintenance [[#5344](https://github.com/woodpecker-ci/woodpecker/pull/5344)]\n- fix(deps): update web npm deps non-major [[#5341](https://github.com/woodpecker-ci/woodpecker/pull/5341)]\n- fix(deps): update dependency vue-i18n to v11.1.10 [security] [[#5335](https://github.com/woodpecker-ci/woodpecker/pull/5335)]\n- fix(deps): update golang-packages [[#5333](https://github.com/woodpecker-ci/woodpecker/pull/5333)]\n- chore(deps): lock file maintenance [[#5320](https://github.com/woodpecker-ci/woodpecker/pull/5320)]\n- fix(deps): update web npm deps non-major [[#5317](https://github.com/woodpecker-ci/woodpecker/pull/5317)]\n- fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.9.0 [[#5318](https://github.com/woodpecker-ci/woodpecker/pull/5318)]\n- chore(deps): update dependency golang to v1.24.5 [[#5314](https://github.com/woodpecker-ci/woodpecker/pull/5314)]\n- fix(deps): update golang-packages [[#5313](https://github.com/woodpecker-ci/woodpecker/pull/5313)]\n- fix(deps): update golang-packages [[#5311](https://github.com/woodpecker-ci/woodpecker/pull/5311)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.134.0 [[#5308](https://github.com/woodpecker-ci/woodpecker/pull/5308)]\n- chore(deps): lock file maintenance [[#5307](https://github.com/woodpecker-ci/woodpecker/pull/5307)]\n\n### Misc\n\n- 🧑‍💻 Add support for proxying to existing woodpecker server [[#5354](https://github.com/woodpecker-ci/woodpecker/pull/5354)]\n- Update and improve nix flake [[#5349](https://github.com/woodpecker-ci/woodpecker/pull/5349)]\n- Update issue number for link checker [[#5327](https://github.com/woodpecker-ci/woodpecker/pull/5327)]\n\n## [3.8.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.8.0) - 2025-07-05\n\n### ❤️ Thanks to all contributors! ❤️\n\n@OCram85, @henkka, @johanvdw, @mmatous, @qwerty287\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#5302](https://github.com/woodpecker-ci/woodpecker/pull/5302)]\n- chore(deps): update dependency @types/node to v22.15.34 [[#5280](https://github.com/woodpecker-ci/woodpecker/pull/5280)]\n- chore(deps): update dependency @types/node to v22.15.33 [[#5277](https://github.com/woodpecker-ci/woodpecker/pull/5277)]\n- fix(deps): update docs npm deps non-major [[#5267](https://github.com/woodpecker-ci/woodpecker/pull/5267)]\n- add Peckify plugin [[#5260](https://github.com/woodpecker-ci/woodpecker/pull/5260)]\n- fix(deps): update docs npm deps non-major [[#5252](https://github.com/woodpecker-ci/woodpecker/pull/5252)]\n- fix(deps): update docs npm deps non-major [[#5226](https://github.com/woodpecker-ci/woodpecker/pull/5226)]\n\n### 🐛 Bug Fixes\n\n- Fix gitlab MR fetching [[#5287](https://github.com/woodpecker-ci/woodpecker/pull/5287)]\n- Use pipeline number in title [[#5275](https://github.com/woodpecker-ci/woodpecker/pull/5275)]\n- Adjust documentation urls [[#5273](https://github.com/woodpecker-ci/woodpecker/pull/5273)]\n- Fix doc links in agent settings [[#5251](https://github.com/woodpecker-ci/woodpecker/pull/5251)]\n\n### 📈 Enhancement\n\n- Add pipeline author and avatar env vars [[#5227](https://github.com/woodpecker-ci/woodpecker/pull/5227)]\n- Support for pull request file changes in bitbucketdatacenter [[#5205](https://github.com/woodpecker-ci/woodpecker/pull/5205)]\n\n### 📦️ Dependency\n\n- chore(deps): update dependency vue-tsc to v3 [[#5301](https://github.com/woodpecker-ci/woodpecker/pull/5301)]\n- chore(deps): update web npm deps non-major [[#5300](https://github.com/woodpecker-ci/woodpecker/pull/5300)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.3.0 [[#5298](https://github.com/woodpecker-ci/woodpecker/pull/5298)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.1 [[#5297](https://github.com/woodpecker-ci/woodpecker/pull/5297)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.2 [[#5295](https://github.com/woodpecker-ci/woodpecker/pull/5295)]\n- chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.1 [[#5296](https://github.com/woodpecker-ci/woodpecker/pull/5296)]\n- chore(deps): lock file maintenance [[#5289](https://github.com/woodpecker-ci/woodpecker/pull/5289)]\n- fix(deps): update web npm deps non-major [[#5281](https://github.com/woodpecker-ci/woodpecker/pull/5281)]\n- fix(deps): update golang-packages [[#5291](https://github.com/woodpecker-ci/woodpecker/pull/5291)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.1 [[#5288](https://github.com/woodpecker-ci/woodpecker/pull/5288)]\n- fix(deps): update dependency marked to v16 [[#5284](https://github.com/woodpecker-ci/woodpecker/pull/5284)]\n- chore(deps): update dependency @vitejs/plugin-vue to v6 [[#5282](https://github.com/woodpecker-ci/woodpecker/pull/5282)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.0 [[#5286](https://github.com/woodpecker-ci/woodpecker/pull/5286)]\n- chore(deps): update dependency vite to v7 [[#5283](https://github.com/woodpecker-ci/woodpecker/pull/5283)]\n- fix(deps): update module github.com/google/go-github/v72 to v73 [[#5285](https://github.com/woodpecker-ci/woodpecker/pull/5285)]\n- chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.6.2 [[#5278](https://github.com/woodpecker-ci/woodpecker/pull/5278)]\n- fix(deps): update golang-packages to v28.3.0+incompatible [[#5274](https://github.com/woodpecker-ci/woodpecker/pull/5274)]\n- chore(deps): lock file maintenance [[#5271](https://github.com/woodpecker-ci/woodpecker/pull/5271)]\n- fix(deps): update dependency vue-i18n to v11.1.7 [[#5270](https://github.com/woodpecker-ci/woodpecker/pull/5270)]\n- fix(deps): update dependency simple-icons to v15.3.0 [[#5269](https://github.com/woodpecker-ci/woodpecker/pull/5269)]\n- fix(deps): update web npm deps non-major [[#5268](https://github.com/woodpecker-ci/woodpecker/pull/5268)]\n- fix(deps): update golang-packages to v0.33.2 [[#5265](https://github.com/woodpecker-ci/woodpecker/pull/5265)]\n- fix(deps): update golang-packages [[#5261](https://github.com/woodpecker-ci/woodpecker/pull/5261)]\n- fix(deps): update module github.com/go-viper/mapstructure/v2 to v2.3.0 [[#5259](https://github.com/woodpecker-ci/woodpecker/pull/5259)]\n- chore(deps): lock file maintenance [[#5257](https://github.com/woodpecker-ci/woodpecker/pull/5257)]\n- fix(deps): update dependency simple-icons to v15.2.0 [[#5256](https://github.com/woodpecker-ci/woodpecker/pull/5256)]\n- fix(deps): update web npm deps non-major [[#5254](https://github.com/woodpecker-ci/woodpecker/pull/5254)]\n- chore(deps): update gitea/gitea docker tag to v1.24 [[#5253](https://github.com/woodpecker-ci/woodpecker/pull/5253)]\n- fix(deps): update golang-packages [[#5250](https://github.com/woodpecker-ci/woodpecker/pull/5250)]\n- chore(deps): lock file maintenance [[#5233](https://github.com/woodpecker-ci/woodpecker/pull/5233)]\n- fix(deps): update dependency simple-icons to v15.1.0 [[#5246](https://github.com/woodpecker-ci/woodpecker/pull/5246)]\n- fix(deps): update web npm deps non-major [[#5244](https://github.com/woodpecker-ci/woodpecker/pull/5244)]\n- fix(deps): update golang-packages [[#5242](https://github.com/woodpecker-ci/woodpecker/pull/5242)]\n- chore(deps): update dependency golang to v1.24.4 [[#5241](https://github.com/woodpecker-ci/woodpecker/pull/5241)]\n\n### Misc\n\n- Disable package name linting [[#5294](https://github.com/woodpecker-ci/woodpecker/pull/5294)]\n\n## [3.7.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.7.0) - 2025-06-06\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Epsilon02, @Levy-Tal, @OCram85, @Spiffyk, @SuperSandro2000, @deltamualpha, @qwerty287, @rruzicic, @sebastinez, @xoxys\n\n### 📚 Documentation\n\n- update docs-link for todo checker [[#5236](https://github.com/woodpecker-ci/woodpecker/pull/5236)]\n- Add `sccache` plugin [[#5234](https://github.com/woodpecker-ci/woodpecker/pull/5234)]\n- fix(deps): update dependency redocusaurus to v2.3.0 [[#5203](https://github.com/woodpecker-ci/woodpecker/pull/5203)]\n- chore(deps): update docs npm deps non-major [[#5197](https://github.com/woodpecker-ci/woodpecker/pull/5197)]\n- Add reference to woodpecker-community plugin org [[#5186](https://github.com/woodpecker-ci/woodpecker/pull/5186)]\n- fix(deps): update docs npm deps non-major [[#5183](https://github.com/woodpecker-ci/woodpecker/pull/5183)]\n- Move `gitea-package` plugin to codeberg [[#5175](https://github.com/woodpecker-ci/woodpecker/pull/5175)]\n- add Portainer Service Update plugin [[#5172](https://github.com/woodpecker-ci/woodpecker/pull/5172)]\n- Split 'pull' option docs from 'image' docs [[#5161](https://github.com/woodpecker-ci/woodpecker/pull/5161)]\n- chore(deps): update docs npm deps non-major [[#5164](https://github.com/woodpecker-ci/woodpecker/pull/5164)]\n\n### 📈 Enhancement\n\n- Move forge webhook fixtures into own files [[#5216](https://github.com/woodpecker-ci/woodpecker/pull/5216)]\n- Treat no available route in grpc as fatal error [[#5192](https://github.com/woodpecker-ci/woodpecker/pull/5192)]\n\n### 🐛 Bug Fixes\n\n- Always collect metrics (reverts #4667) [[#5213](https://github.com/woodpecker-ci/woodpecker/pull/5213)]\n- fix(bitbucketDC): manual event has broken commit link [[#5160](https://github.com/woodpecker-ci/woodpecker/pull/5160)]\n- fix(bitbucketdc): build status gets incorrectly reported on multi workflow builds [[#5178](https://github.com/woodpecker-ci/woodpecker/pull/5178)]\n- fix(bitbucketdc): build status not reported on PR builds [[#5162](https://github.com/woodpecker-ci/woodpecker/pull/5162)]\n\n### 📦️ Dependency\n\n- fix(deps): update golang-packages to v28.2.1+incompatible [[#5217](https://github.com/woodpecker-ci/woodpecker/pull/5217)]\n- fix(deps): update dependency simple-icons to v15 [[#5232](https://github.com/woodpecker-ci/woodpecker/pull/5232)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.5 [[#5230](https://github.com/woodpecker-ci/woodpecker/pull/5230)]\n- fix(deps): update web npm deps non-major [[#5228](https://github.com/woodpecker-ci/woodpecker/pull/5228)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.0 [[#5225](https://github.com/woodpecker-ci/woodpecker/pull/5225)]\n- chore(deps): update docker.io/alpine docker tag to v3.22 [[#5224](https://github.com/woodpecker-ci/woodpecker/pull/5224)]\n- fix(deps): update golang-packages [[#5209](https://github.com/woodpecker-ci/woodpecker/pull/5209)]\n- chore(deps): lock file maintenance [[#5204](https://github.com/woodpecker-ci/woodpecker/pull/5204)]\n- fix(deps): update dependency simple-icons to v14.15.0 [[#5202](https://github.com/woodpecker-ci/woodpecker/pull/5202)]\n- fix(deps): update dependency vue-i18n to v11.1.4 [[#5201](https://github.com/woodpecker-ci/woodpecker/pull/5201)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.6 [[#5200](https://github.com/woodpecker-ci/woodpecker/pull/5200)]\n- fix(deps): update web npm deps non-major [[#5198](https://github.com/woodpecker-ci/woodpecker/pull/5198)]\n- fix(deps): update module github.com/oklog/ulid/v2 to v2.1.1 [[#5194](https://github.com/woodpecker-ci/woodpecker/pull/5194)]\n- fix(deps): update module github.com/gin-gonic/gin to v1.10.1 [[#5193](https://github.com/woodpecker-ci/woodpecker/pull/5193)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.129.0 [[#5190](https://github.com/woodpecker-ci/woodpecker/pull/5190)]\n- chore(deps): lock file maintenance [[#5189](https://github.com/woodpecker-ci/woodpecker/pull/5189)]\n- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.45.0 [[#5187](https://github.com/woodpecker-ci/woodpecker/pull/5187)]\n- fix(deps): update dependency simple-icons to v14.14.0 [[#5188](https://github.com/woodpecker-ci/woodpecker/pull/5188)]\n- fix(deps): update web npm deps non-major [[#5185](https://github.com/woodpecker-ci/woodpecker/pull/5185)]\n- fix(deps): update golang-packages to v0.33.1 [[#5184](https://github.com/woodpecker-ci/woodpecker/pull/5184)]\n- fix(deps): update golang-packages [[#5180](https://github.com/woodpecker-ci/woodpecker/pull/5180)]\n- chore(deps): lock file maintenance [[#5171](https://github.com/woodpecker-ci/woodpecker/pull/5171)]\n- fix(deps): update module github.com/google/go-github/v71 to v72 [[#5167](https://github.com/woodpecker-ci/woodpecker/pull/5167)]\n- fix(deps): update dependency simple-icons to v14.13.0 [[#5170](https://github.com/woodpecker-ci/woodpecker/pull/5170)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.3.3 [[#5169](https://github.com/woodpecker-ci/woodpecker/pull/5169)]\n- fix(deps): update web npm deps non-major [[#5166](https://github.com/woodpecker-ci/woodpecker/pull/5166)]\n- chore(deps): update postgres docker tag to v17.5 [[#5165](https://github.com/woodpecker-ci/woodpecker/pull/5165)]\n- chore(deps): update dependency golang to v1.24.3 [[#5163](https://github.com/woodpecker-ci/woodpecker/pull/5163)]\n\n### Misc\n\n- Ignore direnv config and folder [[#5235](https://github.com/woodpecker-ci/woodpecker/pull/5235)]\n- flake.lock: Update [[#5206](https://github.com/woodpecker-ci/woodpecker/pull/5206)]\n- Add Bluesky post plugin [[#5156](https://github.com/woodpecker-ci/woodpecker/pull/5156)]\n\n## [3.6.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.6.0) - 2025-05-06\n\n### ❤️ Thanks to all contributors! ❤️\n\n@Spiffyk, @SuperSandro2000, @gsaslis, @joshuachp, @lukashass, @maurerle, @pat-s, @qwerty287, @renich, @sp1thas, @xoxys\n\n### ✨ Features\n\n- Use docker go client directly [[#5134](https://github.com/woodpecker-ci/woodpecker/pull/5134)]\n\n### 📚 Documentation\n\n- Simplify NixOS docs [[#5120](https://github.com/woodpecker-ci/woodpecker/pull/5120)]\n- chore(deps): lock file maintenance [[#5150](https://github.com/woodpecker-ci/woodpecker/pull/5150)]\n- plugins: Add SSH/SCP plugin [[#4871](https://github.com/woodpecker-ci/woodpecker/pull/4871)]\n- chore(deps): update dependency @types/node to v22.15.3 [[#5142](https://github.com/woodpecker-ci/woodpecker/pull/5142)]\n- chore(deps): lock file maintenance [[#5136](https://github.com/woodpecker-ci/woodpecker/pull/5136)]\n- Explain tasks [[#5129](https://github.com/woodpecker-ci/woodpecker/pull/5129)]\n- Mention named volumes [[#5130](https://github.com/woodpecker-ci/woodpecker/pull/5130)]\n- chore(deps): update docs npm deps non-major [[#5128](https://github.com/woodpecker-ci/woodpecker/pull/5128)]\n- Fix link to agent configuration in `v3.5` docs [[#5122](https://github.com/woodpecker-ci/woodpecker/pull/5122)]\n- Fix link to agent configuration in `next` docs [[#5119](https://github.com/woodpecker-ci/woodpecker/pull/5119)]\n- Move `plugin-s3` to Codeberg [[#5118](https://github.com/woodpecker-ci/woodpecker/pull/5118)]\n- Use slugified plugin urls in docs [[#5116](https://github.com/woodpecker-ci/woodpecker/pull/5116)]\n- Fix example value for `WOODPECKER_GRPC_ADDR` in autoscaler docs [[#5102](https://github.com/woodpecker-ci/woodpecker/pull/5102)]\n- .deb and .rpm installation commands fixed [[#5087](https://github.com/woodpecker-ci/woodpecker/pull/5087)]\n- chore(deps): update dependency @types/react to v19.1.2 [[#5107](https://github.com/woodpecker-ci/woodpecker/pull/5107)]\n- Slugify plugin names used for urls [[#5098](https://github.com/woodpecker-ci/woodpecker/pull/5098)]\n- Mention `backend_options` in workflow syntax docs [[#5096](https://github.com/woodpecker-ci/woodpecker/pull/5096)]\n- Document rootless container requirements for skip-clone [[#5056](https://github.com/woodpecker-ci/woodpecker/pull/5056)]\n\n### 📈 Enhancement\n\n- View full pipeline duration in tooltip [[#5123](https://github.com/woodpecker-ci/woodpecker/pull/5123)]\n- Set dynamic page titles [[#5104](https://github.com/woodpecker-ci/woodpecker/pull/5104)]\n- Use centrally typed inject provide in Vue [[#5113](https://github.com/woodpecker-ci/woodpecker/pull/5113)]\n- Scroll to selected pipeline step [[#5103](https://github.com/woodpecker-ci/woodpecker/pull/5103)]\n\n### 🐛 Bug Fixes\n\n- Fix args docs for admin secrets [[#5127](https://github.com/woodpecker-ci/woodpecker/pull/5127)]\n- Add name flag to admin secret add [[#5101](https://github.com/woodpecker-ci/woodpecker/pull/5101)]\n\n### 📦️ Dependency\n\n- fix(deps): update golang-packages [[#5152](https://github.com/woodpecker-ci/woodpecker/pull/5152)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.6 [[#5149](https://github.com/woodpecker-ci/woodpecker/pull/5149)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.1 [[#5147](https://github.com/woodpecker-ci/woodpecker/pull/5147)]\n- chore(deps): update pre-commit hook adrienverge/yamllint to v1.37.1 [[#5148](https://github.com/woodpecker-ci/woodpecker/pull/5148)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6 [[#5144](https://github.com/woodpecker-ci/woodpecker/pull/5144)]\n- fix(deps): update web npm deps non-major [[#5143](https://github.com/woodpecker-ci/woodpecker/pull/5143)]\n- fix(deps): update module github.com/getkin/kin-openapi to v0.132.0 [[#5141](https://github.com/woodpecker-ci/woodpecker/pull/5141)]\n- chore(deps): update dependency vite to v6.3.4 [security] [[#5139](https://github.com/woodpecker-ci/woodpecker/pull/5139)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.3.2 [[#5137](https://github.com/woodpecker-ci/woodpecker/pull/5137)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.3.1 [[#5135](https://github.com/woodpecker-ci/woodpecker/pull/5135)]\n- fix(deps): update module github.com/docker/docker to v28 [[#5132](https://github.com/woodpecker-ci/woodpecker/pull/5132)]\n- fix(deps): update module github.com/docker/cli to v28 [[#5131](https://github.com/woodpecker-ci/woodpecker/pull/5131)]\n- fix(deps): update dependency vue-router to v4.5.1 [[#5126](https://github.com/woodpecker-ci/woodpecker/pull/5126)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.5 [[#5125](https://github.com/woodpecker-ci/woodpecker/pull/5125)]\n- fix(deps): update web npm deps non-major [[#5077](https://github.com/woodpecker-ci/woodpecker/pull/5077)]\n- fix(deps): update golang-packages [[#5121](https://github.com/woodpecker-ci/woodpecker/pull/5121)]\n- fix(deps): update golang-packages [[#5111](https://github.com/woodpecker-ci/woodpecker/pull/5111)]\n- chore(deps): lock file maintenance [[#5112](https://github.com/woodpecker-ci/woodpecker/pull/5112)]\n- chore(deps): update docker.io/mysql docker tag to v9.3.0 [[#5109](https://github.com/woodpecker-ci/woodpecker/pull/5109)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.2.0 [[#5110](https://github.com/woodpecker-ci/woodpecker/pull/5110)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.2 [[#5108](https://github.com/woodpecker-ci/woodpecker/pull/5108)]\n- fix(deps): update golang-packages [[#5097](https://github.com/woodpecker-ci/woodpecker/pull/5097)]\n\n### Misc\n\n- Add pre-commit plugin [[#5146](https://github.com/woodpecker-ci/woodpecker/pull/5146)]\n- Fix gitpod golang version [[#5093](https://github.com/woodpecker-ci/woodpecker/pull/5093)]\n\n## [3.5.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.2) - 2025-04-15\n\n### ❤️ Thanks to all contributors! ❤️\n\n@xoxys\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#5092](https://github.com/woodpecker-ci/woodpecker/pull/5092)]\n- fix(deps): update docs npm deps non-major [[#5089](https://github.com/woodpecker-ci/woodpecker/pull/5089)]\n- Move plugin-surge docs to codeberg [[#5086](https://github.com/woodpecker-ci/woodpecker/pull/5086)]\n- chore(deps): lock file maintenance [[#5080](https://github.com/woodpecker-ci/woodpecker/pull/5080)]\n- chore(deps): update docs npm deps non-major [[#5075](https://github.com/woodpecker-ci/woodpecker/pull/5075)]\n\n### 🐛 Bug Fixes\n\n- Avoid db errors while executing migrations check [[#5072](https://github.com/woodpecker-ci/woodpecker/pull/5072)]\n\n### 📦️ Dependency\n\n- fix(deps): update module github.com/google/go-github/v70 to v71 [[#5090](https://github.com/woodpecker-ci/woodpecker/pull/5090)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.1 [[#5091](https://github.com/woodpecker-ci/woodpecker/pull/5091)]\n- chore(deps): update dependency vite to v6.2.6 [security] [[#5088](https://github.com/woodpecker-ci/woodpecker/pull/5088)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.22.0 [[#5084](https://github.com/woodpecker-ci/woodpecker/pull/5084)]\n- fix(deps): update golang-packages [[#5083](https://github.com/woodpecker-ci/woodpecker/pull/5083)]\n- fix(deps): update module golang.org/x/crypto to v0.37.0 [[#5079](https://github.com/woodpecker-ci/woodpecker/pull/5079)]\n- fix(deps): update golang-packages [[#5078](https://github.com/woodpecker-ci/woodpecker/pull/5078)]\n- fix(deps): update module github.com/fsnotify/fsnotify to v1.9.0 [[#5076](https://github.com/woodpecker-ci/woodpecker/pull/5076)]\n- chore(deps): update dependency vite to v6.2.5 [security] [[#5074](https://github.com/woodpecker-ci/woodpecker/pull/5074)]\n\n### Misc\n\n- Add markdown template for release umbrella issues [[#5055](https://github.com/woodpecker-ci/woodpecker/pull/5055)]\n\n## [3.5.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.1) - 2025-04-04\n\n### ❤️ Thanks to all contributors! ❤️\n\n@xoxys\n\n### 🐛 Bug Fixes\n\n- Add missing icon for changes files tab [[#5068](https://github.com/woodpecker-ci/woodpecker/pull/5068)]\n- Improve CLI info text and remove markdown [[#5069](https://github.com/woodpecker-ci/woodpecker/pull/5069)]\n- Fix cli format flag fallback [[#5057](https://github.com/woodpecker-ci/woodpecker/pull/5057)]\n\n### 📚 Documentation\n\n- chore(deps): update docs npm deps non-major [[#5060](https://github.com/woodpecker-ci/woodpecker/pull/5060)]\n\n### 📦️ Dependency\n\n- fix(deps): update module code.gitea.io/sdk/gitea to v0.21.0 [[#5067](https://github.com/woodpecker-ci/woodpecker/pull/5067)]\n- chore(deps): lock file maintenance [[#5062](https://github.com/woodpecker-ci/woodpecker/pull/5062)]\n- fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.27 [[#5058](https://github.com/woodpecker-ci/woodpecker/pull/5058)]\n\n## [3.5.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.0) - 2025-04-02\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Levy-Tal, @anbraten, @jenrik, @nekowinston, @qwerty287, @rhafer, @xoxys\n\n### 🐛 Bug Fixes\n\n- BitbucketDC: add event pull request opened [[#5048](https://github.com/woodpecker-ci/woodpecker/pull/5048)]\n- Fix exclude path constraint behavior [[#5042](https://github.com/woodpecker-ci/woodpecker/pull/5042)]\n- Use pointer cursor for icon buttons [[#5002](https://github.com/woodpecker-ci/woodpecker/pull/5002)]\n- Add back cursor-pointer to pipeline step list buttons [[#4982](https://github.com/woodpecker-ci/woodpecker/pull/4982)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#5044](https://github.com/woodpecker-ci/woodpecker/pull/5044)]\n- chore(deps): lock file maintenance [[#5032](https://github.com/woodpecker-ci/woodpecker/pull/5032)]\n- Print at which file docs parsing failed [[#5040](https://github.com/woodpecker-ci/woodpecker/pull/5040)]\n- fix(deps): update dependency yaml to v2.7.1 [[#5029](https://github.com/woodpecker-ci/woodpecker/pull/5029)]\n- fix(deps): update docs npm deps non-major [[#5026](https://github.com/woodpecker-ci/woodpecker/pull/5026)]\n- Revert manual changes to changelog [[#5007](https://github.com/woodpecker-ci/woodpecker/pull/5007)]\n- Add missing docs for 3.x minor versions [[#4992](https://github.com/woodpecker-ci/woodpecker/pull/4992)]\n- chore(deps): lock file maintenance [[#5000](https://github.com/woodpecker-ci/woodpecker/pull/5000)]\n- fix(deps): update dependency redocusaurus to v2.2.2 [[#4998](https://github.com/woodpecker-ci/woodpecker/pull/4998)]\n- Add missing links to 3.x docs [[#4991](https://github.com/woodpecker-ci/woodpecker/pull/4991)]\n- chore(deps): update docs npm deps non-major [[#4987](https://github.com/woodpecker-ci/woodpecker/pull/4987)]\n- Rework secrets docs and document multiline secrets [[#4974](https://github.com/woodpecker-ci/woodpecker/pull/4974)]\n- Add documentation for WOODPECKER_EXPERT env vars [[#4972](https://github.com/woodpecker-ci/woodpecker/pull/4972)]\n\n### 📈 Enhancement\n\n- add nushell support to local backend [[#5043](https://github.com/woodpecker-ci/woodpecker/pull/5043)]\n- Style navbar login button as navbar-link [[#5033](https://github.com/woodpecker-ci/woodpecker/pull/5033)]\n- Use xorm quoter for feed query [[#5018](https://github.com/woodpecker-ci/woodpecker/pull/5018)]\n- Use badge value instead of label for single values [[#5010](https://github.com/woodpecker-ci/woodpecker/pull/5010)]\n- Add icons to all tabs [[#4421](https://github.com/woodpecker-ci/woodpecker/pull/4421)]\n- Tag pipeline with source information [[#4796](https://github.com/woodpecker-ci/woodpecker/pull/4796)]\n- Add titles and descriptions to repos page [[#4981](https://github.com/woodpecker-ci/woodpecker/pull/4981)]\n\n### 📦️ Dependency\n\n- fix(deps): update golang-packages [[#5046](https://github.com/woodpecker-ci/woodpecker/pull/5046)]\n- fix(deps): update module github.com/urfave/cli/v3 to v3.1.0 [[#5039](https://github.com/woodpecker-ci/woodpecker/pull/5039)]\n- chore(deps): update dependency vite to v6.2.4 [security] [[#5036](https://github.com/woodpecker-ci/woodpecker/pull/5036)]\n- fix(deps): update dependency simple-icons to v14.12.0 [[#5030](https://github.com/woodpecker-ci/woodpecker/pull/5030)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v2 [[#5028](https://github.com/woodpecker-ci/woodpecker/pull/5028)]\n- fix(deps): update web npm deps non-major [[#5027](https://github.com/woodpecker-ci/woodpecker/pull/5027)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.4 [[#5025](https://github.com/woodpecker-ci/woodpecker/pull/5025)]\n- fix(deps): update module golang.org/x/net to v0.38.0 [[#5024](https://github.com/woodpecker-ci/woodpecker/pull/5024)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.3 [[#5021](https://github.com/woodpecker-ci/woodpecker/pull/5021)]\n- chore(deps): update dependency vite to v6.2.3 [security] [[#5014](https://github.com/woodpecker-ci/woodpecker/pull/5014)]\n- fix(deps): update golang-packages [[#5012](https://github.com/woodpecker-ci/woodpecker/pull/5012)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.2 [[#4997](https://github.com/woodpecker-ci/woodpecker/pull/4997)]\n- fix(deps): update dependency simple-icons to v14.11.1 [[#4999](https://github.com/woodpecker-ci/woodpecker/pull/4999)]\n- chore(deps): update pre-commit hook adrienverge/yamllint to v1.37.0 [[#4996](https://github.com/woodpecker-ci/woodpecker/pull/4996)]\n- fix(deps): update module github.com/rs/zerolog to v1.34.0 [[#4995](https://github.com/woodpecker-ci/woodpecker/pull/4995)]\n- chore(deps): update dependency @antfu/eslint-config to v4.11.0 [[#4994](https://github.com/woodpecker-ci/woodpecker/pull/4994)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.5 [[#4993](https://github.com/woodpecker-ci/woodpecker/pull/4993)]\n- fix(deps): update module github.com/google/go-github/v69 to v70 [[#4990](https://github.com/woodpecker-ci/woodpecker/pull/4990)]\n- fix(deps): update web npm deps non-major [[#4989](https://github.com/woodpecker-ci/woodpecker/pull/4989)]\n- chore(deps): update pre-commit non-major [[#4988](https://github.com/woodpecker-ci/woodpecker/pull/4988)]\n- fix(deps): update module github.com/golang-jwt/jwt/v5 to v5.2.2 [security] [[#4986](https://github.com/woodpecker-ci/woodpecker/pull/4986)]\n- fix(deps): update module github.com/go-sql-driver/mysql to v1.9.1 [[#4985](https://github.com/woodpecker-ci/woodpecker/pull/4985)]\n- fix(deps): update module github.com/getkin/kin-openapi to v0.131.0 [[#4984](https://github.com/woodpecker-ci/woodpecker/pull/4984)]\n- fix(deps): update module github.com/expr-lang/expr to v1.17.1 [[#4983](https://github.com/woodpecker-ci/woodpecker/pull/4983)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.126.0 [[#4976](https://github.com/woodpecker-ci/woodpecker/pull/4976)]\n\n### Misc\n\n- Bump golangci-lint to v2 [[#5034](https://github.com/woodpecker-ci/woodpecker/pull/5034)]\n- Update flake development environment [[#5022](https://github.com/woodpecker-ci/woodpecker/pull/5022)]\n\n## [3.4.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.4.0) - 2025-03-17\n\n### ❤️ Thanks to all contributors! ❤️\n\n@qwerty287, @xoxys\n\n### 📈 Enhancement\n\n- Remove woodpecker prefix from env var title in docs [[#4968](https://github.com/woodpecker-ci/woodpecker/pull/4968)]\n- Add backoff retry for store setup [[#4964](https://github.com/woodpecker-ci/woodpecker/pull/4964)]\n- Migrate repo output format to customizable output [[#4888](https://github.com/woodpecker-ci/woodpecker/pull/4888)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#4970](https://github.com/woodpecker-ci/woodpecker/pull/4970)]\n- fix(deps): update docs npm deps non-major [[#4958](https://github.com/woodpecker-ci/woodpecker/pull/4958)]\n- Add global var note [[#4956](https://github.com/woodpecker-ci/woodpecker/pull/4956)]\n- chore(deps): lock file maintenance [[#4948](https://github.com/woodpecker-ci/woodpecker/pull/4948)]\n- chore(deps): update dependency @types/node to v22.13.10 [[#4944](https://github.com/woodpecker-ci/woodpecker/pull/4944)]\n- chore(deps): update dependency axios to v1.8.2 [security] [[#4941](https://github.com/woodpecker-ci/woodpecker/pull/4941)]\n- Fix dockerhub links in docs [[#4931](https://github.com/woodpecker-ci/woodpecker/pull/4931)]\n\n### 🐛 Bug Fixes\n\n- Fix fs owner in scratch-based container images [[#4961](https://github.com/woodpecker-ci/woodpecker/pull/4961)]\n\n### 📦️ Dependency\n\n- fix(deps): update module github.com/expr-lang/expr to v1.17.0 [[#4969](https://github.com/woodpecker-ci/woodpecker/pull/4969)]\n- fix(deps): update dependency simple-icons to v14.11.0 [[#4966](https://github.com/woodpecker-ci/woodpecker/pull/4966)]\n- fix(deps): update golang-packages [[#4963](https://github.com/woodpecker-ci/woodpecker/pull/4963)]\n- chore(deps): update pre-commit hook adrienverge/yamllint to v1.36.1 [[#4962](https://github.com/woodpecker-ci/woodpecker/pull/4962)]\n- fix(deps): update dependency @vueuse/core to v13 [[#4960](https://github.com/woodpecker-ci/woodpecker/pull/4960)]\n- fix(deps): update web npm deps non-major [[#4959](https://github.com/woodpecker-ci/woodpecker/pull/4959)]\n- chore(deps): update pre-commit non-major [[#4957](https://github.com/woodpecker-ci/woodpecker/pull/4957)]\n- fix(deps): update golang-packages to v0.32.3 [[#4953](https://github.com/woodpecker-ci/woodpecker/pull/4953)]\n- fix(deps): update dependency prismjs to v1.30.0 [security] [[#4951](https://github.com/woodpecker-ci/woodpecker/pull/4951)]\n- chore(deps): update dependency @intlify/eslint-plugin-vue-i18n to v4 [[#4943](https://github.com/woodpecker-ci/woodpecker/pull/4943)]\n- fix(deps): update module al.essio.dev/pkg/shellescape to v1.6.0 [[#4947](https://github.com/woodpecker-ci/woodpecker/pull/4947)]\n- fix(deps): update dependency simple-icons to v14.10.0 [[#4946](https://github.com/woodpecker-ci/woodpecker/pull/4946)]\n- chore(deps): update dependency @types/node to v22.13.10 [[#4945](https://github.com/woodpecker-ci/woodpecker/pull/4945)]\n- fix(deps): update web npm deps non-major [[#4942](https://github.com/woodpecker-ci/woodpecker/pull/4942)]\n- fix(deps): update dependency vue-i18n to v11.1.2 [security] [[#4940](https://github.com/woodpecker-ci/woodpecker/pull/4940)]\n- fix(deps): update golang-packages [[#4936](https://github.com/woodpecker-ci/woodpecker/pull/4936)]\n- chore(deps): lock file maintenance [[#4933](https://github.com/woodpecker-ci/woodpecker/pull/4933)]\n- fix(deps): update golang-packages [[#4929](https://github.com/woodpecker-ci/woodpecker/pull/4929)]\n\n## [3.3.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.3.0) - 2025-03-04\n\n### ❤️ Thanks to all contributors! ❤️\n\n@Levy-Tal, @qwerty287, @xoxys\n\n### 📚 Documentation\n\n- Refactor admin docs [[#4899](https://github.com/woodpecker-ci/woodpecker/pull/4899)]\n- chore(deps): lock file maintenance [[#4928](https://github.com/woodpecker-ci/woodpecker/pull/4928)]\n- chore(deps): update dependency @types/node to v22.13.9 [[#4925](https://github.com/woodpecker-ci/woodpecker/pull/4925)]\n- chore(deps): lock file maintenance [[#4922](https://github.com/woodpecker-ci/woodpecker/pull/4922)]\n- Add some blog posts [[#4921](https://github.com/woodpecker-ci/woodpecker/pull/4921)]\n- chore(deps): update dependency @types/node to v22.13.8 [[#4915](https://github.com/woodpecker-ci/woodpecker/pull/4915)]\n- Remove Slack plugin from examples [[#4914](https://github.com/woodpecker-ci/woodpecker/pull/4914)]\n- chore(deps): update docs npm deps non-major [[#4911](https://github.com/woodpecker-ci/woodpecker/pull/4911)]\n\n### 🐛 Bug Fixes\n\n- Add migration to fix zero forge_id in orgs table [[#4924](https://github.com/woodpecker-ci/woodpecker/pull/4924)]\n- Fix unique constraint for orgs [[#4923](https://github.com/woodpecker-ci/woodpecker/pull/4923)]\n\n### 📈 Enhancement\n\n- BitbucketDC: optimize repository search [[#4919](https://github.com/woodpecker-ci/woodpecker/pull/4919)]\n- Include forge type in netrc [[#4908](https://github.com/woodpecker-ci/woodpecker/pull/4908)]\n\n### 📦️ Dependency\n\n- chore(deps): update dependency @types/node to v22.13.9 [[#4926](https://github.com/woodpecker-ci/woodpecker/pull/4926)]\n- chore(deps): update pre-commit non-major [[#4927](https://github.com/woodpecker-ci/woodpecker/pull/4927)]\n- chore(deps): update dependency @antfu/eslint-config to v4.4.0 [[#4917](https://github.com/woodpecker-ci/woodpecker/pull/4917)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.124.0 [[#4920](https://github.com/woodpecker-ci/woodpecker/pull/4920)]\n- chore(deps): update dependency @types/node to v22.13.8 [[#4916](https://github.com/woodpecker-ci/woodpecker/pull/4916)]\n- chore(deps): update dependency @types/lodash to v4.17.16 [[#4913](https://github.com/woodpecker-ci/woodpecker/pull/4913)]\n- chore(deps): update web npm deps non-major [[#4912](https://github.com/woodpecker-ci/woodpecker/pull/4912)]\n\n## [3.2.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.2.0) - 2025-02-26\n\n### ❤️ Thanks to all contributors! ❤️\n\n@DHandspikerWade, @anbraten, @arthurpro, @hhomar, @jenrik, @jpgleeson, @mark-pitblado, @maurerle, @qwerty287, @xoxys\n\n### 🔒 Security\n\n- Fix approval requirement if PR is closed [[#4902](https://github.com/woodpecker-ci/woodpecker/pull/4902)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#4906](https://github.com/woodpecker-ci/woodpecker/pull/4906)]\n- chore(deps): update dependency axios to v1.8.1 [[#4905](https://github.com/woodpecker-ci/woodpecker/pull/4905)]\n- Fix typo on forgejo/gitea documentation [[#4898](https://github.com/woodpecker-ci/woodpecker/pull/4898)]\n- chore(deps): update docs npm deps non-major [[#4878](https://github.com/woodpecker-ci/woodpecker/pull/4878)]\n- plugins: add Hugo plugin for woodpecker [[#4870](https://github.com/woodpecker-ci/woodpecker/pull/4870)]\n- Add Microsoft Teams Notification (Advanced) plugin [[#4868](https://github.com/woodpecker-ci/woodpecker/pull/4868)]\n- chore(deps): update dependency @types/react to v19.0.9 [[#4864](https://github.com/woodpecker-ci/woodpecker/pull/4864)]\n- Drop versioned docs for v1 [[#4844](https://github.com/woodpecker-ci/woodpecker/pull/4844)]\n- Add a Home Assistant notification plugin  [[#4841](https://github.com/woodpecker-ci/woodpecker/pull/4841)]\n\n### 🐛 Bug Fixes\n\n- Use forge IDs for hook tokens [[#4897](https://github.com/woodpecker-ci/woodpecker/pull/4897)]\n- Fix nil dereference in Bitbucket webhook handling [[#4896](https://github.com/woodpecker-ci/woodpecker/pull/4896)]\n- Fix org assign on login [[#4817](https://github.com/woodpecker-ci/woodpecker/pull/4817)]\n- Directly fetch directory contents [[#4842](https://github.com/woodpecker-ci/woodpecker/pull/4842)]\n\n### 📈 Enhancement\n\n- Remove eslint types [[#4893](https://github.com/woodpecker-ci/woodpecker/pull/4893)]\n- Add default option for allowing pull requests on repositories [[#4873](https://github.com/woodpecker-ci/woodpecker/pull/4873)]\n- Replace deprecated linter [[#4843](https://github.com/woodpecker-ci/woodpecker/pull/4843)]\n\n### 📦️ Dependency\n\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.2 [[#4903](https://github.com/woodpecker-ci/woodpecker/pull/4903)]\n- fix(deps): update web npm deps non-major [[#4904](https://github.com/woodpecker-ci/woodpecker/pull/4904)]\n- fix(deps): update golang-packages [[#4900](https://github.com/woodpecker-ci/woodpecker/pull/4900)]\n- chore(deps): lock file maintenance [[#4895](https://github.com/woodpecker-ci/woodpecker/pull/4895)]\n- chore(deps): update dependency vue-tsc to v2.2.4 [[#4894](https://github.com/woodpecker-ci/woodpecker/pull/4894)]\n- fix(deps): update dependency simple-icons to v14.8.0 [[#4891](https://github.com/woodpecker-ci/woodpecker/pull/4891)]\n- fix(deps): update golang-packages [[#4890](https://github.com/woodpecker-ci/woodpecker/pull/4890)]\n- chore(deps): update dependency @types/eslint__js to v9 [[#4884](https://github.com/woodpecker-ci/woodpecker/pull/4884)]\n- chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.5.2 [[#4883](https://github.com/woodpecker-ci/woodpecker/pull/4883)]\n- fix(deps): update module codeberg.org/mvdkleijn/forgejo-sdk/forgejo to v2 [[#4858](https://github.com/woodpecker-ci/woodpecker/pull/4858)]\n- fix(deps): update web npm deps non-major [[#4882](https://github.com/woodpecker-ci/woodpecker/pull/4882)]\n- chore(deps): update postgres docker tag to v17.4 [[#4881](https://github.com/woodpecker-ci/woodpecker/pull/4881)]\n- chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.1 [[#4879](https://github.com/woodpecker-ci/woodpecker/pull/4879)]\n- chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.0 [[#4880](https://github.com/woodpecker-ci/woodpecker/pull/4880)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.5 [[#4877](https://github.com/woodpecker-ci/woodpecker/pull/4877)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.21.0 [[#4874](https://github.com/woodpecker-ci/woodpecker/pull/4874)]\n- fix(deps): update module github.com/go-sql-driver/mysql to v1.9.0 [[#4872](https://github.com/woodpecker-ci/woodpecker/pull/4872)]\n- fix(deps): update module github.com/google/go-github/v69 to v69.2.0 [[#4869](https://github.com/woodpecker-ci/woodpecker/pull/4869)]\n- chore(deps): lock file maintenance [[#4866](https://github.com/woodpecker-ci/woodpecker/pull/4866)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.0 [[#4865](https://github.com/woodpecker-ci/woodpecker/pull/4865)]\n- fix(deps): update dependency simple-icons to v14.7.0 [[#4862](https://github.com/woodpecker-ci/woodpecker/pull/4862)]\n- fix(deps): update dependency pinia to v3 [[#4856](https://github.com/woodpecker-ci/woodpecker/pull/4856)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.123.0 [[#4860](https://github.com/woodpecker-ci/woodpecker/pull/4860)]\n- chore(deps): update dependency vue-tsc to v2.2.2 [[#4859](https://github.com/woodpecker-ci/woodpecker/pull/4859)]\n- fix(deps): update web npm deps non-major [[#4857](https://github.com/woodpecker-ci/woodpecker/pull/4857)]\n- chore(deps): update pre-commit non-major [[#4855](https://github.com/woodpecker-ci/woodpecker/pull/4855)]\n- chore(deps): update postgres docker tag to v17.3 [[#4854](https://github.com/woodpecker-ci/woodpecker/pull/4854)]\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.24.x [[#4853](https://github.com/woodpecker-ci/woodpecker/pull/4853)]\n- chore(deps): update docker.io/golang docker tag to v1.24 [[#4852](https://github.com/woodpecker-ci/woodpecker/pull/4852)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.4 [[#4851](https://github.com/woodpecker-ci/woodpecker/pull/4851)]\n- fix(deps): update dependency @tailwindcss/vite to v4.0.6 [[#4846](https://github.com/woodpecker-ci/woodpecker/pull/4846)]\n- chore(deps): lock file maintenance [[#4845](https://github.com/woodpecker-ci/woodpecker/pull/4845)]\n- fix(deps): update dependency tailwindcss to v4 [[#4778](https://github.com/woodpecker-ci/woodpecker/pull/4778)]\n- fix(deps): update golang-packages [[#4839](https://github.com/woodpecker-ci/woodpecker/pull/4839)]\n\n### Misc\n\n- kubernetes: create service for detached steps [[#4892](https://github.com/woodpecker-ci/woodpecker/pull/4892)]\n- docs: remove latest from docker compose example [[#4849](https://github.com/woodpecker-ci/woodpecker/pull/4849)]\n\n## [3.1.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.1.0) - 2025-02-12\n\n### ❤️ Thanks to all contributors! ❤️\n\n@Levy-Tal, @anbraten, @cduchenoy, @damuzhi0810, @lafriks, @mzampetakis, @pat-s, @qwerty287, @xoxys\n\n### ✨ Features\n\n- Add allow list for approvals [[#4768](https://github.com/woodpecker-ci/woodpecker/pull/4768)]\n\n### 🐛 Bug Fixes\n\n- Unsanitize user and org names in DB [[#4762](https://github.com/woodpecker-ci/woodpecker/pull/4762)]\n- Store/delete repos after forge communication [[#4827](https://github.com/woodpecker-ci/woodpecker/pull/4827)]\n- Fix k8s secret schema [[#4819](https://github.com/woodpecker-ci/woodpecker/pull/4819)]\n- Move section description to the top [[#4773](https://github.com/woodpecker-ci/woodpecker/pull/4773)]\n\n### 📚 Documentation\n\n- Docs: Add Radicle forge addon [[#4833](https://github.com/woodpecker-ci/woodpecker/pull/4833)]\n- fix(deps): update docs npm deps non-major [[#4823](https://github.com/woodpecker-ci/woodpecker/pull/4823)]\n- chore(deps): update dependency isomorphic-dompurify to v2.21.0 [[#4805](https://github.com/woodpecker-ci/woodpecker/pull/4805)]\n- chore(deps): update dependency @types/node to v22.13.0 [[#4799](https://github.com/woodpecker-ci/woodpecker/pull/4799)]\n- Add bluesky post plugin [[#4549](https://github.com/woodpecker-ci/woodpecker/pull/4549)]\n- Various docs improvements [[#4772](https://github.com/woodpecker-ci/woodpecker/pull/4772)]\n- fix(deps): update docs npm deps non-major [[#4774](https://github.com/woodpecker-ci/woodpecker/pull/4774)]\n- Add git basic changelog [[#4755](https://github.com/woodpecker-ci/woodpecker/pull/4755)]\n\n### 📈 Enhancement\n\n- Optimize repository list loading to return also latest pipeline info [[#4814](https://github.com/woodpecker-ci/woodpecker/pull/4814)]\n- Add Git Ref To Build Status in BitbucketDatacenter [[#4724](https://github.com/woodpecker-ci/woodpecker/pull/4724)]\n\n### 📦️ Dependency\n\n- fix(deps): update golang-packages [[#4834](https://github.com/woodpecker-ci/woodpecker/pull/4834)]\n- fix(deps): update web npm deps non-major [[#4831](https://github.com/woodpecker-ci/woodpecker/pull/4831)]\n- fix(deps): update dependency simple-icons to v14.6.0 [[#4830](https://github.com/woodpecker-ci/woodpecker/pull/4830)]\n- fix(deps): update golang-packages [[#4829](https://github.com/woodpecker-ci/woodpecker/pull/4829)]\n- fix(deps): update web npm deps non-major to v4.0.5 [[#4828](https://github.com/woodpecker-ci/woodpecker/pull/4828)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.1 [[#4822](https://github.com/woodpecker-ci/woodpecker/pull/4822)]\n- fix(deps): update module github.com/google/go-github/v68 to v69 [[#4826](https://github.com/woodpecker-ci/woodpecker/pull/4826)]\n- fix(deps): update web npm deps non-major [[#4825](https://github.com/woodpecker-ci/woodpecker/pull/4825)]\n- fix(deps): update golang-packages [[#4812](https://github.com/woodpecker-ci/woodpecker/pull/4812)]\n- chore(deps): update dependency vitest to v3.0.5 [security] [[#4810](https://github.com/woodpecker-ci/woodpecker/pull/4810)]\n- chore(deps): lock file maintenance [[#4808](https://github.com/woodpecker-ci/woodpecker/pull/4808)]\n- chore(deps): update dependency @antfu/eslint-config to v4.1.1 [[#4806](https://github.com/woodpecker-ci/woodpecker/pull/4806)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.121.0 [[#4804](https://github.com/woodpecker-ci/woodpecker/pull/4804)]\n- fix(deps): update dependency simple-icons to v14.5.0 [[#4803](https://github.com/woodpecker-ci/woodpecker/pull/4803)]\n- fix(deps): update web npm deps non-major to v4.0.3 [[#4802](https://github.com/woodpecker-ci/woodpecker/pull/4802)]\n- fix(deps): update web npm deps non-major [[#4798](https://github.com/woodpecker-ci/woodpecker/pull/4798)]\n- fix(deps): update module github.com/getkin/kin-openapi to v0.129.0 [[#4790](https://github.com/woodpecker-ci/woodpecker/pull/4790)]\n- chore(deps): lock file maintenance [[#4783](https://github.com/woodpecker-ci/woodpecker/pull/4783)]\n- chore(deps): update dependency @antfu/eslint-config to v4.1.0 [[#4780](https://github.com/woodpecker-ci/woodpecker/pull/4780)]\n- fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.8.1 [[#4781](https://github.com/woodpecker-ci/woodpecker/pull/4781)]\n- chore(deps): update dependency @antfu/eslint-config to v4 [[#4779](https://github.com/woodpecker-ci/woodpecker/pull/4779)]\n- fix(deps): update web npm deps non-major [[#4777](https://github.com/woodpecker-ci/woodpecker/pull/4777)]\n- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.44.0 [[#4776](https://github.com/woodpecker-ci/woodpecker/pull/4776)]\n- fix(deps): update module google.golang.org/protobuf to v1.36.4 [[#4775](https://github.com/woodpecker-ci/woodpecker/pull/4775)]\n- fix(deps): update module google.golang.org/grpc to v1.70.0 [[#4770](https://github.com/woodpecker-ci/woodpecker/pull/4770)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.0 [[#4767](https://github.com/woodpecker-ci/woodpecker/pull/4767)]\n- chore(deps): update docker.io/mysql docker tag to v9.2.0 [[#4766](https://github.com/woodpecker-ci/woodpecker/pull/4766)]\n- fix(deps): update module github.com/hashicorp/go-plugin to v1.6.3 [[#4765](https://github.com/woodpecker-ci/woodpecker/pull/4765)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.3 [[#4764](https://github.com/woodpecker-ci/woodpecker/pull/4764)]\n- fix(deps): update docker to v27.5.1+incompatible [[#4761](https://github.com/woodpecker-ci/woodpecker/pull/4761)]\n- chore(deps): update dependency vite to v6.0.9 [security] [[#4757](https://github.com/woodpecker-ci/woodpecker/pull/4757)]\n\n### Misc\n\n- chore: fix some function names in comment [[#4769](https://github.com/woodpecker-ci/woodpecker/pull/4769)]\n\n## [3.0.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.0.1) - 2025-01-20\n\n### ❤️ Thanks to all contributors! ❤️\n\n@pat-s, @qwerty287, @xoxys\n\n### 🐛 Bug Fixes\n\n- Only show visited repos and hide at all if less than 4 repos [[#4753](https://github.com/woodpecker-ci/woodpecker/pull/4753)]\n- Fix sql identifier escaping in datastore feed [[#4746](https://github.com/woodpecker-ci/woodpecker/pull/4746)]\n- Fix log folder permissions [[#4749](https://github.com/woodpecker-ci/woodpecker/pull/4749)]\n- Add missing error message for org_access_denied [[#4744](https://github.com/woodpecker-ci/woodpecker/pull/4744)]\n- Fix package configs [[#4741](https://github.com/woodpecker-ci/woodpecker/pull/4741)]\n\n### 📚 Documentation\n\n- chore(deps): lock file maintenance [[#4751](https://github.com/woodpecker-ci/woodpecker/pull/4751)]\n\n### 📦️ Dependency\n\n- fix(deps): update golang-packages [[#4750](https://github.com/woodpecker-ci/woodpecker/pull/4750)]\n- fix(deps): update dependency simple-icons to v14.3.0 [[#4739](https://github.com/woodpecker-ci/woodpecker/pull/4739)]\n- chore(deps): update dependency vitest to v3 [[#4736](https://github.com/woodpecker-ci/woodpecker/pull/4736)]\n\n### Misc\n\n- fix minor tag creation for server scratch image [[#4748](https://github.com/woodpecker-ci/woodpecker/pull/4748)]\n- use v3 woodpecker libs [[#4742](https://github.com/woodpecker-ci/woodpecker/pull/4742)]\n\n## [3.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.0.0) - 2025-01-18\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Fishbowler, @Levy-Tal, @M0Rf30, @anbraten, @cduchenoy, @cevatkerim, @fernandrone, @gedankenstuecke, @gnowland, @greenaar, @hg, @j04n-f, @jenrik, @johanneskastl, @jolheiser, @lafriks, @lukashass, @meln5674, @not-my-profile, @pat-s, @plafue, @qwerty287, @smainz, @stevapple, @tori-27, @tsufeki, @xoxys, @xtexChooser, @zc-devs\n\n### 💥 Breaking changes\n\n- Add rootless (alpine) images [[#4617](https://github.com/woodpecker-ci/woodpecker/pull/4617)]\n- Unify CLI bin name [[#4673](https://github.com/woodpecker-ci/woodpecker/pull/4673)]\n- Support Git as only VCS [[#4346](https://github.com/woodpecker-ci/woodpecker/pull/4346)]\n- Add rolling semver tags, remove `latest` tag [[#4600](https://github.com/woodpecker-ci/woodpecker/pull/4600)]\n- Drop native Let's Encrypt support [[#4541](https://github.com/woodpecker-ci/woodpecker/pull/4541)]\n- Require approval for prs from public repos by default [[#4456](https://github.com/woodpecker-ci/woodpecker/pull/4456)]\n- Do not set empty environment variables [[#4193](https://github.com/woodpecker-ci/woodpecker/pull/4193)]\n- Unify cli commands and flags [[#4481](https://github.com/woodpecker-ci/woodpecker/pull/4481)]\n- Move pipeline logs command [[#4480](https://github.com/woodpecker-ci/woodpecker/pull/4480)]\n- Fix woodpecker-go repo model to match server [[#4479](https://github.com/woodpecker-ci/woodpecker/pull/4479)]\n- Restructure cli commands [[#4467](https://github.com/woodpecker-ci/woodpecker/pull/4467)]\n- Add pagination options to all supported endpoints in sdk [[#4463](https://github.com/woodpecker-ci/woodpecker/pull/4463)]\n- Allow to set custom trusted clone plugins [[#4352](https://github.com/woodpecker-ci/woodpecker/pull/4352)]\n- Add PipelineListsOptions to woodpecker-go [[#3652](https://github.com/woodpecker-ci/woodpecker/pull/3652)]\n- Remove `secrets` in favor of `from_secret` [[#4363](https://github.com/woodpecker-ci/woodpecker/pull/4363)]\n- Kubernetes | Docker: Add support for rootless images [[#4151](https://github.com/woodpecker-ci/woodpecker/pull/4151)]\n- Split repo trusted setting [[#4025](https://github.com/woodpecker-ci/woodpecker/pull/4025)]\n- Move docker resource limit settings from server to agent [[#3174](https://github.com/woodpecker-ci/woodpecker/pull/3174)]\n- Set `/woodpecker` as default workdir for the **woodpecker-cli** container [[#4130](https://github.com/woodpecker-ci/woodpecker/pull/4130)]\n- Require upgrade from 2.x [[#4112](https://github.com/woodpecker-ci/woodpecker/pull/4112)]\n- Don't expose task data via api [[#4108](https://github.com/woodpecker-ci/woodpecker/pull/4108)]\n- Remove some ci environment variables [[#3846](https://github.com/woodpecker-ci/woodpecker/pull/3846)]\n- Remove all default privileged plugins [[#4053](https://github.com/woodpecker-ci/woodpecker/pull/4053)]\n- Add option to filter secrets by plugins with specific tags [[#4069](https://github.com/woodpecker-ci/woodpecker/pull/4069)]\n- Remove old pipeline options [[#4016](https://github.com/woodpecker-ci/woodpecker/pull/4016)]\n- Remove various deprecations [[#4017](https://github.com/woodpecker-ci/woodpecker/pull/4017)]\n- Drop repo name fallback for hooks [[#4013](https://github.com/woodpecker-ci/woodpecker/pull/4013)]\n- Improve local backend detection [[#4006](https://github.com/woodpecker-ci/woodpecker/pull/4006)]\n- Refactor JSON and SDK fields [[#3968](https://github.com/woodpecker-ci/woodpecker/pull/3968)]\n- Migrate to maintained cron lib and remove seconds [[#3785](https://github.com/woodpecker-ci/woodpecker/pull/3785)]\n- Switch to profile-based AppArmor configuration [[#4008](https://github.com/woodpecker-ci/woodpecker/pull/4008)]\n- Remove Kubernetes default image pull secret name `regcred` [[#4005](https://github.com/woodpecker-ci/woodpecker/pull/4005)]\n- Drop \"WOODPECKER_WEBHOOK_HOST\" env var and adjust docs [[#3969](https://github.com/woodpecker-ci/woodpecker/pull/3969)]\n- Drop version in schema [[#3970](https://github.com/woodpecker-ci/woodpecker/pull/3970)]\n- Update docker to v27 [[#3972](https://github.com/woodpecker-ci/woodpecker/pull/3972)]\n- Require gitlab 12.4 [[#3966](https://github.com/woodpecker-ci/woodpecker/pull/3966)]\n- Migrate to maintained httpsign library [[#3839](https://github.com/woodpecker-ci/woodpecker/pull/3839)]\n- Remove `WOODPECKER_DEV_OAUTH_HOST` and `WOODPECKER_DEV_GITEA_OAUTH_URL` [[#3961](https://github.com/woodpecker-ci/woodpecker/pull/3961)]\n- Remove deprecated pipeline keywords: `pipeline:`, `platform:`, `branches:` [[#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916)]\n- server: remove old unused routes [[#3845](https://github.com/woodpecker-ci/woodpecker/pull/3845)]\n- CLI: remove step-id and add step-number as option to logs [[#3927](https://github.com/woodpecker-ci/woodpecker/pull/3927)]\n\n### 🔒 Security\n\n- Don't log DB passwords [[#4583](https://github.com/woodpecker-ci/woodpecker/pull/4583)]\n- Do not log forge tokens [[#4551](https://github.com/woodpecker-ci/woodpecker/pull/4551)]\n- Add server config to disable user registered agents [[#4206](https://github.com/woodpecker-ci/woodpecker/pull/4206)]\n- chore: fix `http-proxy-middleware` CVE [[#4257](https://github.com/woodpecker-ci/woodpecker/pull/4257)]\n- Allow altering trusted clone plugins and filter them via tag [[#4074](https://github.com/woodpecker-ci/woodpecker/pull/4074)]\n- Update gitea sdk [[#4012](https://github.com/woodpecker-ci/woodpecker/pull/4012)]\n- Update Forgejo SDK [[#3948](https://github.com/woodpecker-ci/woodpecker/pull/3948)]\n\n### ✨ Features\n\n- Add user as docker backend_option [[#4526](https://github.com/woodpecker-ci/woodpecker/pull/4526)]\n- Add dns config option to official feature set [[#4418](https://github.com/woodpecker-ci/woodpecker/pull/4418)]\n- Implement org/user agents [[#3539](https://github.com/woodpecker-ci/woodpecker/pull/3539)]\n- Replay pipeline using `cli exec` by downloading metadata [[#4103](https://github.com/woodpecker-ci/woodpecker/pull/4103)]\n- Update clone plugin to support sha256 [[#4136](https://github.com/woodpecker-ci/woodpecker/pull/4136)]\n\n### 📚 Documentation\n\n- Improve 3.0.0 migration notes [[#4737](https://github.com/woodpecker-ci/woodpecker/pull/4737)]\n- Add docs for 3.0 [[#4705](https://github.com/woodpecker-ci/woodpecker/pull/4705)]\n- fix(deps): update docs npm deps non-major [[#4733](https://github.com/woodpecker-ci/woodpecker/pull/4733)]\n- chore(deps): update dependency @types/react to v19.0.5 [[#4714](https://github.com/woodpecker-ci/woodpecker/pull/4714)]\n- fix(deps): update docs npm deps non-major [[#4702](https://github.com/woodpecker-ci/woodpecker/pull/4702)]\n- fix(deps): update react monorepo to v19 (major) [[#4529](https://github.com/woodpecker-ci/woodpecker/pull/4529)]\n- Refactor `secrets` page in docs [[#4644](https://github.com/woodpecker-ci/woodpecker/pull/4644)]\n- fix(deps): update docs npm deps non-major [[#4661](https://github.com/woodpecker-ci/woodpecker/pull/4661)]\n- chore(deps): lock file maintenance [[#4647](https://github.com/woodpecker-ci/woodpecker/pull/4647)]\n- chore(deps): update dependency concurrently to v9.1.1 [[#4631](https://github.com/woodpecker-ci/woodpecker/pull/4631)]\n- Add docker in docker example to advanced usage in docs [[#4620](https://github.com/woodpecker-ci/woodpecker/pull/4620)]\n- fixed a typo [[#4621](https://github.com/woodpecker-ci/woodpecker/pull/4621)]\n- Fix misleading example in Workflow syntax/Privileged mode [[#4613](https://github.com/woodpecker-ci/woodpecker/pull/4613)]\n- Update docs section about \"Custom clone plugins\" [[#4618](https://github.com/woodpecker-ci/woodpecker/pull/4618)]\n- Search in plugin tags [[#4604](https://github.com/woodpecker-ci/woodpecker/pull/4604)]\n- chore(deps): update dependency @types/react to v18.3.18 [[#4599](https://github.com/woodpecker-ci/woodpecker/pull/4599)]\n- Update About [[#4555](https://github.com/woodpecker-ci/woodpecker/pull/4555)]\n- chore(deps): update dependency marked to v15.0.4 [[#4570](https://github.com/woodpecker-ci/woodpecker/pull/4570)]\n- Expand docs around the deprecation of former secret syntax [[#4561](https://github.com/woodpecker-ci/woodpecker/pull/4561)]\n- fix(deps): update docs npm deps non-major [[#4568](https://github.com/woodpecker-ci/woodpecker/pull/4568)]\n- Show client flags [[#4542](https://github.com/woodpecker-ci/woodpecker/pull/4542)]\n- chore(deps): update react monorepo to v19 (major) [[#4523](https://github.com/woodpecker-ci/woodpecker/pull/4523)]\n- chore(deps): update docs npm deps non-major [[#4519](https://github.com/woodpecker-ci/woodpecker/pull/4519)]\n- chore(deps): update dependency isomorphic-dompurify to v2.18.0 [[#4493](https://github.com/woodpecker-ci/woodpecker/pull/4493)]\n- fix(deps): update docs npm deps non-major [[#4484](https://github.com/woodpecker-ci/woodpecker/pull/4484)]\n- Add migration notes for restructured cli commands [[#4476](https://github.com/woodpecker-ci/woodpecker/pull/4476)]\n- Various fixes for `awesome.md` [[#4448](https://github.com/woodpecker-ci/woodpecker/pull/4448)]\n- chore(deps): update dependency isomorphic-dompurify to v2.17.0 [[#4449](https://github.com/woodpecker-ci/woodpecker/pull/4449)]\n- fix(deps): update docs npm deps non-major [[#4441](https://github.com/woodpecker-ci/woodpecker/pull/4441)]\n- chore(deps): update dependency @docusaurus/tsconfig to v3.6.2 [[#4433](https://github.com/woodpecker-ci/woodpecker/pull/4433)]\n- Bump minimum nodejs to v20 [[#4417](https://github.com/woodpecker-ci/woodpecker/pull/4417)]\n- Add microsoft teams plugin [[#4400](https://github.com/woodpecker-ci/woodpecker/pull/4400)]\n- fix(deps): update docs npm deps non-major [[#4394](https://github.com/woodpecker-ci/woodpecker/pull/4394)]\n- chore(deps): update dependency @types/node to v22 [[#4395](https://github.com/woodpecker-ci/woodpecker/pull/4395)]\n- chore(deps): update dependency marked to v15 [[#4396](https://github.com/woodpecker-ci/woodpecker/pull/4396)]\n- Kubernetes documentation enhancements [[#4374](https://github.com/woodpecker-ci/woodpecker/pull/4374)]\n- Podman is not (official) supported [[#4367](https://github.com/woodpecker-ci/woodpecker/pull/4367)]\n- Add EditorConfig-Checker Plugin to docs [[#4371](https://github.com/woodpecker-ci/woodpecker/pull/4371)]\n- Update netrc option description [[#4342](https://github.com/woodpecker-ci/woodpecker/pull/4342)]\n- Fix deployment event note [[#4283](https://github.com/woodpecker-ci/woodpecker/pull/4283)]\n- Improve migration notes [[#4291](https://github.com/woodpecker-ci/woodpecker/pull/4291)]\n- Add instructions how to build images locally [[#4277](https://github.com/woodpecker-ci/woodpecker/pull/4277)]\n- chore(deps): update docs npm deps non-major [[#4238](https://github.com/woodpecker-ci/woodpecker/pull/4238)]\n- Correct spelling [[#4279](https://github.com/woodpecker-ci/woodpecker/pull/4279)]\n- Add Telegram plugin [[#4229](https://github.com/woodpecker-ci/woodpecker/pull/4229)]\n- Remove archived plugin [[#4227](https://github.com/woodpecker-ci/woodpecker/pull/4227)]\n- Use \"Woodpecker Authors\" as copyright on website [[#4225](https://github.com/woodpecker-ci/woodpecker/pull/4225)]\n- chore(deps): update dependency cookie to v1 [[#4224](https://github.com/woodpecker-ci/woodpecker/pull/4224)]\n- fix(deps): update docs npm deps non-major [[#4221](https://github.com/woodpecker-ci/woodpecker/pull/4221)]\n- Fix errant apostrophe in docker-compose documentation [[#4203](https://github.com/woodpecker-ci/woodpecker/pull/4203)]\n- chore(deps): update dependency concurrently to v9 [[#4176](https://github.com/woodpecker-ci/woodpecker/pull/4176)]\n- chore(deps): update docs npm deps non-major [[#4164](https://github.com/woodpecker-ci/woodpecker/pull/4164)]\n- Update image filter error message [[#4143](https://github.com/woodpecker-ci/woodpecker/pull/4143)]\n- Docs: reference to built-in docker compose and remove deprecated version from compose examples [[#4123](https://github.com/woodpecker-ci/woodpecker/pull/4123)]\n- directory key is allowed for services [[#4127](https://github.com/woodpecker-ci/woodpecker/pull/4127)]\n- [docs] Removes dot prefix from pipeline configuration filenames [[#4105](https://github.com/woodpecker-ci/woodpecker/pull/4105)]\n- Use kaniko plugin in docs as example [[#4072](https://github.com/woodpecker-ci/woodpecker/pull/4072)]\n- Add some posts and videos [[#4070](https://github.com/woodpecker-ci/woodpecker/pull/4070)]\n- Move event type descriptions from Terminology to Workflow Syntax [[#4062](https://github.com/woodpecker-ci/woodpecker/pull/4062)]\n- Add community posts from discussions [[#4058](https://github.com/woodpecker-ci/woodpecker/pull/4058)]\n- Add a pull request template with some basic guidelines [[#4055](https://github.com/woodpecker-ci/woodpecker/pull/4055)]\n- Add examples of CI environment variable values [[#4009](https://github.com/woodpecker-ci/woodpecker/pull/4009)]\n- Fix inline author warning [[#4040](https://github.com/woodpecker-ci/woodpecker/pull/4040)]\n- Updated Secrets image filter docs [[#4028](https://github.com/woodpecker-ci/woodpecker/pull/4028)]\n- Update dependency marked to v14 [[#4036](https://github.com/woodpecker-ci/woodpecker/pull/4036)]\n- Update docs npm deps non-major [[#4033](https://github.com/woodpecker-ci/woodpecker/pull/4033)]\n- Overhaul README [[#3995](https://github.com/woodpecker-ci/woodpecker/pull/3995)]\n- fix(deps): update docs npm deps non-major [[#3990](https://github.com/woodpecker-ci/woodpecker/pull/3990)]\n- Add spellchecking for docs [[#3787](https://github.com/woodpecker-ci/woodpecker/pull/3787)]\n\n### 🐛 Bug Fixes\n\n- Check organization first [[#4723](https://github.com/woodpecker-ci/woodpecker/pull/4723)]\n- Fix mobile view of the popup [[#4717](https://github.com/woodpecker-ci/woodpecker/pull/4717)]\n- Apply changed files filter to closed PR [[#4711](https://github.com/woodpecker-ci/woodpecker/pull/4711)]\n- Add margins to moving WP svg logo [[#4697](https://github.com/woodpecker-ci/woodpecker/pull/4697)]\n- Add hosts for detached steps [[#4674](https://github.com/woodpecker-ci/woodpecker/pull/4674)]\n- Fix addon `nil` values [[#4666](https://github.com/woodpecker-ci/woodpecker/pull/4666)]\n- fix cli exec statement in debug tab [[#4643](https://github.com/woodpecker-ci/woodpecker/pull/4643)]\n- Fix misaligned step list indentation [[#4609](https://github.com/woodpecker-ci/woodpecker/pull/4609)]\n- Ignore blocked pipelines for badge rendering [[#4582](https://github.com/woodpecker-ci/woodpecker/pull/4582)]\n- Remove related pipeline logs during pipeline deletion [[#4572](https://github.com/woodpecker-ci/woodpecker/pull/4572)]\n- Weakly decode backend options [[#4577](https://github.com/woodpecker-ci/woodpecker/pull/4577)]\n- Add client error to sdk and fix purge cli [[#4574](https://github.com/woodpecker-ci/woodpecker/pull/4574)]\n- Fix pipeline purge cli command [[#4569](https://github.com/woodpecker-ci/woodpecker/pull/4569)]\n- Fix BB ambiguous commit status key [[#4544](https://github.com/woodpecker-ci/woodpecker/pull/4544)]\n- fix: addon JSON pointers [[#4508](https://github.com/woodpecker-ci/woodpecker/pull/4508)]\n- Fix apparmorProfile being ignored when it's the only field [[#4507](https://github.com/woodpecker-ci/woodpecker/pull/4507)]\n- Sanitize strings in table output [[#4466](https://github.com/woodpecker-ci/woodpecker/pull/4466)]\n- Cleanup openapi generation [[#4331](https://github.com/woodpecker-ci/woodpecker/pull/4331)]\n- Support github refresh tokens [[#3811](https://github.com/woodpecker-ci/woodpecker/pull/3811)]\n- Fix not working overflow on repo list message [[#4420](https://github.com/woodpecker-ci/woodpecker/pull/4420)]\n- fix `error=\"io: read/write on closed pipe\"` on k8s backend [[#4281](https://github.com/woodpecker-ci/woodpecker/pull/4281)]\n- Move update notifier dot into settings button [[#4334](https://github.com/woodpecker-ci/woodpecker/pull/4334)]\n- gitea: add check if pull_request webhook is missing pull info [[#4305](https://github.com/woodpecker-ci/woodpecker/pull/4305)]\n- Refresh token before loading branches [[#4284](https://github.com/woodpecker-ci/woodpecker/pull/4284)]\n- Delete GitLab webhooks with partial URL match [[#4259](https://github.com/woodpecker-ci/woodpecker/pull/4259)]\n- Increase `WOODPECKER_FORGE_TIMEOUT` to fix config fetching for GitLab [[#4262](https://github.com/woodpecker-ci/woodpecker/pull/4262)]\n- Ensure cli exec has by default not the same prefix [[#4132](https://github.com/woodpecker-ci/woodpecker/pull/4132)]\n- Fix repo add loading spinner [[#4135](https://github.com/woodpecker-ci/woodpecker/pull/4135)]\n- Fix migration registries table [[#4111](https://github.com/woodpecker-ci/woodpecker/pull/4111)]\n- Wait for tracer to be done before finishing workflow [[#4068](https://github.com/woodpecker-ci/woodpecker/pull/4068)]\n- Fix schema with detached steps [[#4066](https://github.com/woodpecker-ci/woodpecker/pull/4066)]\n- Fix schema with commands and entrypoint [[#4065](https://github.com/woodpecker-ci/woodpecker/pull/4065)]\n- Read long log lines from file storage correctly [[#4048](https://github.com/woodpecker-ci/woodpecker/pull/4048)]\n- Set refspec for gitlab MR [[#4021](https://github.com/woodpecker-ci/woodpecker/pull/4021)]\n- Set `CI_PREV_COMMIT_{SOURCE,TARGET}_BRANCH` as mentioned in the documentation [[#4001](https://github.com/woodpecker-ci/woodpecker/pull/4001)]\n- [Bitbucket Datacenter] Return empty list instead of null [[#4010](https://github.com/woodpecker-ci/woodpecker/pull/4010)]\n- Fix BB PR pipeline ref [[#3985](https://github.com/woodpecker-ci/woodpecker/pull/3985)]\n- Change Bitbucket PR hook to point the source branch, commit & ref [[#3965](https://github.com/woodpecker-ci/woodpecker/pull/3965)]\n- Add updated, merged and declined events to bb webhook activation [[#3963](https://github.com/woodpecker-ci/woodpecker/pull/3963)]\n- Fix login via navbar [[#3962](https://github.com/woodpecker-ci/woodpecker/pull/3962)]\n- Truncate creation in list [[#3952](https://github.com/woodpecker-ci/woodpecker/pull/3952)]\n- Fix panic if forge is unreachable [[#3944](https://github.com/woodpecker-ci/woodpecker/pull/3944)]\n\n### 📈 Enhancement\n\n- Harmonize en texts [[#4716](https://github.com/woodpecker-ci/woodpecker/pull/4716)]\n- feat: add linter support for step-level `depends_on` existence [[#4657](https://github.com/woodpecker-ci/woodpecker/pull/4657)]\n- Reduce version redundancy [[#4707](https://github.com/woodpecker-ci/woodpecker/pull/4707)]\n- Add priority menu to tabs [[#4641](https://github.com/woodpecker-ci/woodpecker/pull/4641)]\n- feat(bitbucketdatacenter): Add support for fetching and converting projects to teams [[#4663](https://github.com/woodpecker-ci/woodpecker/pull/4663)]\n- Migrate from Windi to Tailwind [[#4614](https://github.com/woodpecker-ci/woodpecker/pull/4614)]\n- Do not start metrics collector if metrics are disabled [[#4667](https://github.com/woodpecker-ci/woodpecker/pull/4667)]\n- Improve badge coloring [[#4447](https://github.com/woodpecker-ci/woodpecker/pull/4447)]\n- Inline web helpers [[#4639](https://github.com/woodpecker-ci/woodpecker/pull/4639)]\n- Use filled status icons and harmonize contextually [[#4584](https://github.com/woodpecker-ci/woodpecker/pull/4584)]\n- Two row layout for title and context of pipeline list [[#4625](https://github.com/woodpecker-ci/woodpecker/pull/4625)]\n- Remove workflow-level volumes and networks [[#4636](https://github.com/woodpecker-ci/woodpecker/pull/4636)]\n- Migrate away from goblin [[#4624](https://github.com/woodpecker-ci/woodpecker/pull/4624)]\n- Use lighter red shades for error messages [[#4611](https://github.com/woodpecker-ci/woodpecker/pull/4611)]\n- Avoid usage of inline css style [[#4629](https://github.com/woodpecker-ci/woodpecker/pull/4629)]\n- Use icon sizes relative to font size [[#4575](https://github.com/woodpecker-ci/woodpecker/pull/4575)]\n- Use docusaurus faster [[#4528](https://github.com/woodpecker-ci/woodpecker/pull/4528)]\n- Add settings title action [[#4499](https://github.com/woodpecker-ci/woodpecker/pull/4499)]\n- Use pagination helper to list pipelines in cli [[#4478](https://github.com/woodpecker-ci/woodpecker/pull/4478)]\n- Some UI improvements [[#4497](https://github.com/woodpecker-ci/woodpecker/pull/4497)]\n- Add status filter to list pipeline API [[#4494](https://github.com/woodpecker-ci/woodpecker/pull/4494)]\n- Use JS-native date/time formatting [[#4488](https://github.com/woodpecker-ci/woodpecker/pull/4488)]\n- Add pipeline purge command to cli [[#4470](https://github.com/woodpecker-ci/woodpecker/pull/4470)]\n- Add option to limit the resultset returned by paginate helper [[#4475](https://github.com/woodpecker-ci/woodpecker/pull/4475)]\n- Add filter to list repository pipelines API [[#4416](https://github.com/woodpecker-ci/woodpecker/pull/4416)]\n- Increase log level when failing to fetch YAML [[#4107](https://github.com/woodpecker-ci/woodpecker/pull/4107)]\n- Trim space to all config flags that allow to read value from file [[#4468](https://github.com/woodpecker-ci/woodpecker/pull/4468)]\n- Change default icon size to 20 [[#4458](https://github.com/woodpecker-ci/woodpecker/pull/4458)]\n- Use same default sort for repo and org repo list [[#4461](https://github.com/woodpecker-ci/woodpecker/pull/4461)]\n- Add visibility icon to repo list [[#4460](https://github.com/woodpecker-ci/woodpecker/pull/4460)]\n- Improve tab layout and add hover effect [[#4431](https://github.com/woodpecker-ci/woodpecker/pull/4431)]\n- Unify pipeline status icons [[#4414](https://github.com/woodpecker-ci/woodpecker/pull/4414)]\n- Improve project settings descriptions [[#4410](https://github.com/woodpecker-ci/woodpecker/pull/4410)]\n- Add count badge to visualize counters in tab title [[#4419](https://github.com/woodpecker-ci/woodpecker/pull/4419)]\n- Redesign repo list and include last pipeline [[#4386](https://github.com/woodpecker-ci/woodpecker/pull/4386)]\n- Use KeyValueEditor for DeployPipelinePopup too [[#4412](https://github.com/woodpecker-ci/woodpecker/pull/4412)]\n- Use separate routes instead of anchors [[#4285](https://github.com/woodpecker-ci/woodpecker/pull/4285)]\n- Untangle settings / header slots [[#4403](https://github.com/woodpecker-ci/woodpecker/pull/4403)]\n- Fix responsiveness of the settings template [[#4383](https://github.com/woodpecker-ci/woodpecker/pull/4383)]\n- Use squared spinner for active pipelines [[#4379](https://github.com/woodpecker-ci/woodpecker/pull/4379)]\n- Add server configuration option to add default set of labels for workflows that has no labels specified [[#4326](https://github.com/woodpecker-ci/woodpecker/pull/4326)]\n- Add `cli lint` option to treat warnings as errors [[#4373](https://github.com/woodpecker-ci/woodpecker/pull/4373)]\n- Improve error message for wrong secrets / environment config [[#4359](https://github.com/woodpecker-ci/woodpecker/pull/4359)]\n- Improve linter messages in UI [[#4351](https://github.com/woodpecker-ci/woodpecker/pull/4351)]\n- Pass settings to services [[#4338](https://github.com/woodpecker-ci/woodpecker/pull/4338)]\n- Inline model types for migrations [[#4293](https://github.com/woodpecker-ci/woodpecker/pull/4293)]\n- Add options to control the database connections (open,idle,timeout) [[#4212](https://github.com/woodpecker-ci/woodpecker/pull/4212)]\n- Move Queue creation behind new func that evaluates queue type [[#4252](https://github.com/woodpecker-ci/woodpecker/pull/4252)]\n- Add additional error message on swagger v2 to v3 convert [[#4254](https://github.com/woodpecker-ci/woodpecker/pull/4254)]\n- Fix wording for privileged plugins linter error [[#4280](https://github.com/woodpecker-ci/woodpecker/pull/4280)]\n- Deprecate `secrets` [[#4235](https://github.com/woodpecker-ci/woodpecker/pull/4235)]\n- Agent edit/detail view: change the help url based on the backend [[#4219](https://github.com/woodpecker-ci/woodpecker/pull/4219)]\n- Use middleware to load org [[#4208](https://github.com/woodpecker-ci/woodpecker/pull/4208)]\n- Assign workflows to agents with the best label matches [[#4201](https://github.com/woodpecker-ci/woodpecker/pull/4201)]\n- Report custom labels set by agent admins back [[#4141](https://github.com/woodpecker-ci/woodpecker/pull/4141)]\n- Highlight invalid entries in manual pipeline trigger [[#4153](https://github.com/woodpecker-ci/woodpecker/pull/4153)]\n- Print agent labels in debug mode [[#4155](https://github.com/woodpecker-ci/woodpecker/pull/4155)]\n- Implement registries for Kubernetes backend [[#4092](https://github.com/woodpecker-ci/woodpecker/pull/4092)]\n- Correct cli exec flags and remove ineffective ones [[#4129](https://github.com/woodpecker-ci/woodpecker/pull/4129)]\n- Set repo user to repairing user when old user is missing [[#4128](https://github.com/woodpecker-ci/woodpecker/pull/4128)]\n- Restart tasks on dead agents sooner [[#4114](https://github.com/woodpecker-ci/woodpecker/pull/4114)]\n- Adjust cli exec metadata structure to equal server metadata [[#4119](https://github.com/woodpecker-ci/woodpecker/pull/4119)]\n- Allow to restart declined pipelines [[#4109](https://github.com/woodpecker-ci/woodpecker/pull/4109)]\n- Add indices to repo table [[#4087](https://github.com/woodpecker-ci/woodpecker/pull/4087)]\n- Add systemd unit files to the RPM/DEB packages [[#3986](https://github.com/woodpecker-ci/woodpecker/pull/3986)]\n- Duplicate key `workflow_id` in the agent logs [[#4046](https://github.com/woodpecker-ci/woodpecker/pull/4046)]\n- Improve error on config loading [[#4024](https://github.com/woodpecker-ci/woodpecker/pull/4024)]\n- Show error if secret name is missing [[#4014](https://github.com/woodpecker-ci/woodpecker/pull/4014)]\n- Show error returned from API [[#3980](https://github.com/woodpecker-ci/woodpecker/pull/3980)]\n- Move manual popup to own page [[#3981](https://github.com/woodpecker-ci/woodpecker/pull/3981)]\n- Fail on InvalidImageName [[#4007](https://github.com/woodpecker-ci/woodpecker/pull/4007)]\n- Use Bitbucket PR title for pipeline message [[#3984](https://github.com/woodpecker-ci/woodpecker/pull/3984)]\n- Show logs if step has error [[#3979](https://github.com/woodpecker-ci/woodpecker/pull/3979)]\n- Refactor docker backend and add more test coverage [[#2700](https://github.com/woodpecker-ci/woodpecker/pull/2700)]\n- Make cli plugin log purge recognize steps by name [[#3953](https://github.com/woodpecker-ci/woodpecker/pull/3953)]\n- Pin page size [[#3946](https://github.com/woodpecker-ci/woodpecker/pull/3946)]\n- Improve cron list [[#3947](https://github.com/woodpecker-ci/woodpecker/pull/3947)]\n- Add PULLREQUEST_DRONE_PULL_REQUEST drone env [[#3939](https://github.com/woodpecker-ci/woodpecker/pull/3939)]\n- Make agent gRPC errors distinguishable [[#3936](https://github.com/woodpecker-ci/woodpecker/pull/3936)]\n\n### 📦️ Dependency\n\n- fix(deps): update web npm deps non-major [[#4735](https://github.com/woodpecker-ci/woodpecker/pull/4735)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.3 [[#4734](https://github.com/woodpecker-ci/woodpecker/pull/4734)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.4 [[#4732](https://github.com/woodpecker-ci/woodpecker/pull/4732)]\n- fix(deps): update golang-packages to v0.32.1 [[#4727](https://github.com/woodpecker-ci/woodpecker/pull/4727)]\n- fix(deps): update module google.golang.org/protobuf to v1.36.3 [[#4726](https://github.com/woodpecker-ci/woodpecker/pull/4726)]\n- fix(deps): update golang-packages [[#4725](https://github.com/woodpecker-ci/woodpecker/pull/4725)]\n- chore(deps): lock file maintenance [[#4721](https://github.com/woodpecker-ci/woodpecker/pull/4721)]\n- fix(deps): update module code.gitea.io/sdk/gitea to v0.20.0 [[#4710](https://github.com/woodpecker-ci/woodpecker/pull/4710)]\n- fix(deps): update dependency simple-icons to v14.2.0 [[#4709](https://github.com/woodpecker-ci/woodpecker/pull/4709)]\n- chore(deps): update dependency jsdom to v26 [[#4704](https://github.com/woodpecker-ci/woodpecker/pull/4704)]\n- fix(deps): update web npm deps non-major [[#4703](https://github.com/woodpecker-ci/woodpecker/pull/4703)]\n- chore(deps): update gitea/gitea docker tag to v1.23 [[#4701](https://github.com/woodpecker-ci/woodpecker/pull/4701)]\n- fix(deps): update golang-packages [[#4688](https://github.com/woodpecker-ci/woodpecker/pull/4688)]\n- fix(deps): update golang-packages [[#4678](https://github.com/woodpecker-ci/woodpecker/pull/4678)]\n- fix(deps): update module golang.org/x/term to v0.28.0 [[#4671](https://github.com/woodpecker-ci/woodpecker/pull/4671)]\n- chore(deps): lock file maintenance [[#4672](https://github.com/woodpecker-ci/woodpecker/pull/4672)]\n- fix(deps): update dependency simple-icons to v14.1.0 [[#4668](https://github.com/woodpecker-ci/woodpecker/pull/4668)]\n- fix(deps): update module golang.org/x/oauth2 to v0.25.0 [[#4665](https://github.com/woodpecker-ci/woodpecker/pull/4665)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v1.63.4 [[#4660](https://github.com/woodpecker-ci/woodpecker/pull/4660)]\n- fix(deps): update module github.com/moby/term to v0.5.2 [[#4658](https://github.com/woodpecker-ci/woodpecker/pull/4658)]\n- fix(deps): update web npm deps non-major [[#4659](https://github.com/woodpecker-ci/woodpecker/pull/4659)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.1 [[#4642](https://github.com/woodpecker-ci/woodpecker/pull/4642)]\n- fix(deps): update dependency simple-icons to v14.0.1 [[#4640](https://github.com/woodpecker-ci/woodpecker/pull/4640)]\n- fix(deps): update module github.com/google/go-github/v67 to v68 [[#4635](https://github.com/woodpecker-ci/woodpecker/pull/4635)]\n- fix(deps): update dependency vue-i18n to v11 [[#4634](https://github.com/woodpecker-ci/woodpecker/pull/4634)]\n- fix(deps): update dependency simple-icons to v14 [[#4633](https://github.com/woodpecker-ci/woodpecker/pull/4633)]\n- chore(deps): update dependency vite to v6.0.6 [[#4632](https://github.com/woodpecker-ci/woodpecker/pull/4632)]\n- fix(deps): update github.com/getkin/kin-openapi digest to cea0a13 [[#4630](https://github.com/woodpecker-ci/woodpecker/pull/4630)]\n- chore(deps): lock file maintenance [[#4540](https://github.com/woodpecker-ci/woodpecker/pull/4540)]\n- fix(deps): update web npm deps non-major [[#4440](https://github.com/woodpecker-ci/woodpecker/pull/4440)]\n- fix(deps): update golang-packages [[#4615](https://github.com/woodpecker-ci/woodpecker/pull/4615)]\n- fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.118.0 [[#4606](https://github.com/woodpecker-ci/woodpecker/pull/4606)]\n- fix(deps): update module github.com/cenkalti/backoff/v4 to v5 [[#4601](https://github.com/woodpecker-ci/woodpecker/pull/4601)]\n- fix(deps): update golang-packages [[#4586](https://github.com/woodpecker-ci/woodpecker/pull/4586)]\n- fix(deps): update module golang.org/x/net to v0.33.0 [security] [[#4585](https://github.com/woodpecker-ci/woodpecker/pull/4585)]\n- fix(deps): update golang-packages [[#4579](https://github.com/woodpecker-ci/woodpecker/pull/4579)]\n- Replace discontinued mitchellh/mapstructure by maintained fork [[#4573](https://github.com/woodpecker-ci/woodpecker/pull/4573)]\n- chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.1.6 [[#4566](https://github.com/woodpecker-ci/woodpecker/pull/4566)]\n- fix(deps): update github.com/muesli/termenv digest to 8c990cd [[#4565](https://github.com/woodpecker-ci/woodpecker/pull/4565)]\n- fix(deps): update module google.golang.org/grpc to v1.69.0 [[#4563](https://github.com/woodpecker-ci/woodpecker/pull/4563)]\n- fix(deps): update golang-packages [[#4553](https://github.com/woodpecker-ci/woodpecker/pull/4553)]\n- Update kin-openapi [[#4560](https://github.com/woodpecker-ci/woodpecker/pull/4560)]\n- fix(deps): update module golang.org/x/crypto to v0.31.0 [security] [[#4557](https://github.com/woodpecker-ci/woodpecker/pull/4557)]\n- fix(deps): update golang-packages [[#4546](https://github.com/woodpecker-ci/woodpecker/pull/4546)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.0 [[#4536](https://github.com/woodpecker-ci/woodpecker/pull/4536)]\n- chore(deps): update docker.io/curlimages/curl docker tag to v8.11.0 [[#4530](https://github.com/woodpecker-ci/woodpecker/pull/4530)]\n- fix(deps): update golang-packages [[#4496](https://github.com/woodpecker-ci/woodpecker/pull/4496)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.1.0 [[#4524](https://github.com/woodpecker-ci/woodpecker/pull/4524)]\n- chore(deps): update docker.io/woodpeckerci/plugin-prettier docker tag to v1 [[#4522](https://github.com/woodpecker-ci/woodpecker/pull/4522)]\n- chore(deps): update docker.io/alpine docker tag to v3.21 [[#4520](https://github.com/woodpecker-ci/woodpecker/pull/4520)]\n- chore(deps): update dependency vite to v6 [[#4485](https://github.com/woodpecker-ci/woodpecker/pull/4485)]\n- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3 [[#4506](https://github.com/woodpecker-ci/woodpecker/pull/4506)]\n- chore(deps): lock file maintenance [[#4502](https://github.com/woodpecker-ci/woodpecker/pull/4502)]\n- chore(deps): lock file maintenance [[#4501](https://github.com/woodpecker-ci/woodpecker/pull/4501)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.3 [[#4495](https://github.com/woodpecker-ci/woodpecker/pull/4495)]\n- fix(deps): update golang-packages [[#4477](https://github.com/woodpecker-ci/woodpecker/pull/4477)]\n- fix(deps): update dependency @vueuse/core to v12 [[#4486](https://github.com/woodpecker-ci/woodpecker/pull/4486)]\n- fix(deps): update module github.com/google/go-github/v66 to v67 [[#4487](https://github.com/woodpecker-ci/woodpecker/pull/4487)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.2 [[#4483](https://github.com/woodpecker-ci/woodpecker/pull/4483)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.2 [[#4482](https://github.com/woodpecker-ci/woodpecker/pull/4482)]\n- fix(deps): update golang-packages [[#4452](https://github.com/woodpecker-ci/woodpecker/pull/4452)]\n- chore(deps): lock file maintenance [[#4453](https://github.com/woodpecker-ci/woodpecker/pull/4453)]\n- fix(deps): update golang-packages [[#4411](https://github.com/woodpecker-ci/woodpecker/pull/4411)]\n- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.43.0 [[#4443](https://github.com/woodpecker-ci/woodpecker/pull/4443)]\n- chore(deps): update postgres docker tag to v17.2 [[#4442](https://github.com/woodpecker-ci/woodpecker/pull/4442)]\n- chore(deps): lock file maintenance [[#4435](https://github.com/woodpecker-ci/woodpecker/pull/4435)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.3.0 [[#4434](https://github.com/woodpecker-ci/woodpecker/pull/4434)]\n- chore(deps): update web npm deps non-major [[#4432](https://github.com/woodpecker-ci/woodpecker/pull/4432)]\n- fix(deps): update golang-packages [[#4401](https://github.com/woodpecker-ci/woodpecker/pull/4401)]\n- chore(deps): lock file maintenance [[#4402](https://github.com/woodpecker-ci/woodpecker/pull/4402)]\n- chore(deps): update web npm deps non-major [[#4391](https://github.com/woodpecker-ci/woodpecker/pull/4391)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to v6 [[#4397](https://github.com/woodpecker-ci/woodpecker/pull/4397)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.0 [[#4390](https://github.com/woodpecker-ci/woodpecker/pull/4390)]\n- chore(deps): update postgres docker tag to v17.1 [[#4389](https://github.com/woodpecker-ci/woodpecker/pull/4389)]\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.23.x [[#4388](https://github.com/woodpecker-ci/woodpecker/pull/4388)]\n- chore(config): migrate renovate config [[#4296](https://github.com/woodpecker-ci/woodpecker/pull/4296)]\n- chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.2.0 [[#4289](https://github.com/woodpecker-ci/woodpecker/pull/4289)]\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.23.x [[#4282](https://github.com/woodpecker-ci/woodpecker/pull/4282)]\n- fix(deps): update golang-packages [[#4251](https://github.com/woodpecker-ci/woodpecker/pull/4251)]\n- fix(deps): update web npm deps non-major [[#4258](https://github.com/woodpecker-ci/woodpecker/pull/4258)]\n- chore(deps): update web npm deps non-major [[#4250](https://github.com/woodpecker-ci/woodpecker/pull/4250)]\n- chore(deps): update node.js to v23 [[#4239](https://github.com/woodpecker-ci/woodpecker/pull/4239)]\n- chore(deps): update web npm deps non-major [[#4237](https://github.com/woodpecker-ci/woodpecker/pull/4237)]\n- chore(deps): update docker.io/mysql docker tag to v9.1.0 [[#4236](https://github.com/woodpecker-ci/woodpecker/pull/4236)]\n- fix(deps): update dependency simple-icons to v13.14.0 [[#4226](https://github.com/woodpecker-ci/woodpecker/pull/4226)]\n- fix(deps): update web npm deps non-major [[#4223](https://github.com/woodpecker-ci/woodpecker/pull/4223)]\n- fix(deps): update golang-packages [[#4215](https://github.com/woodpecker-ci/woodpecker/pull/4215)]\n- fix(deps): update golang-packages [[#4210](https://github.com/woodpecker-ci/woodpecker/pull/4210)]\n- fix(deps): update module github.com/google/go-github/v65 to v66 [[#4205](https://github.com/woodpecker-ci/woodpecker/pull/4205)]\n- fix(deps): update dependency vue-i18n to v10.0.4 [[#4200](https://github.com/woodpecker-ci/woodpecker/pull/4200)]\n- chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v5 [[#4192](https://github.com/woodpecker-ci/woodpecker/pull/4192)]\n- fix(deps): update dependency simple-icons to v13.13.0 [[#4196](https://github.com/woodpecker-ci/woodpecker/pull/4196)]\n- chore(deps): lock file maintenance [[#4186](https://github.com/woodpecker-ci/woodpecker/pull/4186)]\n- chore(deps): update web npm deps non-major [[#4174](https://github.com/woodpecker-ci/woodpecker/pull/4174)]\n- chore(deps): update docker.io/postgres docker tag to v17 [[#4179](https://github.com/woodpecker-ci/woodpecker/pull/4179)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to v5 [[#4183](https://github.com/woodpecker-ci/woodpecker/pull/4183)]\n- fix(deps): update dependency @vueuse/core to v11 [[#4184](https://github.com/woodpecker-ci/woodpecker/pull/4184)]\n- chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.1.5 [[#4167](https://github.com/woodpecker-ci/woodpecker/pull/4167)]\n- fix(deps): update module github.com/google/go-github/v64 to v65 [[#4185](https://github.com/woodpecker-ci/woodpecker/pull/4185)]\n- chore(deps): update docker.io/mysql docker tag to v9 [[#4178](https://github.com/woodpecker-ci/woodpecker/pull/4178)]\n- chore(deps): update docker.io/alpine docker tag to v3.20 [[#4169](https://github.com/woodpecker-ci/woodpecker/pull/4169)]\n- fix(deps): update github.com/urfave/cli/v3 digest to 20ef97b [[#4166](https://github.com/woodpecker-ci/woodpecker/pull/4166)]\n- chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.2 [[#4168](https://github.com/woodpecker-ci/woodpecker/pull/4168)]\n- chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.1 [[#4175](https://github.com/woodpecker-ci/woodpecker/pull/4175)]\n- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v2 [[#4182](https://github.com/woodpecker-ci/woodpecker/pull/4182)]\n- fix(deps): update github.com/muesli/termenv digest to 82936c5 [[#4165](https://github.com/woodpecker-ci/woodpecker/pull/4165)]\n- chore(deps): update postgres docker tag to v17 [[#4181](https://github.com/woodpecker-ci/woodpecker/pull/4181)]\n- chore(deps): update pre-commit non-major [[#4173](https://github.com/woodpecker-ci/woodpecker/pull/4173)]\n- chore(deps): update docker.io/golang docker tag to v1.23 [[#4170](https://github.com/woodpecker-ci/woodpecker/pull/4170)]\n- chore(deps): update node.js to v22 [[#4180](https://github.com/woodpecker-ci/woodpecker/pull/4180)]\n- fix(deps): update golang-packages [[#4161](https://github.com/woodpecker-ci/woodpecker/pull/4161)]\n- chore(deps): update dependency @antfu/eslint-config to v3 [[#4095](https://github.com/woodpecker-ci/woodpecker/pull/4095)]\n- chore(deps): update dependency jsdom to v25 [[#4094](https://github.com/woodpecker-ci/woodpecker/pull/4094)]\n- chore(deps): update docker.io/golang docker tag to v1.23 [[#4081](https://github.com/woodpecker-ci/woodpecker/pull/4081)]\n- chore(deps): update docker.io/woodpeckerci/plugin-prettier docker tag to v0.2.0 [[#4082](https://github.com/woodpecker-ci/woodpecker/pull/4082)]\n- fix(deps): update module github.com/google/go-github/v63 to v64 [[#4073](https://github.com/woodpecker-ci/woodpecker/pull/4073)]\n- fix(deps): update golang-packages [[#4059](https://github.com/woodpecker-ci/woodpecker/pull/4059)]\n- Update github.com/urfave/cli/v3 digest to fc07a8c [[#4043](https://github.com/woodpecker-ci/woodpecker/pull/4043)]\n- Update woodpeckerci/plugin-git Docker tag to v2.5.2 [[#4041](https://github.com/woodpecker-ci/woodpecker/pull/4041)]\n- Update web npm deps non-major [[#4034](https://github.com/woodpecker-ci/woodpecker/pull/4034)]\n- Update dependency simple-icons to v13 [[#4037](https://github.com/woodpecker-ci/woodpecker/pull/4037)]\n- chore(deps): lock file maintenance [[#3991](https://github.com/woodpecker-ci/woodpecker/pull/3991)]\n- fix(deps): update golang-packages [[#3958](https://github.com/woodpecker-ci/woodpecker/pull/3958)]\n\n### Misc\n\n- Use mirror.gcr.io as `trivy` registry [[#4729](https://github.com/woodpecker-ci/woodpecker/pull/4729)]\n- Add docs-dependencies target to makefile [[#4719](https://github.com/woodpecker-ci/woodpecker/pull/4719)]\n- Move link checks into cron-curated issue dashboard [[#4515](https://github.com/woodpecker-ci/woodpecker/pull/4515)]\n- Remove `renovate` branch triggers [[#4437](https://github.com/woodpecker-ci/woodpecker/pull/4437)]\n- Dont run pipeline on push events to renovate branches [[#4406](https://github.com/woodpecker-ci/woodpecker/pull/4406)]\n- Harden and correct fifo task queue tests [[#4377](https://github.com/woodpecker-ci/woodpecker/pull/4377)]\n- Use release-helper for release/* branches [[#4301](https://github.com/woodpecker-ci/woodpecker/pull/4301)]\n- Fix renovate support for `xgo` [[#4276](https://github.com/woodpecker-ci/woodpecker/pull/4276)]\n- Improve nix development environment [[#4256](https://github.com/woodpecker-ci/woodpecker/pull/4256)]\n- [pre-commit.ci] pre-commit autoupdate [[#4209](https://github.com/woodpecker-ci/woodpecker/pull/4209)]\n- Add `.lycheeignore` [[#4154](https://github.com/woodpecker-ci/woodpecker/pull/4154)]\n- Add eslint-plugin-promise back [[#4022](https://github.com/woodpecker-ci/woodpecker/pull/4022)]\n- Improve wording [[#3951](https://github.com/woodpecker-ci/woodpecker/pull/3951)]\n- Fix typos and optimize wording [[#3940](https://github.com/woodpecker-ci/woodpecker/pull/3940)]\n\n## [2.7.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.2) - 2024-11-03\n\n### Important\n\nTo secure your instance, set `WOODPECKER_PLUGINS_PRIVILEGED` to only allow specific versions of the `woodpeckerci/plugin-docker-buildx` plugin, use version 5.0.0 or above. This prevents older, potentially unstable versions from being privileged.\n\nFor example, to allow only version 5.0.0, use:\n\n```bash\nWOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0\n```\n\nTo allow multiple versions, you can separate them with commas:\n\n```bash\nWOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0,woodpeckerci/plugin-docker-buildx:5.1.0\n```\n\nThis setup ensures only specified, stable plugin versions are given privileged access.\n\nRead more about it in [#4213](https://github.com/woodpecker-ci/woodpecker/pull/4213)\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @anbraten, @j04n-f, @pat-s, @qwerty287\n\n### 🔒 Security\n\n- Chore(deps): update dependency vite to v5.4.6 [security] ([#4163](https://github.com/woodpecker-ci/woodpecker/pull/4163)) [[#4187](https://github.com/woodpecker-ci/woodpecker/pull/4187)]\n\n### 🐛 Bug Fixes\n\n- Don't parse forge config files multiple times if no error occured ([#4272](https://github.com/woodpecker-ci/woodpecker/pull/4272)) [[#4273](https://github.com/woodpecker-ci/woodpecker/pull/4273)]\n- Fix repo/owner parsing for gitlab ([#4255](https://github.com/woodpecker-ci/woodpecker/pull/4255)) [[#4261](https://github.com/woodpecker-ci/woodpecker/pull/4261)]\n- Run queue.process() in background [[#4115](https://github.com/woodpecker-ci/woodpecker/pull/4115)]\n- Only update agent.LastWork if not done recently ([#4031](https://github.com/woodpecker-ci/woodpecker/pull/4031)) [[#4100](https://github.com/woodpecker-ci/woodpecker/pull/4100)]\n\n### Misc\n\n- Backport JS dependency updates [[#4189](https://github.com/woodpecker-ci/woodpecker/pull/4189)]\n\n## [2.7.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.1) - 2024-09-07\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @anbraten, @j04n-f, @qwerty287\n\n### 🔒 Security\n\n- Lint privileged plugin match and allow to be set empty [[#4084](https://github.com/woodpecker-ci/woodpecker/pull/4084)]\n- Allow admins to specify privileged plugins by name **and tag** [[#4076](https://github.com/woodpecker-ci/woodpecker/pull/4076)]\n- Warn if using secrets/env with plugin [[#4039](https://github.com/woodpecker-ci/woodpecker/pull/4039)]\n\n### 🐛 Bug Fixes\n\n- Set refspec for gitlab MR [[#4021](https://github.com/woodpecker-ci/woodpecker/pull/4021)]\n- Change Bitbucket PR hook to point the source branch, commit & ref [[#3965](https://github.com/woodpecker-ci/woodpecker/pull/3965)]\n- Add updated, merged and declined events to bb webhook activation [[#3963](https://github.com/woodpecker-ci/woodpecker/pull/3963)]\n- Fix login via navbar [[#3962](https://github.com/woodpecker-ci/woodpecker/pull/3962)]\n- Fix panic if forge is unreachable [[#3944](https://github.com/woodpecker-ci/woodpecker/pull/3944)]\n- Fix org settings page [[#4093](https://github.com/woodpecker-ci/woodpecker/pull/4093)]\n\n### Misc\n\n- Bump github.com/docker/docker from v24.0.9 to v24.0.9+30 [[#4077](https://github.com/woodpecker-ci/woodpecker/pull/4077)]\n\n## [2.7.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.0) - 2024-07-18\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @anbraten, @dvjn, @hhamalai, @lafriks, @pat-s, @qwerty287, @smainz, @tongjicoder, @zc-devs\n\n### 🔒 Security\n\n- Add blocklist of environment variables who could alter execution of plugins [[#3934](https://github.com/woodpecker-ci/woodpecker/pull/3934)]\n- Make sure plugins only mount the workspace base in a predefinde location [[#3933](https://github.com/woodpecker-ci/woodpecker/pull/3933)]\n- Disallow to set arbitrary environments for plugins [[#3909](https://github.com/woodpecker-ci/woodpecker/pull/3909)]\n- Use proper oauth state [[#3847](https://github.com/woodpecker-ci/woodpecker/pull/3847)]\n- Enhance token checking [[#3842](https://github.com/woodpecker-ci/woodpecker/pull/3842)]\n- Bump github.com/hashicorp/go-retryablehttp v0.7.5 -> v0.7.7 [[#3834](https://github.com/woodpecker-ci/woodpecker/pull/3834)]\n\n### ✨ Features\n\n- Gracefully shutdown server [[#3896](https://github.com/woodpecker-ci/woodpecker/pull/3896)]\n- Gracefully shutdown agent [[#3895](https://github.com/woodpecker-ci/woodpecker/pull/3895)]\n- Convert urls in logs to links  [[#3904](https://github.com/woodpecker-ci/woodpecker/pull/3904)]\n- Allow login using multiple forges [[#3822](https://github.com/woodpecker-ci/woodpecker/pull/3822)]\n- Global and organization registries [[#1672](https://github.com/woodpecker-ci/woodpecker/pull/1672)]\n- Cli get repo from git remote [[#3830](https://github.com/woodpecker-ci/woodpecker/pull/3830)]\n- Add api for forges [[#3733](https://github.com/woodpecker-ci/woodpecker/pull/3733)]\n\n### 📈 Enhancement\n\n- Cli fix pipeline logs [[#3913](https://github.com/woodpecker-ci/woodpecker/pull/3913)]\n- Migrate to github.com/urfave/cli/v3 [[#2951](https://github.com/woodpecker-ci/woodpecker/pull/2951)]\n- Allow to change the working directory also for plugins and services [[#3914](https://github.com/woodpecker-ci/woodpecker/pull/3914)]\n- Remove `unplugin-icons` [[#3809](https://github.com/woodpecker-ci/woodpecker/pull/3809)]\n- Release windows binaries as zip file [[#3906](https://github.com/woodpecker-ci/woodpecker/pull/3906)]\n- Convert to openapi 3.0 [[#3897](https://github.com/woodpecker-ci/woodpecker/pull/3897)]\n- Enhance pipeline list [[#3898](https://github.com/woodpecker-ci/woodpecker/pull/3898)]\n- Add user registries UI [[#3888](https://github.com/woodpecker-ci/woodpecker/pull/3888)]\n- Sort users by login [[#3891](https://github.com/woodpecker-ci/woodpecker/pull/3891)]\n- Exclude dummy backend in production [[#3877](https://github.com/woodpecker-ci/woodpecker/pull/3877)]\n- Fix deploy task env [[#3878](https://github.com/woodpecker-ci/woodpecker/pull/3878)]\n- Get default branch and show message in pipeline list [[#3867](https://github.com/woodpecker-ci/woodpecker/pull/3867)]\n- Add timestamp for last work done by agent [[#3844](https://github.com/woodpecker-ci/woodpecker/pull/3844)]\n- Adjust logger types [[#3859](https://github.com/woodpecker-ci/woodpecker/pull/3859)]\n- Cleanup state reporting [[#3850](https://github.com/woodpecker-ci/woodpecker/pull/3850)]\n- Unify DB tables/columns [[#3806](https://github.com/woodpecker-ci/woodpecker/pull/3806)]\n- Let webhook pass on pipeline parsing error [[#3829](https://github.com/woodpecker-ci/woodpecker/pull/3829)]\n- Exclude mocks from release build [[#3831](https://github.com/woodpecker-ci/woodpecker/pull/3831)]\n- K8s secrets reference from step [[#3655](https://github.com/woodpecker-ci/woodpecker/pull/3655)]\n\n### 🐛 Bug Fixes\n\n- Handle empty repositories in gitea when listing PRs [[#3925](https://github.com/woodpecker-ci/woodpecker/pull/3925)]\n- Update alpine package dep for docker images [[#3917](https://github.com/woodpecker-ci/woodpecker/pull/3917)]\n- Don't report error if agent was terminated gracefully [[#3894](https://github.com/woodpecker-ci/woodpecker/pull/3894)]\n- Let agents continuously report their health [[#3893](https://github.com/woodpecker-ci/woodpecker/pull/3893)]\n- Ignore warnings for cli exec [[#3868](https://github.com/woodpecker-ci/woodpecker/pull/3868)]\n- Correct favicon states [[#3832](https://github.com/woodpecker-ci/woodpecker/pull/3832)]\n- Cleanup of the login flow and tests [[#3810](https://github.com/woodpecker-ci/woodpecker/pull/3810)]\n- Fix newlines in logs [[#3808](https://github.com/woodpecker-ci/woodpecker/pull/3808)]\n- Fix authentication error handling [[#3807](https://github.com/woodpecker-ci/woodpecker/pull/3807)]\n\n### 📚 Documentation\n\n- Streamline docs for new users [[#3803](https://github.com/woodpecker-ci/woodpecker/pull/3803)]\n- Add mastodon verification [[#3843](https://github.com/woodpecker-ci/woodpecker/pull/3843)]\n- chore(deps): update docs npm deps non-major [[#3837](https://github.com/woodpecker-ci/woodpecker/pull/3837)]\n- fix(deps): update docs npm deps non-major [[#3824](https://github.com/woodpecker-ci/woodpecker/pull/3824)]\n- Add openSUSE package [[#3800](https://github.com/woodpecker-ci/woodpecker/pull/3800)]\n- chore(deps): update docs npm deps non-major [[#3798](https://github.com/woodpecker-ci/woodpecker/pull/3798)]\n- Add \"Docker Tags\" Plugin [[#3796](https://github.com/woodpecker-ci/woodpecker/pull/3796)]\n- chore(deps): update dependency marked to v13 [[#3792](https://github.com/woodpecker-ci/woodpecker/pull/3792)]\n- chore: fix some comments [[#3788](https://github.com/woodpecker-ci/woodpecker/pull/3788)]\n\n### Misc\n\n- chore(deps): update web npm deps non-major [[#3930](https://github.com/woodpecker-ci/woodpecker/pull/3930)]\n- chore(deps): update dependency vitest to v2 [[#3905](https://github.com/woodpecker-ci/woodpecker/pull/3905)]\n- fix(deps): update module github.com/google/go-github/v62 to v63 [[#3910](https://github.com/woodpecker-ci/woodpecker/pull/3910)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v4.1.0 [[#3908](https://github.com/woodpecker-ci/woodpecker/pull/3908)]\n- Update plugin-git and add renovate trigger [[#3901](https://github.com/woodpecker-ci/woodpecker/pull/3901)]\n- chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3.0.3 [[#3903](https://github.com/woodpecker-ci/woodpecker/pull/3903)]\n- fix(deps): update golang-packages [[#3875](https://github.com/woodpecker-ci/woodpecker/pull/3875)]\n- chore(deps): lock file maintenance [[#3876](https://github.com/woodpecker-ci/woodpecker/pull/3876)]\n- [pre-commit.ci] pre-commit autoupdate [[#3862](https://github.com/woodpecker-ci/woodpecker/pull/3862)]\n- Add dummy backend [[#3820](https://github.com/woodpecker-ci/woodpecker/pull/3820)]\n- chore(deps): update dependency replace-in-file to v8 [[#3852](https://github.com/woodpecker-ci/woodpecker/pull/3852)]\n- Update forgejo sdk [[#3840](https://github.com/woodpecker-ci/woodpecker/pull/3840)]\n- chore(deps): lock file maintenance [[#3838](https://github.com/woodpecker-ci/woodpecker/pull/3838)]\n- Allow to set dist dir using env var [[#3814](https://github.com/woodpecker-ci/woodpecker/pull/3814)]\n- chore(deps): lock file maintenance [[#3805](https://github.com/woodpecker-ci/woodpecker/pull/3805)]\n- chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.15.1 [[#3797](https://github.com/woodpecker-ci/woodpecker/pull/3797)]\n\n## [2.6.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.6.1) - 2024-07-19\n\n### 🔒 Security\n\n- Add blocklist of environment variables who could alter execution of plugins [[#3934](https://github.com/woodpecker-ci/woodpecker/pull/3934)]\n- Make sure plugins only mount the workspace base in a predefinde location [[#3933](https://github.com/woodpecker-ci/woodpecker/pull/3933)]\n- Disalow to set arbitrary environments for plugins [[#3909](https://github.com/woodpecker-ci/woodpecker/pull/3909)]\n- Bump trivy plugin version and remove unused variable [[#3833](https://github.com/woodpecker-ci/woodpecker/pull/3833)]\n\n### 🐛 Bug Fixes\n\n- Let webhook pass on pipeline parsion error [[#3829](https://github.com/woodpecker-ci/woodpecker/pull/3829)]\n- Fix newlines in logs [[#3808](https://github.com/woodpecker-ci/woodpecker/pull/3808)]\n\n## [2.6.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.6.0) - 2024-06-13\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @anbraten, @jcgl17, @pat-s, @qwerty287, @s00500, @wez, @zc-devs\n\n### 🔒 Security\n\n- Bump trivy plugin version and remove unused variable [[#3759](https://github.com/woodpecker-ci/woodpecker/pull/3759)]\n\n### ✨ Features\n\n- Allow to store logs in files [[#3568](https://github.com/woodpecker-ci/woodpecker/pull/3568)]\n- Native forgejo support [[#3684](https://github.com/woodpecker-ci/woodpecker/pull/3684)]\n\n### 🐛 Bug Fixes\n\n- Add release event to webhooks [[#3784](https://github.com/woodpecker-ci/woodpecker/pull/3784)]\n- Respect cli argument when checking docker backend availability [[#3770](https://github.com/woodpecker-ci/woodpecker/pull/3770)]\n- Fix repo creation [[#3756](https://github.com/woodpecker-ci/woodpecker/pull/3756)]\n- Fix config loading of cli [[#3764](https://github.com/woodpecker-ci/woodpecker/pull/3764)]\n- Fix missing WOODPECKER_BITBUCKET_DC_URL [[#3761](https://github.com/woodpecker-ci/woodpecker/pull/3761)]\n- Correct repo repair success message in cli [[#3757](https://github.com/woodpecker-ci/woodpecker/pull/3757)]\n\n### 📈 Enhancement\n\n- Improve step logging [[#3722](https://github.com/woodpecker-ci/woodpecker/pull/3722)]\n- chore(deps): update dependency eslint to v9 [[#3594](https://github.com/woodpecker-ci/woodpecker/pull/3594)]\n- Show workflow names if there are multiple configs [[#3767](https://github.com/woodpecker-ci/woodpecker/pull/3767)]\n- Use http constants [[#3766](https://github.com/woodpecker-ci/woodpecker/pull/3766)]\n- Spellcheck \"server/*\" [[#3753](https://github.com/woodpecker-ci/woodpecker/pull/3753)]\n- Agent-wide node selector [[#3608](https://github.com/woodpecker-ci/woodpecker/pull/3608)]\n\n### 📚 Documentation\n\n- Remove misleading crontab guru suggestion from docs [[#3781](https://github.com/woodpecker-ci/woodpecker/pull/3781)]\n- Add documentation for KUBERNETES_SERVICE_HOST in Agent [[#3747](https://github.com/woodpecker-ci/woodpecker/pull/3747)]\n- Remove web.archive.org workaround in docs [[#3771](https://github.com/woodpecker-ci/woodpecker/pull/3771)]\n- Serve plugin icons locally [[#3768](https://github.com/woodpecker-ci/woodpecker/pull/3768)]\n- Docs: update local backend page [[#3765](https://github.com/woodpecker-ci/woodpecker/pull/3765)]\n- Remove old docs versions [[#3743](https://github.com/woodpecker-ci/woodpecker/pull/3743)]\n- Merge release plugins [[#3752](https://github.com/woodpecker-ci/woodpecker/pull/3752)]\n- Split FAQ [[#3746](https://github.com/woodpecker-ci/woodpecker/pull/3746)]\n\n### Misc\n\n- Update nix flake [[#3780](https://github.com/woodpecker-ci/woodpecker/pull/3780)]\n- chore(deps): lock file maintenance [[#3783](https://github.com/woodpecker-ci/woodpecker/pull/3783)]\n- chore(deps): update pre-commit hook golangci/golangci-lint to v1.59.1 [[#3782](https://github.com/woodpecker-ci/woodpecker/pull/3782)]\n- fix(deps): update codeberg.org/mvdkleijn/forgejo-sdk/forgejo digest to 168c988 [[#3776](https://github.com/woodpecker-ci/woodpecker/pull/3776)]\n- chore(deps): lock file maintenance [[#3750](https://github.com/woodpecker-ci/woodpecker/pull/3750)]\n- chore(deps): update gitea/gitea docker tag to v1.22 [[#3749](https://github.com/woodpecker-ci/woodpecker/pull/3749)]\n- Fix setting name [[#3744](https://github.com/woodpecker-ci/woodpecker/pull/3744)]\n\n## [2.5.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.5.0) - 2024-06-01\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Andre601, @Elara6331, @OCram85, @anbraten, @aumetra, @da-Kai, @dominic-p, @dvjn, @eliasscosta, @fernandrone, @linghuying, @manuelluis, @nemunaire, @pat-s, @qwerty287, @sinlov, @stevapple, @xoxys, @zc-devs\n\n### 🔒 Security\n\n- bump golang.org/x/net to v0.24.0 [[#3628](https://github.com/woodpecker-ci/woodpecker/pull/3628)]\n\n### ✨ Features\n\n- Add DeletePipeline API [[#3506](https://github.com/woodpecker-ci/woodpecker/pull/3506)]\n- CLI: remove step logs [[#3458](https://github.com/woodpecker-ci/woodpecker/pull/3458)]\n- Step logs removing API and Button [[#3451](https://github.com/woodpecker-ci/woodpecker/pull/3451)]\n\n### 📚 Documentation\n\n- Create 2.5 docs [[#3732](https://github.com/woodpecker-ci/woodpecker/pull/3732)]\n- Fix spelling in README [[#3741](https://github.com/woodpecker-ci/woodpecker/pull/3741)]\n- chore: fix some comments [[#3740](https://github.com/woodpecker-ci/woodpecker/pull/3740)]\n- Add \"Is It Up Yet?\" Plugin [[#3731](https://github.com/woodpecker-ci/woodpecker/pull/3731)]\n- Remove discord as official community channel [[#3717](https://github.com/woodpecker-ci/woodpecker/pull/3717)]\n- Add Gitea Package plugin [[#3707](https://github.com/woodpecker-ci/woodpecker/pull/3707)]\n- Add documentation for setting Kubernetes labels and annotations [[#3687](https://github.com/woodpecker-ci/woodpecker/pull/3687)]\n- Remove broken link to gobook.io [[#3694](https://github.com/woodpecker-ci/woodpecker/pull/3694)]\n- docs: add `Gitea publisher-golang` plugin [[#3691](https://github.com/woodpecker-ci/woodpecker/pull/3691)]\n- Add Ansible+Woodpecker blog post [[#3685](https://github.com/woodpecker-ci/woodpecker/pull/3685)]\n- Clarify info on failing workflows/Steps [[#3679](https://github.com/woodpecker-ci/woodpecker/pull/3679)]\n- Add discord plugin [[#3662](https://github.com/woodpecker-ci/woodpecker/pull/3662)]\n- chore(deps): update dependency trim to v1 [[#3658](https://github.com/woodpecker-ci/woodpecker/pull/3658)]\n- chore(deps): update dependency got to v14 [[#3657](https://github.com/woodpecker-ci/woodpecker/pull/3657)]\n- Fail on broken anchors [[#3644](https://github.com/woodpecker-ci/woodpecker/pull/3644)]\n- Fix step syntax in docs [[#3635](https://github.com/woodpecker-ci/woodpecker/pull/3635)]\n- chore(deps): update docs npm deps non-major [[#3632](https://github.com/woodpecker-ci/woodpecker/pull/3632)]\n- Add Twine plugin [[#3619](https://github.com/woodpecker-ci/woodpecker/pull/3619)]\n- Fix docs [[#3615](https://github.com/woodpecker-ci/woodpecker/pull/3615)]\n- Document how to enable parallel step exec for all steps [[#3605](https://github.com/woodpecker-ci/woodpecker/pull/3605)]\n- Update dependency @types/marked to v6 [[#3544](https://github.com/woodpecker-ci/woodpecker/pull/3544)]\n- Update docs npm deps non-major [[#3485](https://github.com/woodpecker-ci/woodpecker/pull/3485)]\n- Docs updates and fixes [[#3535](https://github.com/woodpecker-ci/woodpecker/pull/3535)]\n\n### 🐛 Bug Fixes\n\n- Fix privileged steps in kubernetes [[#3711](https://github.com/woodpecker-ci/woodpecker/pull/3711)]\n- Check for error in repo middleware [[#3688](https://github.com/woodpecker-ci/woodpecker/pull/3688)]\n- Fix parent pipeline number env on restarts [[#3683](https://github.com/woodpecker-ci/woodpecker/pull/3683)]\n- Fix bitbucket dir fetching [[#3668](https://github.com/woodpecker-ci/woodpecker/pull/3668)]\n- Sanitize tag ref for gitea/forgejo [[#3664](https://github.com/woodpecker-ci/woodpecker/pull/3664)]\n- Fix secret loading [[#3620](https://github.com/woodpecker-ci/woodpecker/pull/3620)]\n- fix cli config loading and correct comment [[#3618](https://github.com/woodpecker-ci/woodpecker/pull/3618)]\n- Handle ImagePullBackOff pod status [[#3580](https://github.com/woodpecker-ci/woodpecker/pull/3580)]\n- Apply skip ci filter only on push events [[#3612](https://github.com/woodpecker-ci/woodpecker/pull/3612)]\n- agent: Continue to retry indefinitely [[#3599](https://github.com/woodpecker-ci/woodpecker/pull/3599)]\n- Fix cli version comparison and improve setup [[#3518](https://github.com/woodpecker-ci/woodpecker/pull/3518)]\n- Fix flag name [[#3534](https://github.com/woodpecker-ci/woodpecker/pull/3534)]\n\n### 📈 Enhancement\n\n- Use IDs for tokens [[#3695](https://github.com/woodpecker-ci/woodpecker/pull/3695)]\n- Lint go code with cspell [[#3706](https://github.com/woodpecker-ci/woodpecker/pull/3706)]\n- Replace duplicated strings [[#3710](https://github.com/woodpecker-ci/woodpecker/pull/3710)]\n- Cleanup server env settings [[#3670](https://github.com/woodpecker-ci/woodpecker/pull/3670)]\n- Setting for empty commits on path condition [[#3708](https://github.com/woodpecker-ci/woodpecker/pull/3708)]\n- Lint file names and directories via cSpell too [[#3703](https://github.com/woodpecker-ci/woodpecker/pull/3703)]\n- Make retry count of config fetching form forge configure [[#3699](https://github.com/woodpecker-ci/woodpecker/pull/3699)]\n- Ability to set pod annotations and labels from step [[#3609](https://github.com/woodpecker-ci/woodpecker/pull/3609)]\n- Support github deploy task [[#3512](https://github.com/woodpecker-ci/woodpecker/pull/3512)]\n- Rework entrypoints [[#3269](https://github.com/woodpecker-ci/woodpecker/pull/3269)]\n- Add cli output handlers [[#3660](https://github.com/woodpecker-ci/woodpecker/pull/3660)]\n- Cleanup api docs and ts api-client options [[#3663](https://github.com/woodpecker-ci/woodpecker/pull/3663)]\n- Split client into multiple files and add more tests [[#3647](https://github.com/woodpecker-ci/woodpecker/pull/3647)]\n- Add filter options to GetPipelines API [[#3645](https://github.com/woodpecker-ci/woodpecker/pull/3645)]\n- Deprecate environment filter and improve errors [[#3634](https://github.com/woodpecker-ci/woodpecker/pull/3634)]\n- Add task details to queue info in woodpecker-go [[#3636](https://github.com/woodpecker-ci/woodpecker/pull/3636)]\n- Use forge from db [[#1417](https://github.com/woodpecker-ci/woodpecker/pull/1417)]\n- Remove review button from approval view [[#3617](https://github.com/woodpecker-ci/woodpecker/pull/3617)]\n- Rework addons (use rpc) [[#3268](https://github.com/woodpecker-ci/woodpecker/pull/3268)]\n- Allow to disable deployments [[#3570](https://github.com/woodpecker-ci/woodpecker/pull/3570)]\n- Add flag to only access public repositories on GitHub [[#3566](https://github.com/woodpecker-ci/woodpecker/pull/3566)]\n- Add `runtimeClassName` in Kubernetes backend options [[#3474](https://github.com/woodpecker-ci/woodpecker/pull/3474)]\n- Remove unused cache properties [[#3567](https://github.com/woodpecker-ci/woodpecker/pull/3567)]\n- Allow separate gitea oauth URL  [[#3513](https://github.com/woodpecker-ci/woodpecker/pull/3513)]\n- Add option to set the local repository path to the cli command exec. [[#3524](https://github.com/woodpecker-ci/woodpecker/pull/3524)]\n\n### Misc\n\n- chore(deps): update pre-commit non-major [[#3736](https://github.com/woodpecker-ci/woodpecker/pull/3736)]\n- chore(deps): update docker.io/alpine docker tag to v3.20 [[#3735](https://github.com/woodpecker-ci/woodpecker/pull/3735)]\n- fix(deps): update module github.com/google/go-github/v61 to v62 [[#3730](https://github.com/woodpecker-ci/woodpecker/pull/3730)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v4 [[#3729](https://github.com/woodpecker-ci/woodpecker/pull/3729)]\n- chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3 [[#3728](https://github.com/woodpecker-ci/woodpecker/pull/3728)]\n- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.2 [[#3724](https://github.com/woodpecker-ci/woodpecker/pull/3724)]\n- fix(deps): update golang-packages [[#3713](https://github.com/woodpecker-ci/woodpecker/pull/3713)]\n- chore(deps): update postgres docker tag to v16.3 [[#3719](https://github.com/woodpecker-ci/woodpecker/pull/3719)]\n- chore(deps): update docker.io/appleboy/drone-discord docker tag to v1.3.2 [[#3718](https://github.com/woodpecker-ci/woodpecker/pull/3718)]\n- Added steps to reproduce and expected behavior in bug_report.yaml [[#3714](https://github.com/woodpecker-ci/woodpecker/pull/3714)]\n- flake: add flake-utils import and use eachDefaultSystem [[#3704](https://github.com/woodpecker-ci/woodpecker/pull/3704)]\n- Add nix flake for dev shell [[#3702](https://github.com/woodpecker-ci/woodpecker/pull/3702)]\n- Skip golangci in pre-commit.ci [[#3692](https://github.com/woodpecker-ci/woodpecker/pull/3692)]\n- chore(deps): update woodpeckerci/plugin-github-release docker tag to v1.2.0 [[#3690](https://github.com/woodpecker-ci/woodpecker/pull/3690)]\n- Switch back to upstream xgo image [[#3682](https://github.com/woodpecker-ci/woodpecker/pull/3682)]\n- Allow running tests on arm64 runners [[#2605](https://github.com/woodpecker-ci/woodpecker/pull/2605)]\n- chore(deps): update node.js to v22 [[#3659](https://github.com/woodpecker-ci/woodpecker/pull/3659)]\n- chore(deps): lock file maintenance [[#3656](https://github.com/woodpecker-ci/woodpecker/pull/3656)]\n- Add make target for spellcheck [[#3648](https://github.com/woodpecker-ci/woodpecker/pull/3648)]\n- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.1 [[#3641](https://github.com/woodpecker-ci/woodpecker/pull/3641)]\n- chore(deps): update web npm deps non-major [[#3640](https://github.com/woodpecker-ci/woodpecker/pull/3640)]\n- chore(deps): update web npm deps non-major [[#3631](https://github.com/woodpecker-ci/woodpecker/pull/3631)]\n- Use our github-release plugin [[#3624](https://github.com/woodpecker-ci/woodpecker/pull/3624)]\n- chore(deps): lock file maintenance [[#3622](https://github.com/woodpecker-ci/woodpecker/pull/3622)]\n- Fix spellcheck and enable more dirs [[#3603](https://github.com/woodpecker-ci/woodpecker/pull/3603)]\n- Update docker.io/golang Docker tag to v1.22.2 [[#3596](https://github.com/woodpecker-ci/woodpecker/pull/3596)]\n- Update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 [[#3597](https://github.com/woodpecker-ci/woodpecker/pull/3597)]\n- Update module github.com/google/go-github/v60 to v61 [[#3595](https://github.com/woodpecker-ci/woodpecker/pull/3595)]\n- Update pre-commit hook golangci/golangci-lint to v1.57.2 [[#3575](https://github.com/woodpecker-ci/woodpecker/pull/3575)]\n- Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v3.2.1 [[#3574](https://github.com/woodpecker-ci/woodpecker/pull/3574)]\n- Update web npm deps non-major [[#3576](https://github.com/woodpecker-ci/woodpecker/pull/3576)]\n- Update dependency @intlify/unplugin-vue-i18n to v4 [[#3572](https://github.com/woodpecker-ci/woodpecker/pull/3572)]\n- Update golang (packages) [[#3564](https://github.com/woodpecker-ci/woodpecker/pull/3564)]\n- Update dependency typescript to v5.4.3 [[#3563](https://github.com/woodpecker-ci/woodpecker/pull/3563)]\n- Lock file maintenance [[#3562](https://github.com/woodpecker-ci/woodpecker/pull/3562)]\n- Update pre-commit non-major [[#3556](https://github.com/woodpecker-ci/woodpecker/pull/3556)]\n- Update web npm deps non-major [[#3549](https://github.com/woodpecker-ci/woodpecker/pull/3549)]\n- Update dependency @types/node-emoji to v2 [[#3545](https://github.com/woodpecker-ci/woodpecker/pull/3545)]\n- Update golang (packages) [[#3543](https://github.com/woodpecker-ci/woodpecker/pull/3543)]\n- Lock file maintenance [[#3541](https://github.com/woodpecker-ci/woodpecker/pull/3541)]\n- Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v3.2.0 [[#3540](https://github.com/woodpecker-ci/woodpecker/pull/3540)]\n\n## [2.4.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.4.1) - 2024-03-20\n\n### ❤️ Thanks to all contributors! ❤️\n\n@manuelluis, @qwerty287, @xoxys\n\n### 🔒 Security\n\n- Only allow to deploy from push, tag and release [[#3522](https://github.com/woodpecker-ci/woodpecker/pull/3522)]\n\n### 🐛 Bug Fixes\n\n- Exclude setup from cli command exec. [[#3523](https://github.com/woodpecker-ci/woodpecker/pull/3523)]\n- Fix uppercased env [[#3516](https://github.com/woodpecker-ci/woodpecker/pull/3516)]\n- Fix env schema [[#3514](https://github.com/woodpecker-ci/woodpecker/pull/3514)]\n\n### Misc\n\n- Temp pin golangci version in makefile [[#3520](https://github.com/woodpecker-ci/woodpecker/pull/3520)]\n\n## [2.4.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.4.0) - 2024-03-19\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @Ray-D-Song, @anbraten, @eliasscosta, @fernandrone, @kjuulh, @kytta, @langecode, @lukashass, @qwerty287, @rockdrilla, @sinlov, @smainz, @xoxys, @zc-devs, @zowhoey\n\n### 🔒 Security\n\n- Improve security context handling [[#3482](https://github.com/woodpecker-ci/woodpecker/pull/3482)]\n- fix(deps): update module github.com/moby/moby to v24.0.9+incompatible [[#3323](https://github.com/woodpecker-ci/woodpecker/pull/3323)]\n\n### ✨ Features\n\n- Cli setup command [[#3384](https://github.com/woodpecker-ci/woodpecker/pull/3384)]\n- Add bitbucket datacenter (server) support  [[#2503](https://github.com/woodpecker-ci/woodpecker/pull/2503)]\n- Cli updater [[#3382](https://github.com/woodpecker-ci/woodpecker/pull/3382)]\n\n### 📚 Documentation\n\n- Delete docs for v0.15.x [[#3508](https://github.com/woodpecker-ci/woodpecker/pull/3508)]\n- Add deployment plugin [[#3495](https://github.com/woodpecker-ci/woodpecker/pull/3495)]\n- Bump follow-redirects and fix broken anchors [[#3488](https://github.com/woodpecker-ci/woodpecker/pull/3488)]\n- fix: plugin doc page not found [[#3480](https://github.com/woodpecker-ci/woodpecker/pull/3480)]\n- Documentation improvements [[#3376](https://github.com/woodpecker-ci/woodpecker/pull/3376)]\n- fix(deps): update docs npm deps non-major [[#3455](https://github.com/woodpecker-ci/woodpecker/pull/3455)]\n- Add \"Sonatype Nexus\" plugin [[#3446](https://github.com/woodpecker-ci/woodpecker/pull/3446)]\n- Add blog post [[#3439](https://github.com/woodpecker-ci/woodpecker/pull/3439)]\n- Add \"Gradle Wrapper Validation\" plugin [[#3435](https://github.com/woodpecker-ci/woodpecker/pull/3435)]\n- Add blog post [[#3410](https://github.com/woodpecker-ci/woodpecker/pull/3410)]\n- Extend core ideas documentation [[#3405](https://github.com/woodpecker-ci/woodpecker/pull/3405)]\n- docs: fix contributions link [[#3363](https://github.com/woodpecker-ci/woodpecker/pull/3363)]\n- Update/fix some docs [[#3359](https://github.com/woodpecker-ci/woodpecker/pull/3359)]\n- chore(deps): update dependency marked to v12 [[#3325](https://github.com/woodpecker-ci/woodpecker/pull/3325)]\n\n### 🐛 Bug Fixes\n\n- Fix skip setup for some general cli commands [[#3498](https://github.com/woodpecker-ci/woodpecker/pull/3498)]\n- Move generic agent flags to cmd/agent/core [[#3484](https://github.com/woodpecker-ci/woodpecker/pull/3484)]\n- Fix usage of WOODPECKER_DATABASE_DATASOURCE_FILE [[#3404](https://github.com/woodpecker-ci/woodpecker/pull/3404)]\n- Set pull-request id and labels on pr-closed event [[#3442](https://github.com/woodpecker-ci/woodpecker/pull/3442)]\n- Update org name on login [[#3409](https://github.com/woodpecker-ci/woodpecker/pull/3409)]\n- Do not alter secret key upper-/lowercase [[#3375](https://github.com/woodpecker-ci/woodpecker/pull/3375)]\n- fix: can't run multiple services on k8s [[#3395](https://github.com/woodpecker-ci/woodpecker/pull/3395)]\n- Fix agent polling [[#3378](https://github.com/woodpecker-ci/woodpecker/pull/3378)]\n- Remove empty strings from slice before parsing agent config [[#3387](https://github.com/woodpecker-ci/woodpecker/pull/3387)]\n- Set correct link for commit [[#3368](https://github.com/woodpecker-ci/woodpecker/pull/3368)]\n- Fix schema links [[#3369](https://github.com/woodpecker-ci/woodpecker/pull/3369)]\n- Fix correctly handle gitlab pr closed events [[#3362](https://github.com/woodpecker-ci/woodpecker/pull/3362)]\n- fix: update schema event_enum to remove error warning when.event [[#3357](https://github.com/woodpecker-ci/woodpecker/pull/3357)]\n- Fix version check on next [[#3340](https://github.com/woodpecker-ci/woodpecker/pull/3340)]\n- Ignore gitlab merge request events without code changes [[#3338](https://github.com/woodpecker-ci/woodpecker/pull/3338)]\n- Ignore gitlab push events without commits [[#3339](https://github.com/woodpecker-ci/woodpecker/pull/3339)]\n- Consider gitlab inherited permissions [[#3308](https://github.com/woodpecker-ci/woodpecker/pull/3308)]\n- fix: agent panic when node is terminated during step execution [[#3331](https://github.com/woodpecker-ci/woodpecker/pull/3331)]\n\n### 📈 Enhancement\n\n- Enable golangci linter gomnd [[#3171](https://github.com/woodpecker-ci/woodpecker/pull/3171)]\n- Apply \"grpcnotrace\" go build tag [[#3448](https://github.com/woodpecker-ci/woodpecker/pull/3448)]\n- Simplify store interfaces [[#3437](https://github.com/woodpecker-ci/woodpecker/pull/3437)]\n- Deprecate alternative names on secrets [[#3406](https://github.com/woodpecker-ci/woodpecker/pull/3406)]\n- Store workflows/steps for blocked pipeline [[#2757](https://github.com/woodpecker-ci/woodpecker/pull/2757)]\n- Parse email from Gitea webhook [[#3420](https://github.com/woodpecker-ci/woodpecker/pull/3420)]\n- Replace http types on forge interface [[#3374](https://github.com/woodpecker-ci/woodpecker/pull/3374)]\n- Prevent agent deletion when it's still running tasks [[#3377](https://github.com/woodpecker-ci/woodpecker/pull/3377)]\n- Refactor internal services [[#915](https://github.com/woodpecker-ci/woodpecker/pull/915)]\n- Lint for event filter and deprecate `exclude` [[#3222](https://github.com/woodpecker-ci/woodpecker/pull/3222)]\n- Allow editing all environment variables in pipeline popups [[#3314](https://github.com/woodpecker-ci/woodpecker/pull/3314)]\n- Parse backend options in backend [[#3227](https://github.com/woodpecker-ci/woodpecker/pull/3227)]\n- Make agent usable for external backends [[#3270](https://github.com/woodpecker-ci/woodpecker/pull/3270)]\n- Add no branches text [[#3312](https://github.com/woodpecker-ci/woodpecker/pull/3312)]\n- Add loading spinner to repo list [[#3310](https://github.com/woodpecker-ci/woodpecker/pull/3310)]\n\n### Misc\n\n- Post on mastodon when releasing a new version [[#3509](https://github.com/woodpecker-ci/woodpecker/pull/3509)]\n- chore(deps): update dependency alpine_3_18/ca-certificates to v20240226 [[#3501](https://github.com/woodpecker-ci/woodpecker/pull/3501)]\n- fix(deps): update module github.com/google/go-github/v59 to v60 [[#3493](https://github.com/woodpecker-ci/woodpecker/pull/3493)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to v3 [[#3492](https://github.com/woodpecker-ci/woodpecker/pull/3492)]\n- chore(deps): update dependency vue-tsc to v2 [[#3491](https://github.com/woodpecker-ci/woodpecker/pull/3491)]\n- chore(deps): update dependency eslint-config-airbnb-typescript to v18 [[#3490](https://github.com/woodpecker-ci/woodpecker/pull/3490)]\n- chore(deps): update web npm deps non-major [[#3489](https://github.com/woodpecker-ci/woodpecker/pull/3489)]\n- fix(deps): update golang (packages) [[#3486](https://github.com/woodpecker-ci/woodpecker/pull/3486)]\n- fix(deps): update module google.golang.org/protobuf to v1.33.0 [security] [[#3487](https://github.com/woodpecker-ci/woodpecker/pull/3487)]\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.22.1 [[#3476](https://github.com/woodpecker-ci/woodpecker/pull/3476)]\n- chore(deps): update docker.io/golang docker tag to v1.22.1 [[#3475](https://github.com/woodpecker-ci/woodpecker/pull/3475)]\n- Update prettier version [[#3471](https://github.com/woodpecker-ci/woodpecker/pull/3471)]\n- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.0 [[#3464](https://github.com/woodpecker-ci/woodpecker/pull/3464)]\n- chore(deps): lock file maintenance [[#3465](https://github.com/woodpecker-ci/woodpecker/pull/3465)]\n- chore(deps): update postgres docker tag to v16.2 [[#3461](https://github.com/woodpecker-ci/woodpecker/pull/3461)]\n- chore(deps): update lycheeverse/lychee docker tag to v0.14.3 [[#3429](https://github.com/woodpecker-ci/woodpecker/pull/3429)]\n- fix(deps): update golang (packages) [[#3430](https://github.com/woodpecker-ci/woodpecker/pull/3430)]\n- More `when` filters [[#3407](https://github.com/woodpecker-ci/woodpecker/pull/3407)]\n- Apply `documentation`/`ui` label to corresponding renovate updates [[#3400](https://github.com/woodpecker-ci/woodpecker/pull/3400)]\n- chore(deps): update dependency eslint-plugin-simple-import-sort to v12 [[#3396](https://github.com/woodpecker-ci/woodpecker/pull/3396)]\n- chore(deps): update typescript-eslint monorepo to v7 (major) [[#3397](https://github.com/woodpecker-ci/woodpecker/pull/3397)]\n- fix(deps): update module github.com/google/go-github/v58 to v59 [[#3398](https://github.com/woodpecker-ci/woodpecker/pull/3398)]\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.22.0 [[#3392](https://github.com/woodpecker-ci/woodpecker/pull/3392)]\n- chore(deps): update docker.io/golang docker tag [[#3391](https://github.com/woodpecker-ci/woodpecker/pull/3391)]\n- fix(deps): update golang (packages) [[#3393](https://github.com/woodpecker-ci/woodpecker/pull/3393)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3.1.0 [[#3394](https://github.com/woodpecker-ci/woodpecker/pull/3394)]\n- Add link checking [[#3371](https://github.com/woodpecker-ci/woodpecker/pull/3371)]\n- Apply `dependencies` label to all PRs [[#3358](https://github.com/woodpecker-ci/woodpecker/pull/3358)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3.0.1 [[#3324](https://github.com/woodpecker-ci/woodpecker/pull/3324)]\n\n## [2.3.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.3.0) - 2024-01-31\n\n### ❤️ Thanks to all contributors! ❤️\n\n@anbraten, @HerHde, @qwerty287, @pat-s, @renovate[bot], @lukashass, @zc-devs, @Alonsohhl, @healdropper, @eliasscosta, @runephilosof-karnovgroup\n\n### ✨ Features\n\n- Add release event [[#3226](https://github.com/woodpecker-ci/woodpecker/pull/3226)]\n\n### 📚 Documentation\n\n- Add release types [[#3303](https://github.com/woodpecker-ci/woodpecker/pull/3303)]\n- Add opencollective footer [[#3281](https://github.com/woodpecker-ci/woodpecker/pull/3281)]\n- Use array syntax in docs [[#3242](https://github.com/woodpecker-ci/woodpecker/pull/3242)]\n\n### 🐛 Bug Fixes\n\n- Fix Gitpod: Gitea auth token creation [[#3299](https://github.com/woodpecker-ci/woodpecker/pull/3299)]\n- Fix agent updating [[#3287](https://github.com/woodpecker-ci/woodpecker/pull/3287)]\n- Sanitize pod's step label [[#3275](https://github.com/woodpecker-ci/woodpecker/pull/3275)]\n- Pipeline errors must be an array [[#3276](https://github.com/woodpecker-ci/woodpecker/pull/3276)]\n- fix bitbucket SSO using UUID from bitbucket api response as ForgeRemoteID [[#3265](https://github.com/woodpecker-ci/woodpecker/pull/3265)]\n- fix: bug pod service without label service [[#3256](https://github.com/woodpecker-ci/woodpecker/pull/3256)]\n- Fix disabling PRs [[#3258](https://github.com/woodpecker-ci/woodpecker/pull/3258)]\n- fix: bug annotations [[#3255](https://github.com/woodpecker-ci/woodpecker/pull/3255)]\n\n### 📈 Enhancement\n\n- Update theme on system color mode change [[#3296](https://github.com/woodpecker-ci/woodpecker/pull/3296)]\n- Improve secrets availability checks [[#3271](https://github.com/woodpecker-ci/woodpecker/pull/3271)]\n- Load more pipeline log lines (500 => 5000) [[#3212](https://github.com/woodpecker-ci/woodpecker/pull/3212)]\n- Clean up models [[#3228](https://github.com/woodpecker-ci/woodpecker/pull/3228)]\n\n### Misc\n\n- chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.21.6 [[#3294](https://github.com/woodpecker-ci/woodpecker/pull/3294)]\n- fix(deps): update docs npm deps non-major [[#3295](https://github.com/woodpecker-ci/woodpecker/pull/3295)]\n- Remove deprecated `group` from config [[#3289](https://github.com/woodpecker-ci/woodpecker/pull/3289)]\n- Add spellcheck config [[#3018](https://github.com/woodpecker-ci/woodpecker/pull/3018)]\n- fix(deps): update golang (packages) [[#3284](https://github.com/woodpecker-ci/woodpecker/pull/3284)]\n- chore(deps): lock file maintenance [[#3274](https://github.com/woodpecker-ci/woodpecker/pull/3274)]\n- chore(deps): update web npm deps non-major [[#3273](https://github.com/woodpecker-ci/woodpecker/pull/3273)]\n- Pin prettier version [[#3260](https://github.com/woodpecker-ci/woodpecker/pull/3260)]\n- Fix prettier [[#3259](https://github.com/woodpecker-ci/woodpecker/pull/3259)]\n- Update UI building in Makefile [[#3250](https://github.com/woodpecker-ci/woodpecker/pull/3250)]\n\n## [2.2.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/2.2.2) - 2024-01-21\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543\n\n### Misc\n\n- build: fix nfpm path for server binary [[#3246](https://github.com/woodpecker-ci/woodpecker/pull/3246)]\n\n## [2.2.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.2.1) - 2024-01-21\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543\n\n### 🐛 Bug Fixes\n\n- Add gitea/forgejo driver check, to handle ErrUnknownVersion error [[#3243](https://github.com/woodpecker-ci/woodpecker/pull/3243)]\n\n### Misc\n\n- Build tarball for distribution packages [[#3244](https://github.com/woodpecker-ci/woodpecker/pull/3244)]\n\n## [2.2.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.2.0) - 2024-01-21\n\n### ❤️ Thanks to all contributors! ❤️\n\n@qwerty287, @zc-devs, @renovate[bot], @mzampetakis, @healdropper, @6543, @micash545, @xoxys, @pat-s, @miry, @lukashass, @lafriks, @pre-commit-ci[bot], @anbraten, @andyhan, @KamilaBorowska\n\n### 🔒 Security\n\n- Update web dependencies [[#3234](https://github.com/woodpecker-ci/woodpecker/pull/3234)]\n\n### ✨ Features\n\n- Support custom steps entrypoint [[#2985](https://github.com/woodpecker-ci/woodpecker/pull/2985)]\n\n### 📚 Documentation\n\n- Add 2.2 docs [[#3237](https://github.com/woodpecker-ci/woodpecker/pull/3237)]\n- Fix/improve issue templates [[#3232](https://github.com/woodpecker-ci/woodpecker/pull/3232)]\n- Delete `FUNDING.yaml` [[#3193](https://github.com/woodpecker-ci/woodpecker/pull/3193)]\n- Remove contributing/security to use globally defined [[#3192](https://github.com/woodpecker-ci/woodpecker/pull/3192)]\n- Add \"Kaniko\" Plugin [[#3183](https://github.com/woodpecker-ci/woodpecker/pull/3183)]\n- Document core development ideas [[#3184](https://github.com/woodpecker-ci/woodpecker/pull/3184)]\n- Add continuous deployment cookbook [[#3098](https://github.com/woodpecker-ci/woodpecker/pull/3098)]\n- Make k8s backend configuration docs in the same format as others [[#3081](https://github.com/woodpecker-ci/woodpecker/pull/3081)]\n- Hide backend config options from TOC [[#3126](https://github.com/woodpecker-ci/woodpecker/pull/3126)]\n- Add X/Twitter account [[#3127](https://github.com/woodpecker-ci/woodpecker/pull/3127)]\n- Add ansible plugin [[#3115](https://github.com/woodpecker-ci/woodpecker/pull/3115)]\n- Format depends_on example [[#3118](https://github.com/woodpecker-ci/woodpecker/pull/3118)]\n- Use WOODPECKER_AGENT_SECRET instead of deprecated alternative [[#3103](https://github.com/woodpecker-ci/woodpecker/pull/3103)]\n- Add Reviewdog ESLint plugin [[#3102](https://github.com/woodpecker-ci/woodpecker/pull/3102)]\n- Mark local backend as stable [[#3088](https://github.com/woodpecker-ci/woodpecker/pull/3088)]\n- Update Owners 2024 [[#3075](https://github.com/woodpecker-ci/woodpecker/pull/3075)]\n- Add reviewdog golangci plugin [[#3080](https://github.com/woodpecker-ci/woodpecker/pull/3080)]\n- Add Codeberg Pages Deploy plugin to plugins list [[#3054](https://github.com/woodpecker-ci/woodpecker/pull/3054)]\n\n### 🐛 Bug Fixes\n\n- Fixed Pods creation of WP services [[#3236](https://github.com/woodpecker-ci/woodpecker/pull/3236)]\n- Fix Bitbucket get pull requests that ignores pagination [[#3235](https://github.com/woodpecker-ci/woodpecker/pull/3235)]\n- Make PipelineConfig unique again [[#3215](https://github.com/woodpecker-ci/woodpecker/pull/3215)]\n- Fix feed sorting [[#3155](https://github.com/woodpecker-ci/woodpecker/pull/3155)]\n- Step status update dont set to running again once it got stoped [[#3151](https://github.com/woodpecker-ci/woodpecker/pull/3151)]\n- Use step uuid instead of name in GRPC status calls [[#3143](https://github.com/woodpecker-ci/woodpecker/pull/3143)]\n- Use UUID instead of step name where possible [[#3136](https://github.com/woodpecker-ci/woodpecker/pull/3136)]\n- Use step type to detect services in Kubernetes backend [[#3141](https://github.com/woodpecker-ci/woodpecker/pull/3141)]\n- Fix config base64 parsing to utf-8 [[#3110](https://github.com/woodpecker-ci/woodpecker/pull/3110)]\n- Pin Gitea version [[#3104](https://github.com/woodpecker-ci/woodpecker/pull/3104)]\n- Fix step `depends_on` as string in schema [[#3099](https://github.com/woodpecker-ci/woodpecker/pull/3099)]\n- Fix slice unmarshaling [[#3097](https://github.com/woodpecker-ci/woodpecker/pull/3097)]\n- Allow PR secrets to be used on close [[#3084](https://github.com/woodpecker-ci/woodpecker/pull/3084)]\n- make event in pipeline schema also a constraint_list [[#3082](https://github.com/woodpecker-ci/woodpecker/pull/3082)]\n- Fix badge's repoUrl with rootpath [[#3076](https://github.com/woodpecker-ci/woodpecker/pull/3076)]\n- Load changed files for closed PR [[#3067](https://github.com/woodpecker-ci/woodpecker/pull/3067)]\n- Fix build output paths [[#3065](https://github.com/woodpecker-ci/woodpecker/pull/3065)]\n- Fix `when` and `depends_on` [[#3063](https://github.com/woodpecker-ci/woodpecker/pull/3063)]\n- Fix DAG cycle detection [[#3049](https://github.com/woodpecker-ci/woodpecker/pull/3049)]\n- Fix duplicated icons [[#3045](https://github.com/woodpecker-ci/woodpecker/pull/3045)]\n\n### 📈 Enhancement\n\n- Retrieve all user repo perms with a single API call [[#3211](https://github.com/woodpecker-ci/woodpecker/pull/3211)]\n- Secured kubernetes backend configuration [[#3204](https://github.com/woodpecker-ci/woodpecker/pull/3204)]\n- Use `assert` for tests [[#3201](https://github.com/woodpecker-ci/woodpecker/pull/3201)]\n- Replace `goimports` with `gci` [[#3202](https://github.com/woodpecker-ci/woodpecker/pull/3202)]\n- Remove multipart logger [[#3200](https://github.com/woodpecker-ci/woodpecker/pull/3200)]\n- Added protocol in port configuration [[#2993](https://github.com/woodpecker-ci/woodpecker/pull/2993)]\n- Kubernetes AppArmor and seccomp [[#3123](https://github.com/woodpecker-ci/woodpecker/pull/3123)]\n- `cli exec`: let override existing environment values but print a warning [[#3140](https://github.com/woodpecker-ci/woodpecker/pull/3140)]\n- Enable golangci linter forcetypeassert [[#3168](https://github.com/woodpecker-ci/woodpecker/pull/3168)]\n- Enable golangci linter contextcheck [[#3170](https://github.com/woodpecker-ci/woodpecker/pull/3170)]\n- Remove panic recovering [[#3162](https://github.com/woodpecker-ci/woodpecker/pull/3162)]\n- More docker backend test remove more undocumented [[#3156](https://github.com/woodpecker-ci/woodpecker/pull/3156)]\n- Lowercase all log strings [[#3173](https://github.com/woodpecker-ci/woodpecker/pull/3173)]\n- Cleanups + prefer .yaml [[#3069](https://github.com/woodpecker-ci/woodpecker/pull/3069)]\n- Use UUID as podName and cleanup arguments for Kubernetes backend [[#3135](https://github.com/woodpecker-ci/woodpecker/pull/3135)]\n- Enable golangci linter stylecheck [[#3167](https://github.com/woodpecker-ci/woodpecker/pull/3167)]\n- Clean up logging [[#3161](https://github.com/woodpecker-ci/woodpecker/pull/3161)]\n- Enable `gocritic` and don't ignore globally [[#3159](https://github.com/woodpecker-ci/woodpecker/pull/3159)]\n- Remove steps for publishing release branches [[#3125](https://github.com/woodpecker-ci/woodpecker/pull/3125)]\n- Enable `nolintlint` [[#3158](https://github.com/woodpecker-ci/woodpecker/pull/3158)]\n- Enable some linters [[#3129](https://github.com/woodpecker-ci/woodpecker/pull/3129)]\n- Use name in backend types instead of alias [[#3142](https://github.com/woodpecker-ci/woodpecker/pull/3142)]\n- Make service icon rotate [[#3149](https://github.com/woodpecker-ci/woodpecker/pull/3149)]\n- Add step name as label to docker containers [[#3137](https://github.com/woodpecker-ci/woodpecker/pull/3137)]\n- Use js-base64 on pipeline log page [[#3146](https://github.com/woodpecker-ci/woodpecker/pull/3146)]\n- Flexible image pull secret reference [[#3016](https://github.com/woodpecker-ci/woodpecker/pull/3016)]\n- Always show pipeline step list [[#3114](https://github.com/woodpecker-ci/woodpecker/pull/3114)]\n- Add loading spinner and no pull request text [[#3113](https://github.com/woodpecker-ci/woodpecker/pull/3113)]\n- Fix timeout settings contrast [[#3112](https://github.com/woodpecker-ci/woodpecker/pull/3112)]\n- Unfold workflow when opening via URL [[#3106](https://github.com/woodpecker-ci/woodpecker/pull/3106)]\n- Remove env argument of addons [[#3100](https://github.com/woodpecker-ci/woodpecker/pull/3100)]\n- Move `cmd/common` to `shared` [[#3092](https://github.com/woodpecker-ci/woodpecker/pull/3092)]\n- use semver for version comparsion [[#3042](https://github.com/woodpecker-ci/woodpecker/pull/3042)]\n- Extend create plugin docs [[#3062](https://github.com/woodpecker-ci/woodpecker/pull/3062)]\n- Remove old files [[#3077](https://github.com/woodpecker-ci/woodpecker/pull/3077)]\n- Indicate if step is service [[#3078](https://github.com/woodpecker-ci/woodpecker/pull/3078)]\n- Add imports checks to linter [[#3056](https://github.com/woodpecker-ci/woodpecker/pull/3056)]\n- Remove workflow version again [[#3052](https://github.com/woodpecker-ci/woodpecker/pull/3052)]\n- Add option to disable version check in admin web UI [[#3040](https://github.com/woodpecker-ci/woodpecker/pull/3040)]\n\n### Misc\n\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3 [[#3229](https://github.com/woodpecker-ci/woodpecker/pull/3229)]\n- Docs: Fix expression syntax docs url [[#3208](https://github.com/woodpecker-ci/woodpecker/pull/3208)]\n- Add schema test for depends_on [[#3205](https://github.com/woodpecker-ci/woodpecker/pull/3205)]\n- chore(deps): lock file maintenance [[#3190](https://github.com/woodpecker-ci/woodpecker/pull/3190)]\n- Do not run prettier with pre-commit [[#3196](https://github.com/woodpecker-ci/woodpecker/pull/3196)]\n- fix(deps): update module github.com/google/go-github/v57 to v58 [[#3187](https://github.com/woodpecker-ci/woodpecker/pull/3187)]\n- chore(deps): update docker.io/golang docker tag to v1.21.6 [[#3189](https://github.com/woodpecker-ci/woodpecker/pull/3189)]\n- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx [[#3186](https://github.com/woodpecker-ci/woodpecker/pull/3186)]\n- fix(deps): update golang (packages) [[#3185](https://github.com/woodpecker-ci/woodpecker/pull/3185)]\n- declare different when statements once and reuse them [[#3176](https://github.com/woodpecker-ci/woodpecker/pull/3176)]\n- Add `make clean-all` [[#3152](https://github.com/woodpecker-ci/woodpecker/pull/3152)]\n- Fix `version.json` updates [[#3057](https://github.com/woodpecker-ci/woodpecker/pull/3057)]\n- [pre-commit.ci] pre-commit autoupdate [[#3101](https://github.com/woodpecker-ci/woodpecker/pull/3101)]\n- Update dependency @vitejs/plugin-vue to v5 [[#3074](https://github.com/woodpecker-ci/woodpecker/pull/3074)]\n- Use CI vars for plugin [[#3061](https://github.com/woodpecker-ci/woodpecker/pull/3061)]\n- Use `yamllint` [[#3066](https://github.com/woodpecker-ci/woodpecker/pull/3066)]\n- Use dag in ci config [[#3010](https://github.com/woodpecker-ci/woodpecker/pull/3010)]\n\n## [2.1.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.1.1) - 2023-12-27\n\n### ❤️ Thanks to all contributors! ❤️\n\n@6543, @andyhan, @qwerty287\n\n### 🐛 Bug Fixes\n\n- trim v on version check [[#3039](https://github.com/woodpecker-ci/woodpecker/pull/3039)]\n- make backend step dag generation deterministic [[#3037](https://github.com/woodpecker-ci/woodpecker/pull/3037)]\n- Fix showing wrong badge url when root path is set [[#3033](https://github.com/woodpecker-ci/woodpecker/pull/3033)]\n- Fix docs label [[#3028](https://github.com/woodpecker-ci/woodpecker/pull/3028)]\n\n### 📚 Documentation\n\n- Update go report card badge [[#3029](https://github.com/woodpecker-ci/woodpecker/pull/3029)]\n\n### Misc\n\n- Add some tests [[#3030](https://github.com/woodpecker-ci/woodpecker/pull/3030)]\n\n## [2.1.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.1.0) - 2023-12-26\n\n### ❤️ Thanks to all contributors! ❤️\n\n@anbraten, @lukashass, @qwerty287, @6543, @Lerentis, @renovate[bot], @zc-devs, @johanvdw, @lafriks, @runephilosof-karnovgroup, @allanger, @xoxys, @gapanyc, @mikhail-putilov, @kaylynb, @voidcontext, @robbie-cahill, @micash545, @dominic-p, @mzampetakis\n\n### ✨ Features\n\n- Add pull request closed event [[#2684](https://github.com/woodpecker-ci/woodpecker/pull/2684)]\n- Add depends_on support for steps [[#2771](https://github.com/woodpecker-ci/woodpecker/pull/2771)]\n- gitlab: support nested repos [[#2981](https://github.com/woodpecker-ci/woodpecker/pull/2981)]\n- Support go plugins for forges and agent backends [[#2751](https://github.com/woodpecker-ci/woodpecker/pull/2751)]\n\n### 📈 Enhancement\n\n- Show default branch on top [[#3019](https://github.com/woodpecker-ci/woodpecker/pull/3019)]\n- Support more addon types [[#2984](https://github.com/woodpecker-ci/woodpecker/pull/2984)]\n- Hide PR tab if PRs are disabled [[#3004](https://github.com/woodpecker-ci/woodpecker/pull/3004)]\n- Switch to ULID [[#2986](https://github.com/woodpecker-ci/woodpecker/pull/2986)]\n- Ignore pipelines without config [[#2949](https://github.com/woodpecker-ci/woodpecker/pull/2949)]\n- Link labels to input and select [[#2974](https://github.com/woodpecker-ci/woodpecker/pull/2974)]\n- Register Agent with hostname [[#2936](https://github.com/woodpecker-ci/woodpecker/pull/2936)]\n- Update slogan & logo [[#2962](https://github.com/woodpecker-ci/woodpecker/pull/2962)]\n- Improve error handling when activating a repository [[#2965](https://github.com/woodpecker-ci/woodpecker/pull/2965)]\n- Add check for storage where repo/org name is empty [[#2968](https://github.com/woodpecker-ci/woodpecker/pull/2968)]\n- Update pipeline icons [[#2783](https://github.com/woodpecker-ci/woodpecker/pull/2783)]\n- Kubernetes refactor [[#2794](https://github.com/woodpecker-ci/woodpecker/pull/2794)]\n- Export changed files via builtin environment variables [[#2935](https://github.com/woodpecker-ci/woodpecker/pull/2935)]\n- Show secrets from org and global level [[#2873](https://github.com/woodpecker-ci/woodpecker/pull/2873)]\n- Only update pipelineStatus in one place [[#2952](https://github.com/woodpecker-ci/woodpecker/pull/2952)]\n- Rename `engine` to `backend` [[#2950](https://github.com/woodpecker-ci/woodpecker/pull/2950)]\n- Add linting for `log.Fatal()` [[#2946](https://github.com/woodpecker-ci/woodpecker/pull/2946)]\n- Remove separate root path config [[#2943](https://github.com/woodpecker-ci/woodpecker/pull/2943)]\n- init CI_COMMIT_TAG if commit ref is a tag [[#2934](https://github.com/woodpecker-ci/woodpecker/pull/2934)]\n- Update go module path for major version 2 [[#2905](https://github.com/woodpecker-ci/woodpecker/pull/2905)]\n- Unify date/time dependencies [[#2891](https://github.com/woodpecker-ci/woodpecker/pull/2891)]\n- Add linting for `any` [[#2893](https://github.com/woodpecker-ci/woodpecker/pull/2893)]\n- Fix vite deprecations [[#2885](https://github.com/woodpecker-ci/woodpecker/pull/2885)]\n- Migrate to Xormigrate [[#2711](https://github.com/woodpecker-ci/woodpecker/pull/2711)]\n- Simple security context options (Kubernetes) [[#2550](https://github.com/woodpecker-ci/woodpecker/pull/2550)]\n- Changes PullRequest Index to ForgeRemoteID type [[#2823](https://github.com/woodpecker-ci/woodpecker/pull/2823)]\n\n### 🐛 Bug Fixes\n\n- Hide queue visualization if nothing to show [[#3003](https://github.com/woodpecker-ci/woodpecker/pull/3003)]\n- fix and lint swagger file [[#3007](https://github.com/woodpecker-ci/woodpecker/pull/3007)]\n- Fix IPv6 host aliases for kubernetes [[#2992](https://github.com/woodpecker-ci/woodpecker/pull/2992)]\n- Fix cli lint throwing error on warnings  [[#2995](https://github.com/woodpecker-ci/woodpecker/pull/2995)]\n- Fix static file caching [[#2975](https://github.com/woodpecker-ci/woodpecker/pull/2975)]\n- Gitea driver: ignore GetOrg error if we get a valid user. [[#2967](https://github.com/woodpecker-ci/woodpecker/pull/2967)]\n- feat(k8s): Add a port name to service definition [[#2933](https://github.com/woodpecker-ci/woodpecker/pull/2933)]\n- Fix error container overflow [[#2957](https://github.com/woodpecker-ci/woodpecker/pull/2957)]\n- ignore some errors on repairAllRepos [[#2792](https://github.com/woodpecker-ci/woodpecker/pull/2792)]\n- Allow to restart pipelines that has warnings [[#2939](https://github.com/woodpecker-ci/woodpecker/pull/2939)]\n- Fix skipped pipelines model [[#2923](https://github.com/woodpecker-ci/woodpecker/pull/2923)]\n- fix: Add `backend_options` to service linter entry [[#2930](https://github.com/woodpecker-ci/woodpecker/pull/2930)]\n- Fix flags added multiple times [[#2914](https://github.com/woodpecker-ci/woodpecker/pull/2914)]\n- Fix schema validation with array syntax for clone and services [[#2920](https://github.com/woodpecker-ci/woodpecker/pull/2920)]\n- Fix prometheus docs [[#2919](https://github.com/woodpecker-ci/woodpecker/pull/2919)]\n- Fix podman agent container in v2 [[#2897](https://github.com/woodpecker-ci/woodpecker/pull/2897)]\n- Fix bitbucket org fetching [[#2874](https://github.com/woodpecker-ci/woodpecker/pull/2874)]\n- Only deploy docs on `main` [[#2892](https://github.com/woodpecker-ci/woodpecker/pull/2892)]\n- Fix pipeline-related environment [[#2876](https://github.com/woodpecker-ci/woodpecker/pull/2876)]\n- Fix version check partially [[#2871](https://github.com/woodpecker-ci/woodpecker/pull/2871)]\n- Fix unregistering agents when using agent tokens [[#2870](https://github.com/woodpecker-ci/woodpecker/pull/2870)]\n\n### 📚 Documentation\n\n- [Awesome Woodpecker] added yet another autoscaler [[#3011](https://github.com/woodpecker-ci/woodpecker/pull/3011)]\n- Add cookbook blog and improve docs [[#3002](https://github.com/woodpecker-ci/woodpecker/pull/3002)]\n- Replace multi-pipelines with workflows on docs frontpage [[#2990](https://github.com/woodpecker-ci/woodpecker/pull/2990)]\n- Update README badges [[#2956](https://github.com/woodpecker-ci/woodpecker/pull/2956)]\n- Update 20-kubernetes.md [[#2927](https://github.com/woodpecker-ci/woodpecker/pull/2927)]\n- Add release documentation to CONTRIBUTING [[#2917](https://github.com/woodpecker-ci/woodpecker/pull/2917)]\n- Add nix-attic plugin to the index [[#2889](https://github.com/woodpecker-ci/woodpecker/pull/2889)]\n- Add usage with Tunnelmole to docs [[#2881](https://github.com/woodpecker-ci/woodpecker/pull/2881)]\n- Improve code blocks in docs [[#2879](https://github.com/woodpecker-ci/woodpecker/pull/2879)]\n- Add a blog post [[#2877](https://github.com/woodpecker-ci/woodpecker/pull/2877)]\n- Add documentation on Kubernetes securityContext [[#2822](https://github.com/woodpecker-ci/woodpecker/pull/2822)]\n- Add default page to categories [[#2869](https://github.com/woodpecker-ci/woodpecker/pull/2869)]\n- Use same format for Github docs as used for the other forges [[#2866](https://github.com/woodpecker-ci/woodpecker/pull/2866)]\n\n### Misc\n\n- chore(deps): update dependency isomorphic-dompurify to v2 [[#3001](https://github.com/woodpecker-ci/woodpecker/pull/3001)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to v2 [[#2998](https://github.com/woodpecker-ci/woodpecker/pull/2998)]\n- Fix go in gitpod [[#2973](https://github.com/woodpecker-ci/woodpecker/pull/2973)]\n- fix(deps): update module google.golang.org/grpc to v1.60.1 [[#2969](https://github.com/woodpecker-ci/woodpecker/pull/2969)]\n- chore(deps): update docker.io/alpine docker tag to v3.19 [[#2970](https://github.com/woodpecker-ci/woodpecker/pull/2970)]\n- Fix broken gated repos [[#2959](https://github.com/woodpecker-ci/woodpecker/pull/2959)]\n- fix(deps): update golang (packages) [[#2958](https://github.com/woodpecker-ci/woodpecker/pull/2958)]\n- Update docker.io/techknowlogick/xgo Docker tag to go-1.21.5 [[#2926](https://github.com/woodpecker-ci/woodpecker/pull/2926)]\n- Update docker.io/golang Docker tag to v1.21.5 [[#2925](https://github.com/woodpecker-ci/woodpecker/pull/2925)]\n- Lock file maintenance [[#2910](https://github.com/woodpecker-ci/woodpecker/pull/2910)]\n- Update web npm deps non-major [[#2909](https://github.com/woodpecker-ci/woodpecker/pull/2909)]\n- Update docs npm deps non-major [[#2908](https://github.com/woodpecker-ci/woodpecker/pull/2908)]\n- Update golang (packages) [[#2904](https://github.com/woodpecker-ci/woodpecker/pull/2904)]\n- Update module github.com/google/go-github/v56 to v57 [[#2899](https://github.com/woodpecker-ci/woodpecker/pull/2899)]\n- Update dependency marked to v11 [[#2898](https://github.com/woodpecker-ci/woodpecker/pull/2898)]\n- Update dependency vite-svg-loader to v5 [[#2837](https://github.com/woodpecker-ci/woodpecker/pull/2837)]\n- Update golang (packages) [[#2894](https://github.com/woodpecker-ci/woodpecker/pull/2894)]\n- Update web npm deps non-major [[#2895](https://github.com/woodpecker-ci/woodpecker/pull/2895)]\n- Update web npm deps non-major [[#2884](https://github.com/woodpecker-ci/woodpecker/pull/2884)]\n- Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v2.2.1 [[#2883](https://github.com/woodpecker-ci/woodpecker/pull/2883)]\n\n## [2.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.0.0) - 2023-11-23\n\n### ❤️ Thanks to all contributors! ❤️\n\n@qwerty287, @anbraten, @6543, @renovate[bot], @pat-s, @zc-devs, @xoxys, @lafriks, @silverwind, @pre-commit-ci[bot], @riczescaran, @J-Ha, @Janik-Haag, @jbiblio, @runephilosof-karnovgroup, @bitethecode, @HamburgerJungeJr, @nitram509, @JohnWalkerx, @OskarsPakers, @Exar04, @dominic-p, @categulario, @mzampetakis, @Timshel, @Denperidge, @tomix1024, @lonix1, @s3lph, @math3vz, @LTek-online, @testwill, @klinux, @pinpox, @hpidcock, @ChewingBever, @azdle, @praneeth-ovckd\n\n### 💥 Breaking changes\n\n- Rename `link` to `url` [[#2812](https://github.com/woodpecker-ci/woodpecker/pull/2812)]\n- Revert to singular CLI args [[#2820](https://github.com/woodpecker-ci/woodpecker/pull/2820)]\n- Use int64 for IDs in woodpecker client lib [[#2703](https://github.com/woodpecker-ci/woodpecker/pull/2703)]\n- Woodpecker-go: Use Feed instead of Activity [[#2690](https://github.com/woodpecker-ci/woodpecker/pull/2690)]\n- Do not sanitzie secrets with 3 or less chars [[#2680](https://github.com/woodpecker-ci/woodpecker/pull/2680)]\n- fix(deps): update docker to v24 [[#2675](https://github.com/woodpecker-ci/woodpecker/pull/2675)]\n- Remove `WOODPECKER_DOCS` config [[#2647](https://github.com/woodpecker-ci/woodpecker/pull/2647)]\n- Remove plugin-only option from secrets [[#2213](https://github.com/woodpecker-ci/woodpecker/pull/2213)]\n- Remove deprecated API paths [[#2639](https://github.com/woodpecker-ci/woodpecker/pull/2639)]\n- Remove SSH backend [[#2635](https://github.com/woodpecker-ci/woodpecker/pull/2635)]\n- Remove deprecated `build` command [[#2602](https://github.com/woodpecker-ci/woodpecker/pull/2602)]\n- Deprecate \"platform\" filter in favour of \"labels\" [[#2181](https://github.com/woodpecker-ci/woodpecker/pull/2181)]\n- Remove useless \"sync\" option from RepoListOpts from the client lib [[#2090](https://github.com/woodpecker-ci/woodpecker/pull/2090)]\n- Drop deprecated built-in environment variables [[#2048](https://github.com/woodpecker-ci/woodpecker/pull/2048)]\n\n### 🔒 Security\n\n- Never log tokens [[#2466](https://github.com/woodpecker-ci/woodpecker/pull/2466)]\n- Check permissions on repo lookup [[#2357](https://github.com/woodpecker-ci/woodpecker/pull/2357)]\n- Change token logging to trace level [[#2247](https://github.com/woodpecker-ci/woodpecker/pull/2247)]\n- Validate webhook before changing any data [[#2221](https://github.com/woodpecker-ci/woodpecker/pull/2221)]\n\n### ✨ Features\n\n- Add version and update notes [[#2722](https://github.com/woodpecker-ci/woodpecker/pull/2722)]\n- Add repos list for admins [[#2347](https://github.com/woodpecker-ci/woodpecker/pull/2347)]\n- Add org list [[#2338](https://github.com/woodpecker-ci/woodpecker/pull/2338)]\n- Add option to configure tolerations in kubernetes backend [[#2249](https://github.com/woodpecker-ci/woodpecker/pull/2249)]\n- Support user secrets [[#2126](https://github.com/woodpecker-ci/woodpecker/pull/2126)]\n- Add opt save global log output to file [[#2115](https://github.com/woodpecker-ci/woodpecker/pull/2115)]\n- Support bitbucket Dir() and support multi-workflows [[#2045](https://github.com/woodpecker-ci/woodpecker/pull/2045)]\n- Add ping command to server to allow container healthchecks [[#2030](https://github.com/woodpecker-ci/woodpecker/pull/2030)]\n\n### 📚 Documentation\n\n- Add 2.0.0 post [[#2864](https://github.com/woodpecker-ci/woodpecker/pull/2864)]\n- Add extend env plugin [[#2847](https://github.com/woodpecker-ci/woodpecker/pull/2847)]\n- mark v1.0.x as unmaintained [[#2818](https://github.com/woodpecker-ci/woodpecker/pull/2818)]\n- Update docs npm deps non-major [[#2799](https://github.com/woodpecker-ci/woodpecker/pull/2799)]\n- Add docs about Gitea on same host and update docker-compose example [[#2752](https://github.com/woodpecker-ci/woodpecker/pull/2752)]\n- Update docusaurus plugin [[#2804](https://github.com/woodpecker-ci/woodpecker/pull/2804)]\n- Mark kubernetes backend as fully supported [[#2756](https://github.com/woodpecker-ci/woodpecker/pull/2756)]\n- Update docusaurus to v3 [[#2732](https://github.com/woodpecker-ci/woodpecker/pull/2732)]\n- Fix the wrong link to the cron job document [[#2740](https://github.com/woodpecker-ci/woodpecker/pull/2740)]\n- Improve secrets documentation [[#2707](https://github.com/woodpecker-ci/woodpecker/pull/2707)]\n- Add woodpecker-lint tool [[#2648](https://github.com/woodpecker-ci/woodpecker/pull/2648)]\n- Add autoscaler docs [[#2631](https://github.com/woodpecker-ci/woodpecker/pull/2631)]\n- Rework setup docs [[#2630](https://github.com/woodpecker-ci/woodpecker/pull/2630)]\n- doc: improve prometheus docs [[#2617](https://github.com/woodpecker-ci/woodpecker/pull/2617)]\n- docs add nixos install instructions [[#2616](https://github.com/woodpecker-ci/woodpecker/pull/2616)]\n- Add prettier plugin [[#2621](https://github.com/woodpecker-ci/woodpecker/pull/2621)]\n- [doc] improve documentation WOODPECKER_SESSION_EXPIRES [[#2603](https://github.com/woodpecker-ci/woodpecker/pull/2603)]\n- Update documentation WRT to recent `$platform` changes [[#2531](https://github.com/woodpecker-ci/woodpecker/pull/2531)]\n- Add plugin \"GitHub release\" [[#2592](https://github.com/woodpecker-ci/woodpecker/pull/2592)]\n- Cleanup docs [[#2478](https://github.com/woodpecker-ci/woodpecker/pull/2478)]\n- Add plugin \"Release helper\" [[#2584](https://github.com/woodpecker-ci/woodpecker/pull/2584)]\n- Add plugin \"Gitea Create Pull Request\" to plugin index [[#2581](https://github.com/woodpecker-ci/woodpecker/pull/2581)]\n- Adjust github scopes and clarify documentation. [[#2578](https://github.com/woodpecker-ci/woodpecker/pull/2578)]\n- Remove redundant definition of webhook form docs [[#2561](https://github.com/woodpecker-ci/woodpecker/pull/2561)]\n- Add notes about CRI-O specific config [[#2546](https://github.com/woodpecker-ci/woodpecker/pull/2546)]\n- Fix incorrect yaml syntax for `ref` in docs [[#2518](https://github.com/woodpecker-ci/woodpecker/pull/2518)]\n- Local image documentation [[#2521](https://github.com/woodpecker-ci/woodpecker/pull/2521)]\n- Adds bitbucket tag support in docs [[#2536](https://github.com/woodpecker-ci/woodpecker/pull/2536)]\n- Fix docs duplicate WOODPECKER_HOST assignment [[#2501](https://github.com/woodpecker-ci/woodpecker/pull/2501)]\n- Update github auth install [[#2499](https://github.com/woodpecker-ci/woodpecker/pull/2499)]\n- Update GH app installation instructions [[#2472](https://github.com/woodpecker-ci/woodpecker/pull/2472)]\n- Add videos [[#2465](https://github.com/woodpecker-ci/woodpecker/pull/2465)]\n- docs: missing info for runs_on [[#2457](https://github.com/woodpecker-ci/woodpecker/pull/2457)]\n- Add hint about alternative pipeline skip syntax [[#2443](https://github.com/woodpecker-ci/woodpecker/pull/2443)]\n- Fix typo in GitLab docs [[#2376](https://github.com/woodpecker-ci/woodpecker/pull/2376)]\n- clarify setup with gitlab with RFC1918 nets and non standard TLDs [[#2363](https://github.com/woodpecker-ci/woodpecker/pull/2363)]\n- Clarify env var `CI` in docs [[#2349](https://github.com/woodpecker-ci/woodpecker/pull/2349)]\n- docs: yaml cheatsheet for advanced syntax [[#2329](https://github.com/woodpecker-ci/woodpecker/pull/2329)]\n- Improve explanation for globs in when:path [[#2252](https://github.com/woodpecker-ci/woodpecker/pull/2252)]\n- Fix usage description for backend-http-proxy flag [[#2250](https://github.com/woodpecker-ci/woodpecker/pull/2250)]\n- Restructure k8s documentation [[#2193](https://github.com/woodpecker-ci/woodpecker/pull/2193)]\n- Update list of \"projects using Woodpecker\" [[#2196](https://github.com/woodpecker-ci/woodpecker/pull/2196)]\n- Update 92-awesome.md [[#2195](https://github.com/woodpecker-ci/woodpecker/pull/2195)]\n- Better blog title/desc [[#2182](https://github.com/woodpecker-ci/woodpecker/pull/2182)]\n- Fix version in FAQ [[#2101](https://github.com/woodpecker-ci/woodpecker/pull/2101)]\n- Add blog posts/tutorials [[#2095](https://github.com/woodpecker-ci/woodpecker/pull/2095)]\n- update version docs about versioning [[#2086](https://github.com/woodpecker-ci/woodpecker/pull/2086)]\n- Fix client example [[#2085](https://github.com/woodpecker-ci/woodpecker/pull/2085)]\n- Update docs deps to address cves [[#2080](https://github.com/woodpecker-ci/woodpecker/pull/2080)]\n- fix: global registry docs [[#2070](https://github.com/woodpecker-ci/woodpecker/pull/2070)]\n- Improve bitbucket docs [[#2066](https://github.com/woodpecker-ci/woodpecker/pull/2066)]\n- update docs about versioning [[#2043](https://github.com/woodpecker-ci/woodpecker/pull/2043)]\n- Set v1.0 documents as default and mark v0.15 as unmaintained [[#2034](https://github.com/woodpecker-ci/woodpecker/pull/2034)]\n\n### 📈 Enhancement\n\n- Cleanup plugins index [[#2856](https://github.com/woodpecker-ci/woodpecker/pull/2856)]\n- Bump default clone image version to 2.4.0 [[#2852](https://github.com/woodpecker-ci/woodpecker/pull/2852)]\n- Signal to clients the hook and event routes where removed [[#2826](https://github.com/woodpecker-ci/woodpecker/pull/2826)]\n- Replace `interface{}` with `any` [[#2807](https://github.com/woodpecker-ci/woodpecker/pull/2807)]\n- Fix repo owner filter [[#2808](https://github.com/woodpecker-ci/woodpecker/pull/2808)]\n- Sort agents list by ID [[#2795](https://github.com/woodpecker-ci/woodpecker/pull/2795)]\n- Fix css loading order in head [[#2785](https://github.com/woodpecker-ci/woodpecker/pull/2785)]\n- Fix error color contrast in dark theme [[#2778](https://github.com/woodpecker-ci/woodpecker/pull/2778)]\n- Replace linter icons to match theme [[#2765](https://github.com/woodpecker-ci/woodpecker/pull/2765)]\n- Switch to go vanity urls [[#2706](https://github.com/woodpecker-ci/woodpecker/pull/2706)]\n- Add workflow version [[#2476](https://github.com/woodpecker-ci/woodpecker/pull/2476)]\n- UI enhancements/fixes [[#2754](https://github.com/woodpecker-ci/woodpecker/pull/2754)]\n- Fail on missing secrets [[#2749](https://github.com/woodpecker-ci/woodpecker/pull/2749)]\n- Add deprecation warnings [[#2725](https://github.com/woodpecker-ci/woodpecker/pull/2725)]\n- Enhance linter and errors [[#1572](https://github.com/woodpecker-ci/woodpecker/pull/1572)]\n- Option to change temp dir for local backend [[#2702](https://github.com/woodpecker-ci/woodpecker/pull/2702)]\n- Revert breaking pipeline changes [[#2677](https://github.com/woodpecker-ci/woodpecker/pull/2677)]\n- Add ports into pipeline backend step model [[#2656](https://github.com/woodpecker-ci/woodpecker/pull/2656)]\n- Unregister stateless agents from server on termination [[#2606](https://github.com/woodpecker-ci/woodpecker/pull/2606)]\n- Let the backend engine report the current platform [[#2688](https://github.com/woodpecker-ci/woodpecker/pull/2688)]\n- Showing the pending pipelines on top [[#1488](https://github.com/woodpecker-ci/woodpecker/pull/1488)]\n- Print local backend command logs [[#2678](https://github.com/woodpecker-ci/woodpecker/pull/2678)]\n- Report problems with listening to ports and exit [[#2102](https://github.com/woodpecker-ci/woodpecker/pull/2102)]\n- Use path.Join for server side path generation [[#2689](https://github.com/woodpecker-ci/woodpecker/pull/2689)]\n- Refactor UI dark/bright mode [[#2590](https://github.com/woodpecker-ci/woodpecker/pull/2590)]\n- Stop steps after they are done [[#2681](https://github.com/woodpecker-ci/woodpecker/pull/2681)]\n- Fix where syntax [[#2676](https://github.com/woodpecker-ci/woodpecker/pull/2676)]\n- Add \"Repair all\" button [[#2642](https://github.com/woodpecker-ci/woodpecker/pull/2642)]\n- Use pagination utils [[#2633](https://github.com/woodpecker-ci/woodpecker/pull/2633)]\n- Dynamic forge request size [[#2622](https://github.com/woodpecker-ci/woodpecker/pull/2622)]\n- Update to docker 23 [[#2577](https://github.com/woodpecker-ci/woodpecker/pull/2577)]\n- Refactor/simplify pubsub [[#2554](https://github.com/woodpecker-ci/woodpecker/pull/2554)]\n- Refactor pipeline parsing and forge refreshing [[#2527](https://github.com/woodpecker-ci/woodpecker/pull/2527)]\n- Fix gitlab hooks and simplify config extension [[#2537](https://github.com/woodpecker-ci/woodpecker/pull/2537)]\n- Set home variable in local backend for windows  [[#2323](https://github.com/woodpecker-ci/woodpecker/pull/2323)]\n- Some cleanups about host config [[#2490](https://github.com/woodpecker-ci/woodpecker/pull/2490)]\n- Fix usage of WOODPECKER_ROOT_PATH [[#2485](https://github.com/woodpecker-ci/woodpecker/pull/2485)]\n- Some UI enhancement [[#2468](https://github.com/woodpecker-ci/woodpecker/pull/2468)]\n- Harmonize pipeline status information and add a review link to the approval [[#2345](https://github.com/woodpecker-ci/woodpecker/pull/2345)]\n- Add Renovate [[#2360](https://github.com/woodpecker-ci/woodpecker/pull/2360)]\n- Add option to render button as link [[#2378](https://github.com/woodpecker-ci/woodpecker/pull/2378)]\n- Close sidebar on outside clicks [[#2325](https://github.com/woodpecker-ci/woodpecker/pull/2325)]\n- Add release helper [[#1976](https://github.com/woodpecker-ci/woodpecker/pull/1976)]\n- Use API error helpers and improve response codes [[#2366](https://github.com/woodpecker-ci/woodpecker/pull/2366)]\n- Import packages only once [[#2362](https://github.com/woodpecker-ci/woodpecker/pull/2362)]\n- Execute `make generate` with new versions [[#2365](https://github.com/woodpecker-ci/woodpecker/pull/2365)]\n- Only show commit title [[#2361](https://github.com/woodpecker-ci/woodpecker/pull/2361)]\n- Truncate commit message in pipeline log view header [[#2356](https://github.com/woodpecker-ci/woodpecker/pull/2356)]\n- Increase header padding again [[#2348](https://github.com/woodpecker-ci/woodpecker/pull/2348)]\n- Use full width header on pipeline view and show repo name [[#2327](https://github.com/woodpecker-ci/woodpecker/pull/2327)]\n- Use html list for changed files list [[#2346](https://github.com/woodpecker-ci/woodpecker/pull/2346)]\n- Show that repo is disabled [[#2340](https://github.com/woodpecker-ci/woodpecker/pull/2340)]\n- Add spacing to pipeline feed spinner [[#2326](https://github.com/woodpecker-ci/woodpecker/pull/2326)]\n- Autodetect host platform in Makefile [[#2322](https://github.com/woodpecker-ci/woodpecker/pull/2322)]\n- Add \"plugin\" support to local backend [[#2239](https://github.com/woodpecker-ci/woodpecker/pull/2239)]\n- Rename grpc pipeline to workflow [[#2173](https://github.com/woodpecker-ci/woodpecker/pull/2173)]\n- Pass netrc data to external config service request [[#2310](https://github.com/woodpecker-ci/woodpecker/pull/2310)]\n- Create settings-panel vue component and use InputFields [[#2177](https://github.com/woodpecker-ci/woodpecker/pull/2177)]\n- Use browser-native tooltips [[#2189](https://github.com/woodpecker-ci/woodpecker/pull/2189)]\n- Improve agent rpc retry logic with exponential backoff [[#2205](https://github.com/woodpecker-ci/woodpecker/pull/2205)]\n- Skip settings proxy config with WithProxy if its empty [[#2242](https://github.com/woodpecker-ci/woodpecker/pull/2242)]\n- Move hook and events-stream routes to use `/api` prefix [[#2212](https://github.com/woodpecker-ci/woodpecker/pull/2212)]\n- Add SSH clone URL env var [[#2198](https://github.com/woodpecker-ci/woodpecker/pull/2198)]\n- Small improvements to mobile interface [[#2202](https://github.com/woodpecker-ci/woodpecker/pull/2202)]\n- Switch to upstream ttlcache [[#2187](https://github.com/woodpecker-ci/woodpecker/pull/2187)]\n- Convert EqualStringSlice to generic EqualSliceValues [[#2179](https://github.com/woodpecker-ci/woodpecker/pull/2179)]\n- Pass netrc to trusted clone images [[#2163](https://github.com/woodpecker-ci/woodpecker/pull/2163)]\n- Use Vue setup directive [[#2165](https://github.com/woodpecker-ci/woodpecker/pull/2165)]\n- Release file lock on USR1 signal [[#2151](https://github.com/woodpecker-ci/woodpecker/pull/2151)]\n- Use min/max width for pipeline step list [[#2141](https://github.com/woodpecker-ci/woodpecker/pull/2141)]\n- Add header to pipeline log and always show buttons [[#2140](https://github.com/woodpecker-ci/woodpecker/pull/2140)]\n- Use fix width for pipeline step list [[#2138](https://github.com/woodpecker-ci/woodpecker/pull/2138)]\n- Make sure we dont have hidden options for backend and pipeline compiler [[#2123](https://github.com/woodpecker-ci/woodpecker/pull/2123)]\n- Enhance local backend [[#2017](https://github.com/woodpecker-ci/woodpecker/pull/2017)]\n- Don't show badge without information [[#2130](https://github.com/woodpecker-ci/woodpecker/pull/2130)]\n- CLI repo sync: Show `forge-remote-id` [[#2103](https://github.com/woodpecker-ci/woodpecker/pull/2103)]\n- Lazy-load TimeAgo locales [[#2094](https://github.com/woodpecker-ci/woodpecker/pull/2094)]\n- Improve user settings [[#2087](https://github.com/woodpecker-ci/woodpecker/pull/2087)]\n- Allow to disable swagger [[#2093](https://github.com/woodpecker-ci/woodpecker/pull/2093)]\n- Use consistent woodpecker color scheme [[#2003](https://github.com/woodpecker-ci/woodpecker/pull/2003)]\n- Change master to main [[#2044](https://github.com/woodpecker-ci/woodpecker/pull/2044)]\n- Remove default branch fallbacks [[#2065](https://github.com/woodpecker-ci/woodpecker/pull/2065)]\n- Remove fallback check for old sqlite file location [[#2046](https://github.com/woodpecker-ci/woodpecker/pull/2046)]\n- Include the function name in generic datastore errors  [[#2041](https://github.com/woodpecker-ci/woodpecker/pull/2041)]\n\n### 🐛 Bug Fixes\n\n- Fix plugin URLs [[#2850](https://github.com/woodpecker-ci/woodpecker/pull/2850)]\n- Fix env vars and add UI url [[#2811](https://github.com/woodpecker-ci/woodpecker/pull/2811)]\n- Fix paths for version check [[#2816](https://github.com/woodpecker-ci/woodpecker/pull/2816)]\n- Add `privileged` schema definition [[#2777](https://github.com/woodpecker-ci/woodpecker/pull/2777)]\n- Use unique label selector for pod label for kubernetes services [[#2723](https://github.com/woodpecker-ci/woodpecker/pull/2723)]\n- Some UI fixes [[#2698](https://github.com/woodpecker-ci/woodpecker/pull/2698)]\n- Fix active tab not updating on prop change [[#2712](https://github.com/woodpecker-ci/woodpecker/pull/2712)]\n- Unique status for matrix  [[#2695](https://github.com/woodpecker-ci/woodpecker/pull/2695)]\n- Fix secret image filter regex [[#2674](https://github.com/woodpecker-ci/woodpecker/pull/2674)]\n- local backend ignore errors in commands in between [[#2636](https://github.com/woodpecker-ci/woodpecker/pull/2636)]\n- Do not print log level on CLI [[#2638](https://github.com/woodpecker-ci/woodpecker/pull/2638)]\n- Fix error when closing logs [[#2637](https://github.com/woodpecker-ci/woodpecker/pull/2637)]\n- Fix `CI_WORKSPACE` in local backend [[#2627](https://github.com/woodpecker-ci/woodpecker/pull/2627)]\n- Some mobile UI fixes [[#2624](https://github.com/woodpecker-ci/woodpecker/pull/2624)]\n- Fix secret priority [[#2599](https://github.com/woodpecker-ci/woodpecker/pull/2599)]\n- UI cleanups and improvements [[#2548](https://github.com/woodpecker-ci/woodpecker/pull/2548)]\n- Fix PR event trigger and list for bitbucket repos [[#2539](https://github.com/woodpecker-ci/woodpecker/pull/2539)]\n- Fix ccmenu endpoint [[#2543](https://github.com/woodpecker-ci/woodpecker/pull/2543)]\n- Trim last \"/\" from WOODPECKER_HOST config [[#2538](https://github.com/woodpecker-ci/woodpecker/pull/2538)]\n- Use correct mime type when no content is sent [[#2515](https://github.com/woodpecker-ci/woodpecker/pull/2515)]\n- Fix bitbucket branches pagination. [[#2509](https://github.com/woodpecker-ci/woodpecker/pull/2509)]\n- fix: change config.config_data column type to longblob in mysql [[#2434](https://github.com/woodpecker-ci/woodpecker/pull/2434)]\n- Fix: change tasks.task_data column type to longblob in mysql [[#2418](https://github.com/woodpecker-ci/woodpecker/pull/2418)]\n- Do not list archived repos for all forges [[#2374](https://github.com/woodpecker-ci/woodpecker/pull/2374)]\n- fix(server/api/repo): Fix repair webhook host [[#2372](https://github.com/woodpecker-ci/woodpecker/pull/2372)]\n- Delete repos/secrets on org deletion [[#2367](https://github.com/woodpecker-ci/woodpecker/pull/2367)]\n- Fix org fetching [[#2343](https://github.com/woodpecker-ci/woodpecker/pull/2343)]\n- Show correct event in pipeline step list [[#2334](https://github.com/woodpecker-ci/woodpecker/pull/2334)]\n- Add min height to mobile pipeline view and fix overflow [[#2335](https://github.com/woodpecker-ci/woodpecker/pull/2335)]\n- Fix grid column size in pipeline log view [[#2336](https://github.com/woodpecker-ci/woodpecker/pull/2336)]\n- Fix mobile login view [[#2332](https://github.com/woodpecker-ci/woodpecker/pull/2332)]\n- Fix button loading spinner when activating repos [[#2333](https://github.com/woodpecker-ci/woodpecker/pull/2333)]\n- make WOODPECKER_MIGRATIONS_ALLOW_LONG have an actuall effect [[#2251](https://github.com/woodpecker-ci/woodpecker/pull/2251)]\n- Docker build dont ignore ci env vars [[#2238](https://github.com/woodpecker-ci/woodpecker/pull/2238)]\n- Handle parsed hooks that should be ignored [[#2243](https://github.com/woodpecker-ci/woodpecker/pull/2243)]\n- Set correct version for release branch releases [[#2227](https://github.com/woodpecker-ci/woodpecker/pull/2227)]\n- Bump default git clone plugin [[#2215](https://github.com/woodpecker-ci/woodpecker/pull/2215)]\n- Show all steps [[#2190](https://github.com/woodpecker-ci/woodpecker/pull/2190)]\n- Fix pipeline config collapsing [[#2166](https://github.com/woodpecker-ci/woodpecker/pull/2166)]\n- Fix 'add-orgs' migration [[#2117](https://github.com/woodpecker-ci/woodpecker/pull/2117)]\n- docs: Environment Variable Seems to be `DOCKER_HOST`, not `DOCKER_SOCK` [[#2122](https://github.com/woodpecker-ci/woodpecker/pull/2122)]\n- Fix swagger response code [[#2119](https://github.com/woodpecker-ci/woodpecker/pull/2119)]\n- Forge Github Org: Use `login` instead of `name` [[#2104](https://github.com/woodpecker-ci/woodpecker/pull/2104)]\n- client.go: Fix RepoPost path [[#2091](https://github.com/woodpecker-ci/woodpecker/pull/2091)]\n- Fix alt text contrast in code boxes [[#2089](https://github.com/woodpecker-ci/woodpecker/pull/2089)]\n- Fix WOODPECKER_GRPC_VERIFY being ignored [[#2077](https://github.com/woodpecker-ci/woodpecker/pull/2077)]\n- Handle case where there is no latest pipeline for GetBadge [[#2042](https://github.com/woodpecker-ci/woodpecker/pull/2042)]\n\n### Misc\n\n- Update release-helper [[#2863](https://github.com/woodpecker-ci/woodpecker/pull/2863)]\n- Add repo owner test [[#2857](https://github.com/woodpecker-ci/woodpecker/pull/2857)]\n- Update woodpeckerci/plugin-ready-release-go Docker tag to v1.0.2 [[#2853](https://github.com/woodpecker-ci/woodpecker/pull/2853)]\n- Update golang (packages) [[#2839](https://github.com/woodpecker-ci/woodpecker/pull/2839)]\n- Update dependency vite to v5 [[#2836](https://github.com/woodpecker-ci/woodpecker/pull/2836)]\n- Lock file maintenance [[#2840](https://github.com/woodpecker-ci/woodpecker/pull/2840)]\n- Update postgres Docker tag to v16.1 [[#2842](https://github.com/woodpecker-ci/woodpecker/pull/2842)]\n- Update docker.io/golang Docker tag to v1.21.4 [[#2828](https://github.com/woodpecker-ci/woodpecker/pull/2828)]\n- Update docker.io/techknowlogick/xgo Docker tag to go-1.21.4 [[#2829](https://github.com/woodpecker-ci/woodpecker/pull/2829)]\n- Update golang (packages) [[#2815](https://github.com/woodpecker-ci/woodpecker/pull/2815)]\n- Update dependency marked to v10 [[#2810](https://github.com/woodpecker-ci/woodpecker/pull/2810)]\n- Update release-helper [[#2801](https://github.com/woodpecker-ci/woodpecker/pull/2801)]\n- Remove go versions from .golangci.yml [[#2775](https://github.com/woodpecker-ci/woodpecker/pull/2775)]\n- [pre-commit.ci] pre-commit autoupdate [[#2767](https://github.com/woodpecker-ci/woodpecker/pull/2767)]\n- Lock file maintenance [[#2755](https://github.com/woodpecker-ci/woodpecker/pull/2755)]\n- Update golang (packages) [[#2742](https://github.com/woodpecker-ci/woodpecker/pull/2742)]\n- Update woodpeckerci/plugin-ready-release-go Docker tag to v0.7.0 [[#2728](https://github.com/woodpecker-ci/woodpecker/pull/2728)]\n- Add grafana dashobard to awesome [[#2710](https://github.com/woodpecker-ci/woodpecker/pull/2710)]\n- Pin alpine versions in Dockerfile [[#2649](https://github.com/woodpecker-ci/woodpecker/pull/2649)]\n- Use full qualifyer for images [[#2692](https://github.com/woodpecker-ci/woodpecker/pull/2692)]\n- chore(deps): lock file maintenance [[#2673](https://github.com/woodpecker-ci/woodpecker/pull/2673)]\n- fix(deps): update golang (packages) [[#2671](https://github.com/woodpecker-ci/woodpecker/pull/2671)]\n- Use `pre-commit`  [[#2650](https://github.com/woodpecker-ci/woodpecker/pull/2650)]\n- fix(deps): update dependency fuse.js to v7 [[#2666](https://github.com/woodpecker-ci/woodpecker/pull/2666)]\n- chore(deps): update dependency @types/node to v20 [[#2664](https://github.com/woodpecker-ci/woodpecker/pull/2664)]\n- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v2.2.0 [[#2663](https://github.com/woodpecker-ci/woodpecker/pull/2663)]\n- chore(deps): update mysql docker tag to v8.2.0 [[#2662](https://github.com/woodpecker-ci/woodpecker/pull/2662)]\n- Add some tests [[#2652](https://github.com/woodpecker-ci/woodpecker/pull/2652)]\n- chore(deps): update docs npm deps non-major [[#2660](https://github.com/woodpecker-ci/woodpecker/pull/2660)]\n- chore(deps): update web npm deps non-major [[#2661](https://github.com/woodpecker-ci/woodpecker/pull/2661)]\n- Fix codecov plugin version [[#2643](https://github.com/woodpecker-ci/woodpecker/pull/2643)]\n- Add prettier [[#2600](https://github.com/woodpecker-ci/woodpecker/pull/2600)]\n- Do not run docker prepare steps [[#2626](https://github.com/woodpecker-ci/woodpecker/pull/2626)]\n- Fix docker workflow and only run if needed [[#2625](https://github.com/woodpecker-ci/woodpecker/pull/2625)]\n- fix(deps): update golang (packages) [[#2614](https://github.com/woodpecker-ci/woodpecker/pull/2614)]\n- chore(deps): lock file maintenance [[#2620](https://github.com/woodpecker-ci/woodpecker/pull/2620)]\n- chore(deps): update codeberg.org/woodpecker-plugins/trivy docker tag to v1.0.1 [[#2618](https://github.com/woodpecker-ci/woodpecker/pull/2618)]\n- chore(deps): update node.js to v21 [[#2615](https://github.com/woodpecker-ci/woodpecker/pull/2615)]\n- Only publish PR images when label is set [[#2608](https://github.com/woodpecker-ci/woodpecker/pull/2608)]\n- chore(deps): lock file maintenance [[#2595](https://github.com/woodpecker-ci/woodpecker/pull/2595)]\n- chore(deps): update postgres docker tag to v16 [[#2588](https://github.com/woodpecker-ci/woodpecker/pull/2588)]\n- Update renovate schedule & use central config repo [[#2597](https://github.com/woodpecker-ci/woodpecker/pull/2597)]\n- chore(deps): update woodpeckerci/plugin-surge-preview docker tag to v1.2.2 [[#2593](https://github.com/woodpecker-ci/woodpecker/pull/2593)]\n- Update README badge link [[#2596](https://github.com/woodpecker-ci/woodpecker/pull/2596)]\n- fix(deps): update golang (packages) to v23.0.7+incompatible [[#2586](https://github.com/woodpecker-ci/woodpecker/pull/2586)]\n- Fix missing web dist [[#2580](https://github.com/woodpecker-ci/woodpecker/pull/2580)]\n- Run tests on `main` branch [[#2576](https://github.com/woodpecker-ci/woodpecker/pull/2576)]\n- fix(deps): update module github.com/google/go-github/v55 to v56 [[#2573](https://github.com/woodpecker-ci/woodpecker/pull/2573)]\n- Add plugin \"NixOS Remote Builder\" to plugin index [[#2571](https://github.com/woodpecker-ci/woodpecker/pull/2571)]\n- Fix renovate [[#2569](https://github.com/woodpecker-ci/woodpecker/pull/2569)]\n- renovate: add `golang` group [[#2567](https://github.com/woodpecker-ci/woodpecker/pull/2567)]\n- chore(deps): update golang docker tag to v1.21.3 [[#2564](https://github.com/woodpecker-ci/woodpecker/pull/2564)]\n- chore(deps): update techknowlogick/xgo docker tag to go-1.21.3 [[#2565](https://github.com/woodpecker-ci/woodpecker/pull/2565)]\n- fix(deps): update golang deps non-major [[#2566](https://github.com/woodpecker-ci/woodpecker/pull/2566)]\n- chore(deps): update mstruebing/editorconfig-checker docker tag to v2.7.2 [[#2563](https://github.com/woodpecker-ci/woodpecker/pull/2563)]\n- Bump to mysql 8 [[#2559](https://github.com/woodpecker-ci/woodpecker/pull/2559)]\n- fix(deps): update module github.com/xanzy/go-gitlab to v0.93.1 [[#2560](https://github.com/woodpecker-ci/woodpecker/pull/2560)]\n- Require Go 1.21 [[#2553](https://github.com/woodpecker-ci/woodpecker/pull/2553)]\n- chore(deps): update techknowlogick/xgo docker tag to go-1.21.2 [[#2523](https://github.com/woodpecker-ci/woodpecker/pull/2523)]\n- Update issue config [[#2353](https://github.com/woodpecker-ci/woodpecker/pull/2353)]\n- Add test for handling pipeline error [[#2547](https://github.com/woodpecker-ci/woodpecker/pull/2547)]\n- chore(deps): update golang docker tag to v1.21.2 [[#2532](https://github.com/woodpecker-ci/woodpecker/pull/2532)]\n- fix(deps): update golang.org/x/exp digest to 7918f67 [[#2535](https://github.com/woodpecker-ci/woodpecker/pull/2535)]\n- fix(deps): update golang deps non-major [[#2533](https://github.com/woodpecker-ci/woodpecker/pull/2533)]\n- fix(deps): update golang.org/x/exp digest to 3e424a5 [[#2530](https://github.com/woodpecker-ci/woodpecker/pull/2530)]\n- Use golangci-lint to lint zerolog [[#2524](https://github.com/woodpecker-ci/woodpecker/pull/2524)]\n- Renovate config updates [[#2519](https://github.com/woodpecker-ci/woodpecker/pull/2519)]\n- fix(deps): update module github.com/docker/distribution to v2.8.3+incompatible [[#2517](https://github.com/woodpecker-ci/woodpecker/pull/2517)]\n- fix(deps): update module github.com/melbahja/goph to v1.4.0 [[#2513](https://github.com/woodpecker-ci/woodpecker/pull/2513)]\n- fix(deps): update golang deps non-major [[#2500](https://github.com/woodpecker-ci/woodpecker/pull/2500)]\n- chore(deps): lock file maintenance [[#2497](https://github.com/woodpecker-ci/woodpecker/pull/2497)]\n- Fix broken link to 3rd party plugin library [[#2494](https://github.com/woodpecker-ci/woodpecker/pull/2494)]\n- fix(deps): update golang deps non-major [[#2486](https://github.com/woodpecker-ci/woodpecker/pull/2486)]\n- chore(deps): lock file maintenance [[#2469](https://github.com/woodpecker-ci/woodpecker/pull/2469)]\n- Add devx lable to compose file PRs [[#2467](https://github.com/woodpecker-ci/woodpecker/pull/2467)]\n- chore(deps): update postgres docker tag to v16 [[#2463](https://github.com/woodpecker-ci/woodpecker/pull/2463)]\n- Update gitea sdk [[#2464](https://github.com/woodpecker-ci/woodpecker/pull/2464)]\n- fix(deps): update golang deps non-major [[#2462](https://github.com/woodpecker-ci/woodpecker/pull/2462)]\n- fix(deps): update dependency ansi_up to v6 [[#2431](https://github.com/woodpecker-ci/woodpecker/pull/2431)]\n- chore(deps): update web npm deps non-major [[#2461](https://github.com/woodpecker-ci/woodpecker/pull/2461)]\n- fix(deps): update module github.com/tevino/abool to v2 [[#2460](https://github.com/woodpecker-ci/woodpecker/pull/2460)]\n- fix(deps): update module github.com/google/go-github/v39 to v55 [[#2456](https://github.com/woodpecker-ci/woodpecker/pull/2456)]\n- fix(deps): update module github.com/golang-jwt/jwt/v4 to v5 [[#2449](https://github.com/woodpecker-ci/woodpecker/pull/2449)]\n- fix(deps): update module github.com/golang-jwt/jwt/v4 to v5 [[#2447](https://github.com/woodpecker-ci/woodpecker/pull/2447)]\n- chore(deps): update node.js to v20 [[#2422](https://github.com/woodpecker-ci/woodpecker/pull/2422)]\n- Add renovate package rule to apply build label [[#2440](https://github.com/woodpecker-ci/woodpecker/pull/2440)]\n- fix(deps): update dependency prism-react-renderer to v2 [[#2436](https://github.com/woodpecker-ci/woodpecker/pull/2436)]\n- fix(deps): update dependency node-emoji to v2 [[#2435](https://github.com/woodpecker-ci/woodpecker/pull/2435)]\n- Add renovate package rule to apply dependencies label [[#2438](https://github.com/woodpecker-ci/woodpecker/pull/2438)]\n- fix(deps): update golang deps non-major [[#2437](https://github.com/woodpecker-ci/woodpecker/pull/2437)]\n- chore(deps): update postgres docker tag to v15 [[#2423](https://github.com/woodpecker-ci/woodpecker/pull/2423)]\n- fix(deps): update dependency esbuild-loader to v4 [[#2433](https://github.com/woodpecker-ci/woodpecker/pull/2433)]\n- fix(deps): update dependency clsx to v2 [[#2432](https://github.com/woodpecker-ci/woodpecker/pull/2432)]\n- fix(deps): update dependency @vueuse/core to v10 [[#2430](https://github.com/woodpecker-ci/woodpecker/pull/2430)]\n- fix(deps): update dependency @svgr/webpack to v8 [[#2429](https://github.com/woodpecker-ci/woodpecker/pull/2429)]\n- fix(deps): update dependency @kyvg/vue3-notification to v3 [[#2427](https://github.com/woodpecker-ci/woodpecker/pull/2427)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to v1 [[#2426](https://github.com/woodpecker-ci/woodpecker/pull/2426)]\n- chore(deps): update typescript-eslint monorepo to v6 (major) [[#2425](https://github.com/woodpecker-ci/woodpecker/pull/2425)]\n- chore(deps): update react monorepo to v18 (major) [[#2424](https://github.com/woodpecker-ci/woodpecker/pull/2424)]\n- chore(deps): update dependency prettier to v3 [[#2420](https://github.com/woodpecker-ci/woodpecker/pull/2420)]\n- chore(deps): update dependency eslint-config-prettier to v9 [[#2415](https://github.com/woodpecker-ci/woodpecker/pull/2415)]\n- chore(deps): update dependency @tsconfig/docusaurus to v2 [[#2410](https://github.com/woodpecker-ci/woodpecker/pull/2410)]\n- chore(deps): update dependency typescript to v5 [[#2421](https://github.com/woodpecker-ci/woodpecker/pull/2421)]\n- chore(deps): update dependency concurrently to v8 [[#2414](https://github.com/woodpecker-ci/woodpecker/pull/2414)]\n- Add renovate deps groups [[#2417](https://github.com/woodpecker-ci/woodpecker/pull/2417)]\n- fix(deps): update module xorm.io/xorm to v1.3.3 [[#2393](https://github.com/woodpecker-ci/woodpecker/pull/2393)]\n- chore(deps): update dependency marked to v9 [[#2419](https://github.com/woodpecker-ci/woodpecker/pull/2419)]\n- chore(deps): update dependency @types/marked to v5 [[#2411](https://github.com/woodpecker-ci/woodpecker/pull/2411)]\n- fix(deps): update module github.com/rs/zerolog to v1.30.0 [[#2404](https://github.com/woodpecker-ci/woodpecker/pull/2404)]\n- fix(deps): update module github.com/jellydator/ttlcache/v3 to v3.1.0 [[#2402](https://github.com/woodpecker-ci/woodpecker/pull/2402)]\n- fix(deps): update module github.com/xanzy/go-gitlab to v0.91.1 [[#2405](https://github.com/woodpecker-ci/woodpecker/pull/2405)]\n- fix(deps): update module github.com/antonmedv/expr to v1.15.1 [[#2400](https://github.com/woodpecker-ci/woodpecker/pull/2400)]\n- chore(deps): update dependency axios to v1 [[#2413](https://github.com/woodpecker-ci/woodpecker/pull/2413)]\n- fix(deps): update module github.com/prometheus/client_golang to v1.16.0 [[#2403](https://github.com/woodpecker-ci/woodpecker/pull/2403)]\n- fix(deps): update module github.com/urfave/cli/v2 to v2.25.7 [[#2391](https://github.com/woodpecker-ci/woodpecker/pull/2391)]\n- fix(deps): update module google.golang.org/protobuf to v1.31.0 [[#2409](https://github.com/woodpecker-ci/woodpecker/pull/2409)]\n- fix(deps): update kubernetes packages to v0.28.1 [[#2399](https://github.com/woodpecker-ci/woodpecker/pull/2399)]\n- fix(deps): update module github.com/swaggo/swag to v1.16.2 [[#2390](https://github.com/woodpecker-ci/woodpecker/pull/2390)]\n- fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.36.0 [[#2406](https://github.com/woodpecker-ci/woodpecker/pull/2406)]\n- fix(deps): update module github.com/stretchr/testify to v1.8.4 [[#2389](https://github.com/woodpecker-ci/woodpecker/pull/2389)]\n- fix(deps): update module github.com/caddyserver/certmagic to v0.19.2 [[#2401](https://github.com/woodpecker-ci/woodpecker/pull/2401)]\n- chore(deps): update postgres docker tag to v12.16 [[#2397](https://github.com/woodpecker-ci/woodpecker/pull/2397)]\n- fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.17 [[#2387](https://github.com/woodpecker-ci/woodpecker/pull/2387)]\n- fix(deps): update module github.com/google/uuid to v1.3.1 [[#2386](https://github.com/woodpecker-ci/woodpecker/pull/2386)]\n- chore(deps): update dependency unplugin-vue-components to ^0.25.0 [[#2395](https://github.com/woodpecker-ci/woodpecker/pull/2395)]\n- fix(deps): update dependency @intlify/unplugin-vue-i18n to ^0.13.0 [[#2398](https://github.com/woodpecker-ci/woodpecker/pull/2398)]\n- chore(deps): update dependency unplugin-icons to ^0.17.0 [[#2394](https://github.com/woodpecker-ci/woodpecker/pull/2394)]\n- chore(deps): update golang docker tag [[#2396](https://github.com/woodpecker-ci/woodpecker/pull/2396)]\n- fix(deps): update module github.com/moby/moby to v20.10.25+incompatible [[#2388](https://github.com/woodpecker-ci/woodpecker/pull/2388)]\n- fix(deps): update module github.com/docker/docker to v20.10.25+incompatible [[#2385](https://github.com/woodpecker-ci/woodpecker/pull/2385)]\n- fix(deps): update module github.com/docker/cli to v20.10.25+incompatible [[#2384](https://github.com/woodpecker-ci/woodpecker/pull/2384)]\n- fix(deps): update module github.com/alessio/shellescape to v1.4.2 [[#2381](https://github.com/woodpecker-ci/woodpecker/pull/2381)]\n- fix(deps): update golang.org/x/exp digest to 9212866 [[#2380](https://github.com/woodpecker-ci/woodpecker/pull/2380)]\n- Check for correct license header [[#2137](https://github.com/woodpecker-ci/woodpecker/pull/2137)]\n- Add TestCompilerCompile [[#2183](https://github.com/woodpecker-ci/woodpecker/pull/2183)]\n- Fix `docs` workflow [[#2128](https://github.com/woodpecker-ci/woodpecker/pull/2128)]\n- Add some tests for bitbucket forge  [[#2097](https://github.com/woodpecker-ci/woodpecker/pull/2097)]\n- Publish releases and branch tags to quay.io too [[#2069](https://github.com/woodpecker-ci/woodpecker/pull/2069)]\n\n## [1.0.5](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.5) - 2023-11-09\n\n- ENHANCEMENTS\n  - Switch to go vanity urls (#2706) (#2773)\n- MISC\n  - Fix release pipeline for 1.x.x (#2774)\n\n## [1.0.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.4) - 2023-11-05\n\n- BUGFIXES\n  - Fix secret image filter regex (#2674) (#2686)\n  - Fix error when closing logs (#2637) (#2640)\n\n## [1.0.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.3) - 2023-10-14\n\n- SECURITY\n  - Update dependencies (#2587)\n  - Frontend: bump postcss to 8.4.31 (#2541)\n  - Check permissions on repo lookup (#2358)\n  - Change token logging to trace level (#2247) (#2248)\n- BUGFIXES\n  - Fix gitlab hooks (#2537) (#2542)\n  - Trim last \"/\" from WOODPECKER_HOST config (#2538) (#2540)\n  - Fix(server/api/repo): Fix repair webhook host (#2372) (#2452)\n  - Show correct event in pipeline step list (#2448)\n  - Make WOODPECKER_MIGRATIONS_ALLOW_LONG have an actuall effect (#2251) (#2309)\n  - Docker build dont ignore ci env vars (#2238) (#2246)\n  - Handle parsed hooks that should be ignored (#2243) (#2244)\n  - Return 204 not 500 on filtered pipeline (#2230)\n  - Set correct version for release branch releases (#2227) (#2229)\n- MISC\n  - Rebuild swagger with latest version (#2455)\n\n## [1.0.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.2) - 2023-08-16\n\n- SECURITY\n  - Validate webhook before change any data (#2221) (#2222)\n- BUGFIXES\n  - Bump default git clone plugin (#2215) (#2220)\n  - Show all steps (#2190) (#2191)\n\n## [1.0.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.1) - 2023-08-08\n\n- SECURITY\n  - Fix WOODPECKER_GRPC_VERIFY being ignored (#2077) (#2082)\n- BUGFIXES\n  - Fix 'add-orgs' migration (#2117) (#2145)\n  - Fix UI and backend paths with subpath (#1799) (#2133)\n  - Fix swagger response code (#2119) (#2121)\n  - Forge Github Org: Use `login` instead of `name` (#2104) (#2106)\n  - Client.go: Backport fix RepoPost path (#2100)\n  - Fix translation key (#2098)\n\n## [1.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.0) - 2023-07-29\n\n- BREAKING\n  - Use IDs to access organizations (#1873)\n  - Drop support for Bitbucket Server (#1994)\n  - Rename yaml `pipeline` to `steps` (#1833)\n  - Drop \".drone.yml\" as default pipeline config (#1795)\n  - Build-in Env Vars, use _URL for all links/URLs (#1794)\n  - Resolve built-in variables for global when filtered too (#1790)\n  - Drop \"Gogs\" support (#1752)\n  - Access repos by their IDs (#1691)\n  - Drop \"coding\" support (#1644)\n  - Add queue details UI for admins (#1632)\n  - Remove `command:` from steps (#1032)\n  - Remove old `build` API routes (#1283)\n  - Let single line command be a single command (#1009)\n  - Drop deprecated environment vars (#920)\n  - Drop Var-Args in steps in favor of settings (#919)\n  - Fix branch condition on tags (#917)\n  - Use asymmetric key to sign webhooks (#916)\n  - Add agent tagging / filtering for pipelines (#902)\n  - Delete old fallback for \"drone.sqlite\" (#791)\n  - Migrate to certmagic (#360)\n- FEATURES\n  - Implement YAML Map Merge, Overrides, and Sequence Merge Support (#1720)\n  - Add users UI for admins (#1634)\n  - Add agent no-schedule flag (#1567)\n  - Change locale in user settings (#1305)\n  - Add when evaluate filter (#1213)\n  - Store an agents list and add agent heartbeats (#1189)\n  - Add ability to trigger manual builds (#1156)\n  - Add default event filter (#1140)\n  - Add CLI support for global and organization secrets (#1113)\n  - Allow multiple when conditions (#1087)\n  - Add syntax highlighting for pipeline config (#1082)\n  - Add `logs` command to CLI & update forges features docs (#1064)\n  - Add method to check organization membership (#1037)\n  - Global and organization secrets (#1027)\n  - Add pipeline log output download (#1023)\n  - Provide global environment variables for pipeline substitution (#968)\n  - Add cron jobs (#934)\n  - Support localized web UI (#912)\n  - Add support to define a custom docker network and enable docker ipv6 (#893)\n  - Add SSH backend (#861)\n  - Add support for superseding runs (#831)\n  - Add support for steps to be a list (instead of dict) (#826)\n  - Add editing of secrets and registries (#823)\n  - Allow loading sensitive flags from files (#815)\n  - Add support for pipeline configuration service (#804)\n  - Support all backends for CLI exec (#801)\n  - Add support for pipeline root.when conditions (#770)\n  - Add support to run pipelines using a local backend (#709)\n  - Add initial version of Kubernetes backend (#552)\n- SECURITY\n  - Fix ignoring server set pipeline max-timeout (#1875)\n  - Only grant privileged to plugins (#1646)\n  - Only inject netrc to trusted clone plugins (#1352)\n  - Support plugin-only secrets (#1344)\n  - Fix insecure /tmp usage in local backend (#872)\n- BUGFIXES\n  - Handle case where there is no latest pipeline for GetBadge (#2042) (#2050)\n  - Fix repo gate protection (#1969)\n  - Make secrets with \"/\" in name editable / deletable (#1938)\n  - Fix Bitbucket implement missing features (#1887) (#1889)\n  - Fix nil pointer in repo repair (#1804)\n  - Do not use OAuth client without token (#1803)\n  - Correct label argument parsing in agent code (#1717)\n  - Fully support `.yaml` (#1713)\n  - Consistent status on delete (#1703)\n  - Fix Bitbucket Server branches (#1698)\n  - Set 'HOME' during local pipeline step (#1686)\n  - Pipeline compiler: handle nil entrys in settings list (#1626)\n  - Fix: backend auto-detection should be consistent (#1618)\n  - Return 404 on badge endpoint for inactive repos (#1600)\n  - Ensure the SharedInformerFactory closes eventually (#1585)\n  - Deduplicate step docker container volumes (#1571)\n  - Don't require secret value on secret edit (#1552) (#1553)\n  - Rework status constraint logic for successes (#1515)\n  - Don't panic on hook parsing (#1501)\n  - Hide not owned repos from sidebar and repo list (#1453)\n  - Fix cut of woodpecker animation (#1402)\n  - Fix approval on mobile (#1320)\n  - Unify buttons, links and improve focus styles (#1317)\n  - Fix pipeline manual trigger on web (#1307)\n  - Fix SCM visibility if user visibility is private (#1217)\n  - Hide log output container if step does not have logs (#1086)\n  - Fix to show build pipeline parse error (#1066)\n  - Pipeline compiler should not alter specified image (#1005)\n  - Gracefully handle non-zero exit code in local backend (#1002)\n  - Replace run_on references with runs_on (#965)\n  - Set default logging value of CLI to info (#871)\n  - Support conditional branch as an array to align with documentation (#836)\n  - Fix redirect after login (#824)\n- ENHANCEMENTS\n  - Add BranchHead implementation for bitbucket forge (#2011)\n  - Use global logger for xorm logs and add options (#1997)\n  - Let HookParse func explicit ignore events (#1942)\n  - Link swagger in navbar (#1984)\n  - Add option to read grpc-secret from file (#1972)\n  - Let pipeline-compiler export step types (#1958)\n  - docker backend use uuid instead of name as identifier (#1967)\n  - Kubernetes do not set Pod's Image pull policy if not explicitly set (#1914)\n  - Fixed when:evaluate on non-standard (non-CI*) env vars (#1907)\n  - Add pull-request implementation for bitbucket forge (#1889)\n  - Store agent ID in config file (#1888)\n  - Fix bitbucket forge add repo (#1887)\n  - Added Woodpecker Host Config used for Webhooks (#1869)\n  - Drop old columns (#1838)\n  - Remove MSSQL specific code and cleanups (#1796)\n  - Remove unused file system API (#1791)\n  - Add Forge Metadata to built-in environment variables (#1789)\n  - Redirect to new pipeline (#1761)\n  - Add reset token button (#1755)\n  - Add agent functions to go-sdk (#1754)\n  - Always send a status back to forge (#1751)\n  - Allow to configure listener port for SSL (#1735)\n  - Identify users using their remote ID (#1732)\n  - Let agent retry to connecting to server (#1728)\n  - Stable sort order for DB lists (#1702)\n  - Add backend label to agents (#1692)\n  - Web: use i18n-t to avoid v-html directive (#1676)\n  - Various UI improvements (#1663)\n  - Do not store inactive repos without any resources (#1658)\n  - Implement visual display of queue statistics (#1657)\n  - Agent check gRPC version against server (#1653)\n  - Initiate Pagination Implementation for API and Infinite Scroll in UI (#1651)\n  - Add PR pipeline list (#1641)\n  - Save agent-id for tasks and add endpoint to get agent tasks (#1631)\n  - Return 404 if pipeline not exist and handle 404 errors in WebUI (#1627)\n  - UI should confirm secret deletion (#1604)\n  - Add collapsable support to panel elements (#1601)\n  - Add cancel button on secrets tab (#1599)\n  - Allow custom dnsConfig in agent deployment (#1569)\n  - Show platform, backend and capacity as badges in agent list (#1568)\n  - Define WOODPECKER_FORGE_TIMEOUT server config (#1558)\n  - Sort repos by org/name (#1548)\n  - Improve button and input contrast in dark mode (#1456)\n  - Consistent and more descriptive naming of parameters in index.ts (#1455)\n  - Add button in UI to trigger the deployment event (#1415)\n  - Use icons for step and workflow states (#1409)\n  - Match notification font size to rest of the UI (#1399)\n  - Support .yaml as file-ending for workflow config too (#1388)\n  - Show workflow state in UI and collapse completed workflows (#1383)\n  - Use pipeline wrapper and improve scaffold UI (#1368)\n  - Lazy load locales (#1362)\n  - Always use rounded quadrat user avatars (#1350)\n  - Fix display of long pipeline and job names (#1346)\n  - Support changed files for Gitea PRs (#1342)\n  - Allow to change directory for steps (#1329)\n  - UI use system font stack (#1326)\n  - Add pull request labels as environment variable (#1321)\n  - Make pipeline workflows collapsable (#1304)\n  - Make submit buttons green and add forms (#1302)\n  - Add pipeline build number into Pipeline list (#1301)\n  - Add title to docs links (#1298)\n  - Check if repo exists before creating pipeline (#1297)\n  - Use HTML buttons to allow keyboard navigation (#1242)\n  - Introduce and use Pagination helper func (#1236)\n  - Sort secret lists and events (#1223)\n  - Add support sub-settings and secrets in sub-settings (#1221)\n  - Add option to ignore failures on steps (#1219)\n  - Set a default value for `pipeline-event` flag of `cli exec` command (#1212)\n  - Add option for docker runtime to provide default volumes (#1203)\n  - Make healthcheck port configurable (#1197)\n  - Don't show \"changed files\" if event can't have them (#1191)\n  - Add dedicated DroneCI env compatibility layer (#1185)\n  - Only enable debug endpoints if log level is debug or below (#1160)\n  - Sort pipelines based on creation date (#1159)\n  - Add option to turn on and off log automatic scrolling (#1149)\n  - Checkout tags on tag pipeline (#1110)\n  - Use fixed version of git clone plugin (#1108)\n  - Fetch repositories with remote ID if possible (#1078)\n  - Support Docker credential helpers (#1075)\n  - Do not show pipeline name if it's a single file (#1069)\n  - Remove xterm and use ansi converter for logs (#1067)\n  - Update jsonschema and define \"services\" (#1036)\n  - Show forge icons in UI (#987)\n  - Make pipeline runtime log with description (#970)\n  - Improve UI colors to have more contrast (#943)\n  - Add branches support for BitBucket (#907)\n  - Auto cancel blocked pipelines (#905)\n  - Allow to change forge status messages (#900)\n  - Added support for step errors when executing backend (#817)\n  - Do not filter on linux/amd64 per default (#805)\n- DOCUMENTATION\n  - Remove never implemented \"tag\"-filter and document \"ref\"-filter to do the same (#1820)\n  - Define Glossary (#1800)\n  - Add more documentation about branch matching (#1186)\n  - Use versioned docs (#1145)\n  - Add gitpod setup (#1020)\n- MISC\n  - Drop tarball release (#1819)\n  - Move helm charts to own repo \"helm\" (#1589)\n  - Replace yarn with pnpm (#1240)\n  - Publish preview docker images of pulls (#1072)\n\n## [0.15.11](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.11) - 2023-07-12\n\n- SECURITY\n  - Update github.com/gin-gonic/gin to 1.9.1 (#1989)\n- ENHANCEMENTS\n  - Allow gitea dev version (#914) (#1988)\n\n## [0.15.10](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.10) - 2023-07-09\n\n- SECURITY\n  - Fix agent auth (#1952) (#1953)\n  - Return after error (#1875) (#1876)\n  - Update github.com/docker/distribution (#1750)\n\n## [0.15.9](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.9) - 2023-05-11\n\n- SECURITY\n  - Backport securitycheck and bump deps where needed (#1745)\n\n## [0.15.8](https://github.com/woodpecker-ci/woodpecker/releases/tag/0.15.8) - 2023-04-29\n\n- BUGFIXES\n  - Use codeberg.org/6543/go-yaml2json (#1719)\n  - Fix faulty hardlink in release tarball (#1669) (#1671)\n  - Persist `DepStatus` of tasks (#1610) (#1625)\n\n## [0.15.7](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.7) - 2023-03-14\n\n- SECURITY\n  - Update dependencies golang/x libs (#1612) (#1621)\n- BUGFIXES\n  - Docker backend should not close 'engine.Tail' result (#1616) (#1620)\n  - Force pure Go resolver onto server (#1502) (#1503)\n- ENHANCEMENTS\n  - SanitizeParamKey \"-\" to \"_\" for plugin settings (#1511)\n- MISC\n  - Bump xgo and go to v1.19.5 (#1538) (#1547)\n  - Pin official default clone image (#1526) (#1534)\n\n## [0.15.6](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.6) - 2022-12-23\n\n- SECURITY\n  - Update golang.org/x/net (#1494)\n  - [**BREAKING**] Disable metrics access if no token is set (#1469) (#1470)\n  - Update dep moby (#1263) (#1264)\n- BUGFIXES\n  - Update json schema for cli lint to cover valid cases (#1384)\n  - Add pipeline.step.when.branch string-array type to schema.json (#1380)\n  - Display system CA error only if there is an error (#870) (#1286)\n- ENHANCEMENTS\n  - Bump Frontend Deps and remove unused (#1404)\n\n## [0.15.5](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.5) - 2022-10-13\n\n- BUGFIXES\n  - Change build message column type to text (#1252) (#1253)\n- ENHANCEMENTS\n  - Bump DefaultCloneImage version to v1.6.0 (#1254)\n  - On Repo update, keep old \"Clone\" if update would empty it (#1170) (#1195)\n\n## [0.15.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.4) - 2022-09-06\n\n- BUGFIXES\n  - Extract commit message from branch creation (#1150) (#1153)\n  - Respect WOODPECKER_GITEA_SKIP_VERIFY (#1152) (#1151)\n  - update golang.org/x/crypto (#1124)\n  - Implement Refresher for GitLab (#1031) (#1120)\n  - Make returned proc list to be returned always in correct order (#1060) (#1065)\n  - Update type of 'log_data' from blob to longblob (#1050) (#1052)\n  - Make ListItem component more accessible by using a button tag when clickable (#1044) (#1046)\n- MISC\n  - Update base images (#1024) (#1025)\n\n## [0.15.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.3) - 2022-06-16\n\n- SECURITY\n  - Update github.com/containerd/containerd (#978) (#980)\n- BUGFIXES\n  - Return to page after clicking login at navbar (#975) (#976)\n\n## [0.15.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.2) - 2022-06-14\n\n- BUGFIXES\n  - Fix uppercase from_secrets (#842) (#925)\n  - Fix key/val format for dind env vars (#889) (#890)\n  - Update helm chart releasing (#882) (#888)\n- DOCUMENTATION\n  - Fix run_on references with runs_on in docs (#965)\n\n## [0.15.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.1) - 2022-04-13\n\n- SECURITY\n  - Escape html / xml in log view (#879) (#880)\n- FEATURES\n  - Build multiarch images for server (#821) (#822)\n- BUGFIXES\n  - Branch list enhancements (#808) (#809)\n  - Get Netrc machine from clone url (#800) (#803)\n\n## [v0.15.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.0) - 2022-02-24\n\n- BREAKING\n  - Change paths to use woodpecker instead of drone (#494)\n  - Move plugin config to root.pipeline.[step].settings (#464)\n  - Replace debug with log-level flag (#440)\n  - Change prometheus metrics from `drone_*` to `woodpecker_*` (#439)\n  - Replace DRONE_with CI_ variables in pipeline steps (#427)\n  - Enable pull_request hook by default on repository activation (#420)\n  - Remote Gitea drop basic auth support (#365)\n  - Change pipeline config path resolution (#299)\n  - Remove push, tag and deployment webhook filters (#281)\n  - Clean up config environment variables for server and agent (#218)\n- SECURITY\n  - Add linter bidichk to prevent malicious utf8 chars (#516)\n- FEATURES\n  - Show changed files of pipeline in UI (#650)\n  - Show yml config of pipeline in UI (#649)\n  - Multiarch build for cli and agent docker images (#634), (#622)\n  - Get secrets in settings (#604)\n  - Add multi-pipeline support to exec & lint (#568)\n  - Add repo branches endpoint (#481)\n  - Add repo permission endpoint (#436)\n  - Add web-config endpoint (#433)\n  - Replace www-path with www-proxy option for development (#248)\n- BUGFIXES\n  - Make gRPC error \"too many keepalive pings\" only show up in trace logs (#787)\n  - WOODPECKER_ENVIRONMENT: ignore items only containing a key and no value (#781)\n  - Fix pipeline timestamps (#730)\n  - Remove \"panic()\" as much as possible from code (#682)\n  - Send decline events back to UI (#680)\n  - Notice all changed files of all related commits for gitea push webhooks (#675)\n  - Use global branch filter only on events containing branch info (#659)\n  - API GetRepos() return empty list if no active repos exist (#658)\n  - Skip nested GitLab repositories during sync (#656), (#652)\n  - Build proc tree function should not depend on sorted procs list (#647)\n  - Fix sqlite migration on column drop of abnormal schemas (#629)\n  - Fix gRPC incompatibility in helm chart (#627)\n  - Fix new pipeline not published to UI if protected repo mode enabled (#619)\n  - Dont panic, report error back (#582)\n  - Improve status updates (#561)\n  - Let normal repo admins change timeout to lower values (#543)\n  - Fix registry delete (#532)\n  - Fix overflowing commit messages (#528)\n  - Fix passing of netrc credentials to clone step (#492)\n  - Fix various typos (#416)\n  - Append trailing slash to default GH API URL (#411)\n  - Fix filter pipeline config files (#279)\n- ENHANCEMENTS\n  - Return better error if repo was deleted/renamed (#780)\n  - Add support to set default clone image via environment variable (#769)\n  - Add flag to always authenticate when cloning public repositories from locked down / private only forges (#760)\n  - UI: show date time on hover over time items (#756)\n  - Add repo-link to badge markdown in UI (#753)\n  - Allow specifying dind container in values (#750)\n  - Add page to view all projects of a user / group (#741)\n  - Let non required migration tasks fail and continue (#729)\n  - Improve pipeline compiler (#699)\n  - Support ChangedFiles for GitHub & Gitlab PRs and pushes and Gitea pushes (#697)\n  - Remove unused flags / options (#693)\n  - Automatically determine platform of agent (#690)\n  - Build ref link point to commit not compare if only one commit was pushed (#673)\n  - Hide multi line secrets from log (#671)\n  - Do not exclude repo owner from gated rule (#641)\n  - Add field for image list in Secrets Repo Settings (Web UI) (#638)\n  - Use Woodpecker theme colors on Safari Tab Bar / Header Bar (#632)\n  - Add \"woodpeckerci/plugin-docker-buildx\" to privileged plugins (#623)\n  - Use gitlab generic webhooks instead of drone-ci-service (#620)\n  - Calculate build number on creation (#615)\n  - Hide gin routes logging on non-debug starts (#603)\n  - Let remove be a remove (#593)\n  - Add flag to set oauth redirect host in dev mode (#586)\n  - Add log-level option to cli (#584)\n  - Improve favicons (#576)\n  - Show icon and index of a pull request in pipelines triggered by pull requests (#575)\n  - Improve secrets tab (#574)\n  - Use monospace font for build logs (#527)\n  - Show environ in every BuildProc (#526)\n  - Drop error only on purpose or else report back or log (#514)\n  - Migrate database backend to Xorm (#474)\n  - Add backend selection for agent (#463)\n  - Switch default git plugin (#449)\n  - Add log level API (#444)\n  - Move entirely to zerolog (#426)\n  - Pass context.Context down (#371)\n  - Extend Logging & Report to WebHook Caller back if pulls are disabled (#369)\n  - If config is no file assume its a folder (#354)\n  - Rename cmd agent and server folders and binaries (#330)\n  - Release Helm charts (#302)\n  - Add flag for specific grpc server addr (#295)\n  - Add option to charts, to pass in topology pod constraints (#262)\n  - Use server-host as source for public links and warn if it is set to localhost (#251)\n  - Rewrite of UI (#245)\n- REFACTOR\n  - Remove github.com/kr/pretty in favor of assert.EqualValues() (#564)\n  - Simplify web router code (#541)\n  - Server obtain remote from glob config not from context (#540)\n  - Serve index.html directly without template (#539)\n  - Add linter revive, unused, ineffassign, varcheck, structcheck, staticcheck, whitespace, misspell (#550), (#551), (#554), (#538), (#537), (#535), (#531), (#530)\n  - Rename struct field and add new types into server/model's (#523)\n  - Update database in one transaction on syncing user repositories (#513)\n  - Format code with 'simplify' flag and check via CI (#509)\n  - Use Goblin Assert as intended (#501)\n  - Embedding libcompose types for yaml parsing (#495)\n  - Use std method to get SystemCertPool (#488)\n  - Upgrade urfave/cli to v2 (#483)\n  - Remove some wrapper and make code more readable (#478)\n  - More logging and refactor (#457)\n  - Simplify routes (#437)\n  - Move api-routes to separate file (#434)\n  - Rename drone-go to woodpecker-go (#390)\n  - Remove ghodss/yaml (#384)\n  - Move model/ to server/model/ (#366)\n  - Use moby definitions for docker pipeline backend (#364)\n  - Rewrite Gitlab Remote (#358)\n  - Update Generated Proto Code (#351)\n  - Remove legacy/unused code + misc cleanups (#331)\n  - CLI use version from version/version.go (#329)\n  - Move cli/drone/ to cli/ (#329)\n  - Cleanup Code (#348)\n  - Move cncd/pipeline/pipeline/ to pipeline/ (#347)\n  - Move cncd/{logging,pubsub,queue}/ to server/{logging,pubsub,queue}/ (#346)\n  - Move remote/ to server/remote/ (#344)\n  - Move plugins/ to server/plugins/ (#343)\n  - Move store/ to server/store/ (#341)\n  - Move router/ to server/router/ (#339)\n  - Create agent/ package for backend agnostic logic (#338)\n  - Reorganize into server/{api,grpc,shared} packages (#337)\n- TESTING\n  - Add tests framework for storage migration (#630)\n  - Add more golangci-lint linters & sort them (#499) (#502)\n  - Compile on pull too (#287)\n- DOCUMENTATION\n  - Add note about Gitlab & Gitea internal connections to docs (#711)\n  - Add registries docs (#679)\n  - Add documentation of all agent configuration options (#667)\n  - Add `repo` to `when` block (#642)\n  - Add development docs (#610)\n  - Clarify Docs on Docker for new users in intro (#606)\n  - Update Documentation (fix diffs and add settings) (#569)\n  - Add notice of supported YAML versions in docs (#556)\n  - Update Agent and Pipeline syntax documentation (#506)\n  - Update docs about selecting agent based on platform (#470)\n  - Add plugin marketplace (for official plugins) (#451)\n  - Add search to docs (#448)\n  - Add image migration docs (#406)\n  - Add security policy (#396)\n  - Explain open registration setting (#361)\n  - Add json schema and cli lint command (#342)\n  - Improve docs deployment (#333)\n  - Improve plugin docs (#313)\n  - Add Support section to README (#310)\n  - Community Guide (#296)\n  - Migrate docs framework to Docusaurus (#282)\n  - Use woodpecker env variable instead of drone in docker-compose (#264)\n- MISC\n  - Add support for building in docker (#759)\n  - Compile for more platforms on release (#703)\n  - Build agent for multiple platforms (arm, arm64, amd64, linux, windows, darwin) (#408)\n  - Release deb, rpm bundles (#405)\n  - Release cli images (#404)\n  - Publish alpine container (#398)\n  - Migrate go-docker to docker/docker (#363)\n  - Use go's vendoring (#284)\n\n## [v0.14.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.4) - 2022-01-31\n\n- BUGFIXES\n  - Docker Images use golang image for ca-certificates (#608)\n\n## [v0.14.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.3) - 2021-10-30\n\n- BUGFIXES\n  - Add flag for not fetching permissions (FlatPermissions) (#491)\n  - Gitea use default branch (#480) (#482)\n  - Fix repo access (#476) (#477)\n- ENHANCEMENTS\n  - Use go embed for web files and remove httptreemux (#382) (#489)\n\n## [v0.14.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.2) - 2021-10-19\n\n- BUGFIXES\n  - Fix sanitizePath (#326) (aa4fa9aab3)\n  - Fix json tag for `Pos` at struct `Line` (#422) (#424)\n  - Fix channel buffer used with signal.Notify (#421) (#423)\n- ENHANCEMENTS\n  - Support recursive glob for path conditions (#327) (#412)\n- TESTING\n  - Add TestPipelineName to procBuilder_test.go (#461) (#455)\n\n## [v0.14.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.1) - 2021-09-21\n\n- SECURITY\n  - Migrate jwt token lib (#332)\n- BUGFIXES\n  - Increase allowed length for user token in db (#328)\n  - Fix cli matrix filter (#311)\n  - Fix ignore pushes to tags for gitea (#289)\n  - Fix use custom config path to sanitize build names (#280)\n\n## [v0.14.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.0) - 2021-08-01\n\n- FEATURES\n  - Add OAuth2 Support for Gitea Remote (#226)\n  - Add support for path-prefix condition (#174)\n- BUGFIXES\n  - Allow multi pipeline file to be named .drone.yml (#250)\n  - Fix release-server make target by build server with correct option (#237)\n  - Fix Gitea unable to login on 0.12.0+ with error \"cannot authenticate user. 403 Forbidden\" (#221)\n- ENHANCEMENTS\n  - Update / Remove drone dependencies (#236)\n  - Add support to gitea remote for path-prefix condition (#235)\n  - Enable go vet for ci (#230)\n  - Enforce code format (#228)\n  - Add multi-pipeline to Gitea (#225)\n  - Move flag definitions into extra files (#215)\n  - Remove unused code in server (#213)\n  - Docs URL configuration (#206)\n  - Filter main branch (#205)\n  - Fix multi pipeline bug when a pipeline depends on two other pipelines (#201)\n  - Using configured server URL instead of obtained from request (#175)\n- DOCUMENTATION\n  - Switch in docs to new docker hub image repo (#227)\n  - Use WOODPECKER_ env vars in docs (#211)\n  - Also show WOODPECKER_HOST and WOODPECKER_SERVER_HOST environment variables in log messages (#208)\n  - Move woodpecker to dedicated organisation on github (#202)\n- MISC\n  - Add chart for installing woodpecker server and agent (#199)\n"
  },
  {
    "path": "LICENSE",
    "content": "                                Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2018 Drone.IO Inc.\n   Copyright 2020 Woodpecker Authors\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": "# renovate: datasource=github-releases depName=mvdan/gofumpt\nGOFUMPT_VERSION := v0.10.0\n# renovate: datasource=github-releases depName=golangci/golangci-lint\nGOLANGCI_LINT_VERSION := v2.12.2\n# renovate: datasource=docker depName=docker.io/techknowlogick/xgo\nXGO_VERSION := go-1.26.x\n\nGO_PACKAGES ?= $(shell go list ./... | grep -v /vendor/)\n\nTARGETOS ?= $(shell go env GOOS)\nTARGETARCH ?= $(shell go env GOARCH)\n\nBIN_SUFFIX :=\nifeq ($(TARGETOS),windows)\n\tBIN_SUFFIX := .exe\nendif\n\nDIST_DIR ?= dist\n\nVERSION ?= next\nVERSION_NUMBER ?= 0.0.0\nCI_COMMIT_SHA ?= $(shell git rev-parse HEAD)\n\n# it's a tagged release\nifneq ($(CI_COMMIT_TAG),)\n\tVERSION := $(CI_COMMIT_TAG:v%=%)\n\tVERSION_NUMBER := ${CI_COMMIT_TAG:v%=%}\nelse\n\t# append commit-sha to next version\n\tifeq ($(VERSION),next)\n\t\tVERSION := $(shell echo \"next-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)\")\n\tendif\n\t# append commit-sha to release branch version\n\tifeq ($(shell echo ${CI_COMMIT_BRANCH} | cut -c -9),release/v)\n\t\tVERSION := $(shell echo \"$(shell echo ${CI_COMMIT_BRANCH} | cut -c 10-)-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)\")\n\tendif\nendif\n\nTAGS ?=\nLDFLAGS := -X go.woodpecker-ci.org/woodpecker/v3/version.Version=${VERSION}\nSTATIC_BUILD ?= true\nifeq ($(STATIC_BUILD),true)\n\tLDFLAGS := -s -w -extldflags \"-static\" $(LDFLAGS)\nendif\nCGO_ENABLED ?= 1 # only used to compile server\n\nHAS_GO = $(shell hash go > /dev/null 2>&1 && echo \"GO\" || echo \"NOGO\" )\nifeq ($(HAS_GO),GO)\n\tCGO_CFLAGS ?= $(shell go env CGO_CFLAGS)\nendif\nCGO_CFLAGS ?=\n\n\n# If the first argument is \"in_docker\"...\nifeq (in_docker,$(firstword $(MAKECMDGOALS)))\n  # use the rest as arguments for \"in_docker\"\n  MAKE_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))\n  # Ignore the next args\n  $(eval $(MAKE_ARGS):;@:)\n\n  in_docker:\n\t@[ \"1\" -eq \"$(shell docker image ls woodpecker/make:local -a | wc -l)\" ] && docker buildx build -f ./docker/Dockerfile.make -t woodpecker/make:local --load . || echo reuse existing docker image\n\t@echo run in docker:\n\t@docker run -it \\\n\t\t--user $(shell id -u):$(shell id -g) \\\n\t\t-e VERSION=\"$(VERSION)\" \\\n\t\t-e CI_COMMIT_SHA=\"$(CI_COMMIT_SHA)\" \\\n\t\t-e TARGETOS=\"linux\" \\\n\t\t-e TARGETARCH=\"$(TARGETARCH)\" \\\n\t\t-e CGO_ENABLED=\"$(CGO_ENABLED)\" \\\n\t\t-v $(PWD):/build --rm woodpecker/make:local make $(MAKE_ARGS)\nelse\n\n# Proceed with normal make\n\n##@ General\n\n.PHONY: all\nall: help\n\n.PHONY: version\nversion: ## Print the current version\n\t@echo ${VERSION}\n\n# The help target prints out all targets with their descriptions organized\n# beneath their categories. The categories are represented by '##@' and the\n# target descriptions by '##'. The awk commands is responsible for reading the\n# entire set of makefiles included in this invocation, looking for lines of the\n# file as xyz: ## something, and then pretty-format the target and help. Then,\n# if there's a line with ##@ something, that gets pretty-printed as a category.\n# More info on the usage of ANSI control characters for terminal formatting:\n# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters\n# More info on the awk command:\n# http://linuxcommand.org/lc3_adv_awk.php\n\n.PHONY: help\nhelp: ## Display this help.\n\t@awk 'BEGIN {FS = \":.*##\"; printf \"\\nUsage:\\n  make \\033[36m<target>\\033[0m\\n\"} /^[a-zA-Z_0-9-]+:.*?##/ { printf \"  \\033[36m%-15s\\033[0m %s\\n\", $$1, $$2 } /^##@/ { printf \"\\n\\033[1m%s\\033[0m\\n\", substr($$0, 5) } ' $(MAKEFILE_LIST)\n\n.PHONY: vendor\nvendor: ## Update the vendor directory\n\tgo mod tidy\n\tgo mod vendor\n\nformat: install-gofumpt ## Format source code\n\t@gofumpt -extra -w .\n\n.PHONY: clean\nclean: ## Clean build artifacts\n\tgo clean -i ./...\n\trm -rf build\n\t@[ \"1\" != \"$(shell docker image ls woodpecker/make:local -a | wc -l)\" ] && docker image rm woodpecker/make:local || echo no docker image to clean\n\n.PHONY: clean-all\nclean-all: clean ## Clean all artifacts\n\trm -rf ${DIST_DIR} web/dist docs/build docs/node_modules web/node_modules\n\t# delete generated\n\trm -rf docs/docs/40-cli.md docs/openapi.json\n\n.PHONY: generate\ngenerate: install-mockery generate-openapi ## Run all code generations\n\tmockery\n\tCGO_ENABLED=0 go generate ./...\n\ngenerate-openapi: ## Run openapi code generation and format it\n\tCGO_ENABLED=0 go run github.com/swaggo/swag/cmd/swag fmt --exclude rpc/proto\n\tCGO_ENABLED=0 go generate cmd/server/openapi.go\n\ngenerate-license-header: install-addlicense\n\taddlicense -c \"Woodpecker Authors\" -l apache -ignore \"vendor/**\" -ignore cmd/server/openapi/docs.go **/*.go\n\ncheck-xgo: ## Check if xgo is installed\n\t@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\t$(GO) install src.techknowlogick.com/xgo@latest; \\\n\tfi\n\ninstall-golangci-lint:\n\t@hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) ; \\\n\tfi\n\ninstall-gofumpt:\n\t@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install mvdan.cc/gofumpt@$(GOFUMPT_VERSION); \\\n\tfi\n\ninstall-addlicense:\n\t@hash addlicense > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install github.com/google/addlicense@latest; \\\n\tfi\n\ninstall-mockery:\n\t@hash mockery > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install github.com/vektra/mockery/v3@latest; \\\n\tfi\n\ninstall-protoc-gen-go:\n\t@hash protoc-gen-go > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install google.golang.org/protobuf/cmd/protoc-gen-go@latest; \\\n\tfi ; \\\n\thash protoc-gen-go-grpc > /dev/null 2>&1; if [ $$? -ne 0 ]; then \\\n\t\tgo install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest; \\\n\tfi\n\n.PHONY: install-tools\ninstall-tools: install-golangci-lint install-gofumpt install-addlicense install-mockery install-protoc-gen-go ## Install development tools\n\nui-dependencies: ## Install UI dependencies\n\t(cd web/; pnpm install --frozen-lockfile)\n\n##@ Test\n\n.PHONY: lint\nlint: install-golangci-lint ## Lint code\n\t@echo \"Running golangci-lint\"\n\tgolangci-lint run\n\nlint-ui: ui-dependencies ## Lint UI code\n\t(cd web/; pnpm lint --quiet)\n\ntest-agent: ## Test agent code\n\tgo test -race -cover -coverprofile agent-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/agent go.woodpecker-ci.org/woodpecker/v3/agent/...\n\ntest-server: ## Test server code\n\tgo test -race -cover -coverprofile server-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/server $(shell go list go.woodpecker-ci.org/woodpecker/v3/server/... | grep -v '/store')\n\ntest-cli: ## Test cli code\n\tgo test -race -cover -coverprofile cli-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/cli go.woodpecker-ci.org/woodpecker/v3/cli/...\n\ntest-server-datastore: ## Test server datastore\n\tgo test -timeout 300s -tags 'test $(TAGS)' -run TestMigrate go.woodpecker-ci.org/woodpecker/v3/server/store/...\n\tgo test -race -timeout 120s -tags 'test $(TAGS)' -skip TestMigrate go.woodpecker-ci.org/woodpecker/v3/server/store/...\n\ntest-server-datastore-coverage: ## Test server datastore with coverage report\n\tgo test -race -cover -coverprofile datastore-coverage.out -timeout 300s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/server/store/...\n\ntest-ui: ui-dependencies ## Test UI code\n\t(cd web/; pnpm run lint)\n\t(cd web/; pnpm run format:check)\n\t(cd web/; pnpm run typecheck)\n\t(cd web/; pnpm run test)\n\ntest-lib: ## Test lib code\n\tgo test -race -cover -coverprofile coverage.out -timeout 60s -tags 'test $(TAGS)' $(shell go list ./... | grep -v '/cmd\\|/agent\\|/cli\\|/server')\n\ntest-e2e: ## Test by running yaml config and compare expected result\n\tgo test -race -cover -coverpkg=./... -coverprofile e2e-coverage.out -timeout 60s -tags 'test $(TAGS)' ./e2e/...\n\n.PHONY: test\ntest: test-agent test-server test-server-datastore test-cli test-lib test-e2e ## Run all tests\n\n##@ Build\n\nbuild-ui: ## Build UI\n\t(cd web/; pnpm install --frozen-lockfile; pnpm build)\n\nbuild-server: build-ui generate-openapi ## Build server\n\tCGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-server${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/server\n\nbuild-agent: ## Build agent\n\tCGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-agent${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\nbuild-cli: ## Build cli\n\tCGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-cli${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\nbuild-tarball: ## Build tar archive\n\tmkdir -p ${DIST_DIR} && tar chzvf ${DIST_DIR}/woodpecker-src.tar.gz \\\n\t  --exclude=\"*.exe\" \\\n\t  --exclude=\"./.pnpm-store\" \\\n\t  --exclude=\"node_modules\" \\\n\t  --exclude=\"./dist\" \\\n\t  --exclude=\"./data\" \\\n\t  --exclude=\"./build\" \\\n\t  --exclude=\"./.git\" \\\n\t  .\n\n.PHONY: build\nbuild: build-agent build-server build-cli ## Build all binaries\n\n.PHONY: release-frontend\nrelease-frontend: build-ui ## Build frontend\n\ncross-compile-server: ## Cross compile the server\n\t$(foreach platform,$(subst ;, ,$(PLATFORMS)),\\\n\t\tTARGETOS=$(firstword $(subst |, ,$(platform))) \\\n\t\tTARGETARCH_XGO=$(subst arm64/v8,arm64,$(subst arm/v7,arm-7,$(word 2,$(subst |, ,$(platform))))) \\\n\t\tTARGETARCH_BUILDX=$(subst arm64/v8,arm64,$(subst arm/v7,arm,$(word 2,$(subst |, ,$(platform))))) \\\n\t\tmake release-server-xgo || exit 1; \\\n\t)\n\ttree ${DIST_DIR}\n\nrelease-server-xgo: check-xgo ## Create server binaries for release using xgo\n\t@echo \"Building for:\"\n\t@echo \"os:$(TARGETOS)\"\n\t@echo \"arch orgi:$(TARGETARCH)\"\n\t@echo \"arch (xgo):$(TARGETARCH_XGO)\"\n\t@echo \"arch (buildx):$(TARGETARCH_BUILDX)\"\n\t# build via xgo\n\tCGO_CFLAGS=\"$(CGO_CFLAGS)\" xgo -go $(XGO_VERSION) -dest ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX) -tags 'netgo osusergo grpcnotrace $(TAGS)' -ldflags '-linkmode external $(LDFLAGS)' -targets '$(TARGETOS)/$(TARGETARCH_XGO)' -out woodpecker-server -pkg cmd/server .\n\t# move binary into subfolder depending on target os and arch\n\t@if [ \"$${XGO_IN_XGO:-0}\" -eq \"1\" ]; then \\\n\t  echo \"inside xgo image\"; \\\n\t  mkdir -p ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX); \\\n\t  mv -vf /build/woodpecker-server* ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \\\n\telse \\\n\t  echo \"outside xgo image\"; \\\n\t  [ -f \"${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX)\" ] && rm -v ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \\\n\t  mv -v ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_XGO)/woodpecker-server* ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \\\n\tfi\n\t# if enabled package it in an archive\n\t@if [ \"$${ARCHIVE_IT:-0}\" -eq \"1\" ]; then \\\n\t  if [ \"$(BIN_SUFFIX)\" = \".exe\" ]; then \\\n\t\t  rm -f  ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).zip; \\\n\t    zip -j ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).zip ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server.exe; \\\n\t  else \\\n\t    tar -cvzf ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).tar.gz -C ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX) woodpecker-server$(BIN_SUFFIX); \\\n\t  fi; \\\n\telse \\\n\t  echo \"skip creating '${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).tar.gz'\"; \\\n\tfi\n\nrelease-server: ## Create server binaries for release\n\t# compile\n\tGOOS=$(TARGETOS) GOARCH=$(TARGETARCH) CGO_ENABLED=${CGO_ENABLED} go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH)/woodpecker-server$(BIN_SUFFIX) go.woodpecker-ci.org/woodpecker/v3/cmd/server\n\t# tar binary files\n\tif [ \"$(BIN_SUFFIX)\" == \".exe\" ]; then \\\n\t  zip -j ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH).zip ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH)/woodpecker-server.exe; \\\n\telse \\\n\t  tar -cvzf ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH).tar.gz -C ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH) woodpecker-server$(BIN_SUFFIX); \\\n\tfi\n\nrelease-agent: ## Create agent binaries for release\n\t# compile\n\tGOOS=linux   GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_amd64/woodpecker-agent       go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=linux   GOARCH=arm64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_arm64/woodpecker-agent       go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=linux   GOARCH=riscv64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_riscv64/woodpecker-agent     go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=linux   GOARCH=arm     CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_arm/woodpecker-agent         go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=windows GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/windows_amd64/woodpecker-agent.exe go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=darwin  GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/darwin_amd64/woodpecker-agent      go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\tGOOS=darwin  GOARCH=arm64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/darwin_arm64/woodpecker-agent      go.woodpecker-ci.org/woodpecker/v3/cmd/agent\n\t# tar binary files\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_linux_amd64.tar.gz   -C ${DIST_DIR}/agent/linux_amd64   woodpecker-agent\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_linux_arm64.tar.gz   -C ${DIST_DIR}/agent/linux_arm64   woodpecker-agent\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_linux_riscv64.tar.gz -C ${DIST_DIR}/agent/linux_riscv64 woodpecker-agent\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_linux_arm.tar.gz     -C ${DIST_DIR}/agent/linux_arm     woodpecker-agent\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_darwin_amd64.tar.gz  -C ${DIST_DIR}/agent/darwin_amd64  woodpecker-agent\n\ttar -cvzf ${DIST_DIR}/woodpecker-agent_darwin_arm64.tar.gz  -C ${DIST_DIR}/agent/darwin_arm64  woodpecker-agent\n\t# zip binary files\n\trm -f  ${DIST_DIR}/woodpecker-agent_windows_amd64.zip\n\tzip -j ${DIST_DIR}/woodpecker-agent_windows_amd64.zip          ${DIST_DIR}/agent/windows_amd64/woodpecker-agent.exe\n\nrelease-cli: ## Create cli binaries for release\n\t# compile\n\tGOOS=linux   GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_amd64/woodpecker-cli       go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=linux   GOARCH=arm64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_arm64/woodpecker-cli       go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=linux   GOARCH=riscv64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_riscv64/woodpecker-cli     go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=linux   GOARCH=arm     CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_arm/woodpecker-cli         go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=windows GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/windows_amd64/woodpecker-cli.exe go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=darwin  GOARCH=amd64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/darwin_amd64/woodpecker-cli      go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\tGOOS=darwin  GOARCH=arm64   CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/darwin_arm64/woodpecker-cli      go.woodpecker-ci.org/woodpecker/v3/cmd/cli\n\t# tar binary files\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_linux_amd64.tar.gz   -C ${DIST_DIR}/cli/linux_amd64   woodpecker-cli\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_linux_arm64.tar.gz   -C ${DIST_DIR}/cli/linux_arm64   woodpecker-cli\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_linux_riscv64.tar.gz -C ${DIST_DIR}/cli/linux_riscv64 woodpecker-cli\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_linux_arm.tar.gz     -C ${DIST_DIR}/cli/linux_arm     woodpecker-cli\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_darwin_amd64.tar.gz  -C ${DIST_DIR}/cli/darwin_amd64  woodpecker-cli\n\ttar -cvzf ${DIST_DIR}/woodpecker-cli_darwin_arm64.tar.gz  -C ${DIST_DIR}/cli/darwin_arm64  woodpecker-cli\n\t# zip binary files\n\trm -f  ${DIST_DIR}/woodpecker-cli_windows_amd64.zip\n\tzip -j ${DIST_DIR}/woodpecker-cli_windows_amd64.zip          ${DIST_DIR}/cli/windows_amd64/woodpecker-cli.exe\n\nrelease-checksums: ## Create checksums for all release files\n\t# generate shas for tar files\n\t(cd ${DIST_DIR}/; sha256sum *.* > checksums.txt)\n\n.PHONY: release\nrelease: release-frontend release-server release-agent release-cli ## Release all binaries\n\nbundle-prepare: ## Prepare the bundles\n\tCGO_ENABLED=0 go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.45.0\n\nbundle-agent: bundle-prepare ## Create bundles for agent\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/agent.yaml --target ${DIST_DIR} --packager deb\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/agent.yaml --target ${DIST_DIR} --packager rpm\n\nbundle-server: bundle-prepare ## Create bundles for server\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/server.yaml --target ${DIST_DIR} --packager deb\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/server.yaml --target ${DIST_DIR} --packager rpm\n\nbundle-cli: bundle-prepare ## Create bundles for cli\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/cli.yaml --target ${DIST_DIR} --packager deb\n\tVERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/cli.yaml --target ${DIST_DIR} --packager rpm\n\n.PHONY: bundle\nbundle: bundle-agent bundle-server bundle-cli ## Create all bundles\n\n.PHONY: spellcheck\nspellcheck:\n\tpnpx cspell lint --no-progress --gitignore '{**,.*}/{*,.*}'\n\ttree --gitignore \\\n\t  -I 012_columns_rename_procs_to_steps.go \\\n\t  -I versioned_docs -I '*opensource.svg' | \\\n\t  pnpx cspell lint --no-progress stdin\n\n##@ Docs\n.PHONY: docs-dependencies\ndocs-dependencies: ## Install docs dependencies\n\t(cd docs/; pnpm install --frozen-lockfile)\n\n.PHONY: generate-docs\ngenerate-docs: ## Generate docs (currently only for the cli)\n\tCGO_ENABLED=0 go generate cmd/cli/app.go\n\tCGO_ENABLED=0 go generate cmd/server/openapi.go\n\n.PHONY: build-docs\nbuild-docs: generate-docs docs-dependencies ## Build the docs\n\t(cd docs/; pnpm build)\n\n##@ Man Pages\n.PHONY: man-cli\nman-cli: ## Generate man pages for cli\n\tmkdir -p dist/ && CGO_ENABLED=0 go run -tags man cmd/cli/man.go cmd/cli/app.go > dist/woodpecker-cli.man.1 && gzip -9 -f dist/woodpecker-cli.man.1\n\n.PHONY: man-agent\nman-agent: ## Generate man pages for agent\n\tmkdir -p dist/ && CGO_ENABLED=0 go run -tags man cmd/agent/man.go > dist/woodpecker-agent.man.1 && gzip -9 -f dist/woodpecker-agent.man.1\n\n.PHONY: man-server\nman-server: ## Generate man pages for server\n\tmkdir -p dist/ && CGO_ENABLED=0 go run -tags man go.woodpecker-ci.org/woodpecker/v3/cmd/server > dist/woodpecker-server.man.1 && gzip -9 -f dist/woodpecker-server.man.1\n\n.PHONY: man\nman: man-cli man-agent man-server ## Generate all man pages\n\nendif\n"
  },
  {
    "path": "README.md",
    "content": "# Woodpecker\n\n<p align=\"center\">\n  <a href=\"https://github.com/woodpecker-ci/woodpecker/\">\n    <img alt=\"Woodpecker\" src=\"docs/static/img/logo.svg\" width=\"220\"/>\n  </a>\n</p>\n<br/>\n<p align=\"center\">\n  <a href=\"https://ci.woodpecker-ci.org/repos/3780\" title=\"Pipeline Status\">\n    <img src=\"https://ci.woodpecker-ci.org/api/badges/3780/status.svg\" alt=\"Pipeline Status\">\n  </a>\n  <a href=\"https://codecov.io/gh/woodpecker-ci/woodpecker\">\n    <img src=\"https://codecov.io/gh/woodpecker-ci/woodpecker/branch/main/graph/badge.svg\" alt=\"Code coverage\">\n  </a>\n  <a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n    <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/svg-badge.svg\" alt=\"Translation status\" />\n  </a>\n  <a href=\"https://matrix.to/#/#woodpecker:matrix.org\" title=\"Join the Matrix space at https://matrix.to/#/#woodpecker:matrix.org\">\n    <img src=\"https://img.shields.io/matrix/woodpecker:matrix.org?label=matrix\" alt=\"Matrix space\">\n  </a>\n  <a href=\"https://goreportcard.com/report/go.woodpecker-ci.org/woodpecker/v3\" title=\"Go Report Card\">\n    <img src=\"https://goreportcard.com/badge/go.woodpecker-ci.org/woodpecker/v3\" alt=\"Go Report Card\">\n  </a>\n  <a href=\"https://pkg.go.dev/go.woodpecker-ci.org/woodpecker/v3\" title=\"go reference\">\n    <img src=\"https://pkg.go.dev/badge/go.woodpecker-ci.org/woodpecker/v3\" alt=\"go reference\">\n  </a>\n  <a href=\"https://github.com/woodpecker-ci/woodpecker/releases/latest\" title=\"GitHub release\">\n    <img src=\"https://img.shields.io/github/v/release/woodpecker-ci/woodpecker?sort=semver\" alt=\"GitHub release\">\n  </a>\n  <a href=\"https://hub.docker.com/r/woodpeckerci/woodpecker-server\" title=\"Docker pulls\">\n    <img src=\"https://img.shields.io/docker/pulls/woodpeckerci/woodpecker-server\" alt=\"Docker pulls\">\n  </a>\n  <a href=\"https://opensource.org/licenses/Apache-2.0\" title=\"License: Apache-2.0\">\n    <img src=\"https://img.shields.io/badge/License-Apache%202.0-blue.svg\" alt=\"License: Apache-2.0\">\n  </a>\n  <a href=\"https://bestpractices.coreinfrastructure.org/projects/5309\">\n    <img src=\"https://bestpractices.coreinfrastructure.org/projects/5309/badge\" alt=\"OpenSSF best practices\">\n  </a>\n  <a href=\"https://results.pre-commit.ci/repo/github/179344069\" title=\"pre-commit.ci\">\n    <img src=\"https://results.pre-commit.ci/badge/github/woodpecker-ci/woodpecker/main.svg\" alt=\"pre-commit.ci\">\n  </a>\n</p>\n<br/>\n\nWoodpecker is a simple, yet powerful CI/CD engine with great extensibility.\n\n![woodpecker](docs/woodpecker.png)\n\n## Installation & Resources\n\nWoodpecker can be installed in various ways (see the [Installation Instructions](https://woodpecker-ci.org/docs/administration/general)) and runs with SQLite as database by default.\nIt requires around 100 MB of RAM (Server) and 30 MB (Agent) at runtime in idle mode.\n\n## Support\n\nYou can support the project by becoming a backer on [Open Collective](https://opencollective.com/woodpecker-ci#category-CONTRIBUTE) or via [GitHub Sponsors](https://github.com/sponsors/woodpecker-ci).\n\n<a href=\"https://opencollective.com/woodpecker-ci\" target=\"_blank\"><img src=\"https://opencollective.com/woodpecker-ci/backers.svg?width=890\" alt=\"Open Collective backers\"></a>\n\n## Documentation\n\nOur documentation can be found at <https://woodpecker-ci.org/docs/intro>.\n\n## Translation\n\nWe have a self-hosted [Weblate](https://weblate.org/en/) instance at [translate.woodpecker-ci.org](https://translate.woodpecker-ci.org).\n\nAn overview of the current translation state is available at <https://translate.woodpecker-ci.org/projects/woodpecker-ci/#languages>.\n\n## Public Woodpecker Instances\n\nWoodpecker is used as the main CI/CD engine at [Codeberg](https://codeberg.org), an alternative Git hosting platform with a focus on privacy and free software development.\n\n## Plugins\n\nWoodpecker can be extended via plugins.\nThe [plugin overview website](https://woodpecker-ci.org/plugins) helps browsing available plugins.\nIt combines both plugins by the Woodpecker core team and community-maintained ones.\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=woodpecker-ci/woodpecker&type=Date)](https://star-history.com/#woodpecker-ci/woodpecker&Date)\n\n## License\n\nWoodpecker is Apache 2.0 licensed.\nThe source files have a header indicating which license they are under and what copyrights apply.\n\nEverything in `docs/` is licensed under the Creative Commons Attribution-ShareAlike 4.0 International Public License.\n"
  },
  {
    "path": "agent/log/line_writer.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"io\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/shared\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n)\n\n// LineWriter sends logs to the client.\ntype LineWriter struct {\n\tsync.Mutex\n\n\tpeer      rpc.Peer\n\tstepUUID  string\n\tnum       int\n\tstartTime time.Time\n\treplacer  *strings.Replacer\n}\n\n// NewLineWriter returns a new line reader.\nfunc NewLineWriter(peer rpc.Peer, stepUUID string, secret ...string) io.Writer {\n\tlw := &LineWriter{\n\t\tpeer:      peer,\n\t\tstepUUID:  stepUUID,\n\t\tstartTime: time.Now().UTC(),\n\t\treplacer:  shared.NewSecretsReplacer(secret),\n\t}\n\treturn lw\n}\n\nfunc (w *LineWriter) Write(p []byte) (n int, err error) {\n\tdata := string(p)\n\tif w.replacer != nil {\n\t\tdata = w.replacer.Replace(data)\n\t}\n\tlog.Trace().Str(\"step-uuid\", w.stepUUID).Msgf(\"grpc write line: %s\", data)\n\n\tline := &rpc.LogEntry{\n\t\tData:     []byte(strings.TrimSuffix(data, \"\\n\")), // remove trailing newline\n\t\tStepUUID: w.stepUUID,\n\t\tTime:     int64(time.Since(w.startTime).Seconds()),\n\t\tType:     rpc.LogEntryStdout,\n\t\tLine:     w.num,\n\t}\n\n\tw.num++\n\n\tw.peer.EnqueueLog(line)\n\treturn len(data), nil\n}\n"
  },
  {
    "path": "agent/log/line_writer_test.go",
    "content": "// Copyright 2019 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/mocks\"\n)\n\nfunc TestLineWriter(t *testing.T) {\n\tpeer := mocks.NewMockPeer(t)\n\tpeer.On(\"EnqueueLog\", mock.Anything)\n\n\tsecrets := []string{\"world\"}\n\tlw := log.NewLineWriter(peer, \"e9ea76a5-44a1-4059-9c4a-6956c478b26d\", secrets...)\n\n\t_, err := lw.Write([]byte(\"hello world\\n\"))\n\tassert.NoError(t, err)\n\t_, err = lw.Write([]byte(\"the previous line had no newline at the end\"))\n\tassert.NoError(t, err)\n\n\tpeer.AssertCalled(t, \"EnqueueLog\", &rpc.LogEntry{\n\t\tStepUUID: \"e9ea76a5-44a1-4059-9c4a-6956c478b26d\",\n\t\tTime:     0,\n\t\tType:     rpc.LogEntryStdout,\n\t\tLine:     0,\n\t\tData:     []byte(\"hello ********\"),\n\t})\n\n\tpeer.AssertCalled(t, \"EnqueueLog\", &rpc.LogEntry{\n\t\tStepUUID: \"e9ea76a5-44a1-4059-9c4a-6956c478b26d\",\n\t\tTime:     0,\n\t\tType:     rpc.LogEntryStdout,\n\t\tLine:     1,\n\t\tData:     []byte(\"the previous line had no newline at the end\"),\n\t})\n\n\tpeer.AssertExpectations(t)\n}\n"
  },
  {
    "path": "agent/logger.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"io\"\n\n\t\"github.com/rs/zerolog\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\tpipeline_utils \"go.woodpecker-ci.org/woodpecker/v3/pipeline/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n)\n\nfunc (r *Runner) createLogger(_logger zerolog.Logger, workflow *rpc.Workflow) logging.Logger {\n\treturn func(step *backend_types.Step, rc io.ReadCloser) error {\n\t\tdefer rc.Close()\n\n\t\tlogger := _logger.With().\n\t\t\tStr(\"image\", step.Image).\n\t\t\tLogger()\n\n\t\tvar secrets []string\n\t\tfor _, secret := range workflow.Config.Secrets {\n\t\t\tsecrets = append(secrets, secret.Value)\n\t\t}\n\n\t\tlogger.Debug().Msg(\"log stream opened\")\n\n\t\tlogStream := log.NewLineWriter(r.client, step.UUID, secrets...)\n\t\tif err := pipeline_utils.CopyLineByLine(logStream, rc, pipeline.MaxLogLineLength); err != nil {\n\t\t\tlogger.Error().Err(err).Msg(\"copy limited logStream part\")\n\t\t}\n\n\t\tlogger.Debug().Msg(\"log stream copied, close ...\")\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "agent/rpc/auth_client_grpc.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"google.golang.org/grpc\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n)\n\nconst authClientTimeout = time.Second * 5\n\ntype AuthClient struct {\n\tclient     proto.WoodpeckerAuthClient\n\tconn       *grpc.ClientConn\n\tagentToken string\n\tagentID    int64\n}\n\nfunc NewAuthGrpcClient(conn *grpc.ClientConn, agentToken string, agentID int64) *AuthClient {\n\tclient := new(AuthClient)\n\tclient.client = proto.NewWoodpeckerAuthClient(conn)\n\tclient.conn = conn\n\tclient.agentToken = agentToken\n\tclient.agentID = agentID\n\treturn client\n}\n\nfunc (c *AuthClient) AgentID() int64 {\n\treturn c.agentID\n}\n\nfunc (c *AuthClient) Auth(ctx context.Context) (string, int64, error) {\n\tctx, cancel := context.WithTimeout(ctx, authClientTimeout)\n\tdefer cancel()\n\n\treq := &proto.AuthRequest{\n\t\tAgentToken: c.agentToken,\n\t\tAgentId:    c.agentID,\n\t}\n\n\tres, err := c.client.Auth(ctx, req)\n\tif err != nil {\n\t\treturn \"\", -1, err\n\t}\n\n\tc.agentID = res.GetAgentId()\n\n\treturn res.GetAccessToken(), c.agentID, nil\n}\n"
  },
  {
    "path": "agent/rpc/auth_client_grpc_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n)\n\nfunc TestAuthClientAgentID(t *testing.T) {\n\tconn, err := grpc.NewClient(\"localhost:0\", grpc.WithTransportCredentials(insecure.NewCredentials()))\n\tassert.NoError(t, err)\n\tdefer conn.Close()\n\n\tclient := NewAuthGrpcClient(conn, \"test-token\", 42)\n\tassert.Equal(t, int64(42), client.AgentID())\n}\n"
  },
  {
    "path": "agent/rpc/auth_interceptor.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/metadata\"\n)\n\n// AuthInterceptor is a client interceptor for authentication.\ntype AuthInterceptor struct {\n\tauthClient  *AuthClient\n\taccessToken string\n}\n\n// NewAuthInterceptor returns a new auth interceptor.\nfunc NewAuthInterceptor(ctx context.Context, authClient *AuthClient, refreshDuration time.Duration) (*AuthInterceptor, error) {\n\tinterceptor := &AuthInterceptor{\n\t\tauthClient: authClient,\n\t}\n\n\terr := interceptor.scheduleRefreshToken(ctx, refreshDuration)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn interceptor, nil\n}\n\n// Unary returns a client interceptor to authenticate unary RPC.\nfunc (interceptor *AuthInterceptor) Unary() grpc.UnaryClientInterceptor {\n\treturn func(\n\t\tctx context.Context,\n\t\tmethod string,\n\t\treq, reply any,\n\t\tcc *grpc.ClientConn,\n\t\tinvoker grpc.UnaryInvoker,\n\t\topts ...grpc.CallOption,\n\t) error {\n\t\treturn invoker(interceptor.attachToken(ctx), method, req, reply, cc, opts...)\n\t}\n}\n\n// Stream returns a client interceptor to authenticate stream RPC.\nfunc (interceptor *AuthInterceptor) Stream() grpc.StreamClientInterceptor {\n\treturn func(\n\t\tctx context.Context,\n\t\tdesc *grpc.StreamDesc,\n\t\tcc *grpc.ClientConn,\n\t\tmethod string,\n\t\tstreamer grpc.Streamer,\n\t\topts ...grpc.CallOption,\n\t) (grpc.ClientStream, error) {\n\t\treturn streamer(interceptor.attachToken(ctx), desc, cc, method, opts...)\n\t}\n}\n\nfunc (interceptor *AuthInterceptor) attachToken(ctx context.Context) context.Context {\n\treturn metadata.AppendToOutgoingContext(ctx, \"token\", interceptor.accessToken)\n}\n\nfunc (interceptor *AuthInterceptor) scheduleRefreshToken(ctx context.Context, refreshInterval time.Duration) error {\n\terr := interceptor.refreshToken(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgo func() {\n\t\twait := refreshInterval\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-time.After(wait):\n\t\t\t\terr := interceptor.refreshToken(ctx)\n\t\t\t\tif err != nil {\n\t\t\t\t\twait = time.Second\n\t\t\t\t} else {\n\t\t\t\t\twait = refreshInterval\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (interceptor *AuthInterceptor) refreshToken(ctx context.Context) error {\n\taccessToken, _, err := interceptor.authClient.Auth(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tinterceptor.accessToken = accessToken\n\tlog.Trace().Msg(\"token refreshed\")\n\n\treturn nil\n}\n"
  },
  {
    "path": "agent/rpc/client_grpc.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/rs/zerolog/log\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/codes\"\n\t\"google.golang.org/grpc/connectivity\"\n\t\"google.golang.org/grpc/status\"\n\tgrpc_proto \"google.golang.org/protobuf/proto\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n)\n\nvar (\n\tErrConnectionLost = errors.New(\"connection to server lost\")\n\terrNotConnected   = errors.New(\"grpc: not connected\")\n)\n\nconst (\n\t// Set grpc version on compile time to compare against server version response.\n\tClientGrpcVersion int32 = proto.Version\n\n\t// Maximum size of an outgoing log message.\n\t// Picked to prevent it from going over GRPC size limit (4 MiB) with a large safety margin.\n\tmaxLogBatchSize int = 1 * 1024 * 1024\n\n\t// Maximum amount of time between sending consecutive batched log messages.\n\t// Controls the delay between the CI job generating a log record, and web users receiving it.\n\tmaxLogFlushPeriod time.Duration = time.Second\n)\n\ntype client struct {\n\tclient proto.WoodpeckerClient\n\tconn   *grpc.ClientConn\n\tlogs   chan *proto.LogEntry\n\t// connectionRetryTimeout is the maximum time to wait for a connection to be\n\t// restored before the agent gives up and exits. Zero means infinite.\n\t// Maps directly onto backoff.WithMaxElapsedTime.\n\tconnectionRetryTimeout time.Duration\n}\n\n// NewGrpcClient returns a new grpc Client.\nfunc NewGrpcClient(ctx context.Context, conn *grpc.ClientConn, opts ...ClientOption) rpc.Peer {\n\tclient := new(client)\n\tclient.client = proto.NewWoodpeckerClient(conn)\n\tclient.conn = conn\n\tclient.logs = make(chan *proto.LogEntry, 10) // max memory use: 10 lines * 1 MiB\n\n\tfor _, opt := range opts {\n\t\topt(client)\n\t}\n\n\tgo client.processLogs(ctx)\n\treturn client\n}\n\ntype ClientOption func(c *client)\n\nfunc SetConnectionRetryTimeout(d time.Duration) ClientOption {\n\tif d == 0 {\n\t\tlog.Warn().Msg(\"connection retry timeout set to infinite\")\n\t}\n\treturn func(c *client) {\n\t\tc.connectionRetryTimeout = d\n\t}\n}\n\n// IsConnected reports whether the underlying gRPC connection is currently up.\n// It is a pure observer with no side effects.\nfunc (c *client) IsConnected() bool {\n\tstate := c.conn.GetState()\n\treturn state == connectivity.Ready || state == connectivity.Idle\n}\n\n// retryOpts returns the backoff options used for every retry loop in this\n// file. The exponential backoff parameters preserve the original tuning\n// (10 ms initial, 10 s cap), and connectionRetryTimeout is wired straight into\n// WithMaxElapsedTime — when it elapses, backoff.Retry returns the last error,\n// which we translate into ErrConnectionLost in retryRPC.\nfunc (c *client) retryOpts(op string) []backoff.RetryOption {\n\tb := backoff.NewExponentialBackOff()\n\tb.MaxInterval = 10 * time.Second          //nolint:mnd\n\tb.InitialInterval = 10 * time.Millisecond //nolint:mnd\n\n\tnotify := func(err error, next time.Duration) {\n\t\t// The \"too_many_pings\" GOAWAY is well-known noise; demote to trace.\n\t\t// See https://github.com/woodpecker-ci/woodpecker/issues/717\n\t\tif strings.Contains(err.Error(), `\"too_many_pings\"`) {\n\t\t\tlog.Trace().Err(err).Dur(\"retry_in\", next).Msgf(\"grpc: %s(): too many keepalive pings without sending data\", op)\n\t\t\treturn\n\t\t}\n\t\tif errors.Is(err, errNotConnected) {\n\t\t\tlog.Warn().Dur(\"retry_in\", next).Msgf(\"grpc: %s() waiting for server connection...\", op)\n\t\t\treturn\n\t\t}\n\t\tlog.Warn().Err(err).Dur(\"retry_in\", next).Msgf(\"grpc error: %s(): code: %v\", op, status.Code(err))\n\t}\n\n\treturn []backoff.RetryOption{\n\t\tbackoff.WithBackOff(b),\n\t\tbackoff.WithMaxElapsedTime(c.connectionRetryTimeout),\n\t\tbackoff.WithNotify(notify),\n\t}\n}\n\n// retryRPC is the workhorse used by every RPC method in this file. It runs op\n// under backoff.Retry with the standard options, and translates the few\n// special outcomes the callers care about:\n//\n//   - op succeeds          -> (result, nil)\n//   - ctx canceled         -> (zero, nil)            same contract as before\n//   - MaxElapsedTime hit   -> (zero, ErrConnectionLost)\n//   - permanent (fatal)    -> (zero, underlying err)\n//\n// The op closure is responsible for:\n//   - returning errNotConnected when IsConnected() is false (Retry will sleep\n//     and call again — same effect as the old \"if !c.IsConnected()\" preamble)\n//   - returning backoff.Permanent(err) for unrecoverable gRPC codes\n//   - returning the raw error for retryable codes (Aborted/DataLoss/...)\nfunc retryRPC[T any](ctx context.Context, c *client, opName string, op backoff.Operation[T]) (T, error) {\n\tres, err := backoff.Retry(ctx, op, c.retryOpts(opName)...)\n\tif err == nil {\n\t\treturn res, nil\n\t}\n\n\tvar zero T\n\n\t// Context canceled while inside Retry: callers historically swallowed this\n\t// and returned a zero-value error, so preserve that contract.\n\tif ctxErr := context.Cause(ctx); ctxErr != nil && errors.Is(err, ctxErr) {\n\t\tlog.Debug().Err(err).Msgf(\"grpc: %s(): context canceled\", opName)\n\t\treturn zero, nil\n\t}\n\n\t// MaxElapsedTime exhausted while we were still in errNotConnected — give up.\n\tif errors.Is(err, errNotConnected) {\n\t\tlog.Error().Msg(\"grpc: connection lost, giving up\")\n\t\treturn zero, ErrConnectionLost\n\t}\n\n\tlog.Error().Err(err).Msgf(\"grpc error: %s(): code: %v\", opName, status.Code(err))\n\treturn zero, err\n}\n\n// classifyRPCErr inspects a gRPC error and returns either the same error (for\n// retryable codes) or a backoff.Permanent wrapping it (for fatal codes). It is\n// the single source of truth for which gRPC codes are worth retrying.\nfunc classifyRPCErr(ctx context.Context, err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\tswitch status.Code(err) {\n\tcase codes.Canceled:\n\t\t// If our own ctx is dead, surface that as the cause so Retry's\n\t\t// context.Cause(ctx) check exits cleanly. Otherwise it's a server-side\n\t\t// cancel that we treat as permanent.\n\t\tif ctx.Err() != nil {\n\t\t\treturn backoff.Permanent(ctx.Err())\n\t\t}\n\t\treturn backoff.Permanent(err)\n\tcase codes.Aborted,\n\t\tcodes.DataLoss,\n\t\tcodes.DeadlineExceeded,\n\t\tcodes.Internal,\n\t\tcodes.Unavailable:\n\t\treturn err\n\tdefault:\n\t\treturn backoff.Permanent(err)\n\t}\n}\n\n// Version returns the server- & grpc-version.\nfunc (c *client) Version(ctx context.Context) (*rpc.Version, error) {\n\tres, err := c.client.Version(ctx, &proto.Empty{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &rpc.Version{\n\t\tGrpcVersion:   res.GrpcVersion,\n\t\tServerVersion: res.ServerVersion,\n\t}, nil\n}\n\n// Next returns the next workflow in the queue.\nfunc (c *client) Next(ctx context.Context, filter rpc.Filter) (*rpc.Workflow, error) {\n\treq := &proto.NextRequest{Filter: &proto.Filter{Labels: filter.Labels}}\n\n\tres, err := retryRPC(ctx, c, \"next\", func() (*proto.NextResponse, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Next(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res == nil || res.GetWorkflow() == nil {\n\t\treturn nil, nil\n\t}\n\n\tw := &rpc.Workflow{\n\t\tID:      res.GetWorkflow().GetId(),\n\t\tTimeout: res.GetWorkflow().GetTimeout(),\n\t\tConfig:  new(backend_types.Config),\n\t}\n\tif err := json.Unmarshal(res.GetWorkflow().GetPayload(), w.Config); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not unmarshal workflow config of '%s'\", w.ID)\n\t}\n\treturn w, nil\n}\n\n// Wait blocks until the workflow with the given ID is marked as completed or canceled by the server.\nfunc (c *client) Wait(ctx context.Context, workflowID string) (canceled bool, err error) {\n\treq := &proto.WaitRequest{Id: workflowID}\n\n\tresp, err := retryRPC(ctx, c, \"wait\", func() (*proto.WaitResponse, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Wait(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif resp == nil {\n\t\treturn false, nil\n\t}\n\treturn resp.GetCanceled(), nil\n}\n\n// Init signals the workflow is initialized.\nfunc (c *client) Init(ctx context.Context, workflowID string, state rpc.WorkflowState) error {\n\treq := &proto.InitRequest{\n\t\tId: workflowID,\n\t\tState: &proto.WorkflowState{\n\t\t\tStarted:  state.Started,\n\t\t\tFinished: state.Finished,\n\t\t\tError:    state.Error,\n\t\t\tCanceled: state.Canceled,\n\t\t},\n\t}\n\n\t_, err := retryRPC(ctx, c, \"init\", func() (*proto.Empty, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Init(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n\n// Done let agent signal to server the workflow has stopped.\nfunc (c *client) Done(ctx context.Context, workflowID string, state rpc.WorkflowState) error {\n\treq := &proto.DoneRequest{\n\t\tId: workflowID,\n\t\tState: &proto.WorkflowState{\n\t\t\tStarted:  state.Started,\n\t\t\tFinished: state.Finished,\n\t\t\tError:    state.Error,\n\t\t\tCanceled: state.Canceled,\n\t\t},\n\t}\n\n\t_, err := retryRPC(ctx, c, \"done\", func() (*proto.Empty, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Done(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n\n// Extend extends the workflow deadline.\nfunc (c *client) Extend(ctx context.Context, workflowID string) error {\n\treq := &proto.ExtendRequest{Id: workflowID}\n\n\t_, err := retryRPC(ctx, c, \"extend\", func() (*proto.Empty, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Extend(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n\n// Update let agent updates the step state at the server.\nfunc (c *client) Update(ctx context.Context, workflowID string, state rpc.StepState) error {\n\treq := &proto.UpdateRequest{\n\t\tId: workflowID,\n\t\tState: &proto.StepState{\n\t\t\tStepUuid: state.StepUUID,\n\t\t\tStarted:  state.Started,\n\t\t\tFinished: state.Finished,\n\t\t\tExited:   state.Exited,\n\t\t\tExitCode: int32(state.ExitCode),\n\t\t\tError:    state.Error,\n\t\t\tCanceled: state.Canceled,\n\t\t\tSkipped:  state.Skipped,\n\t\t},\n\t}\n\n\t_, err := retryRPC(ctx, c, \"update\", func() (*proto.Empty, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.Update(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n\n// EnqueueLog queues the log entry to be written in a batch later.\nfunc (c *client) EnqueueLog(logEntry *rpc.LogEntry) {\n\tc.logs <- &proto.LogEntry{\n\t\tStepUuid: logEntry.StepUUID,\n\t\tData:     logEntry.Data,\n\t\tLine:     int32(logEntry.Line),\n\t\tTime:     logEntry.Time,\n\t\tType:     int32(logEntry.Type),\n\t}\n}\n\nfunc (c *client) processLogs(ctx context.Context) {\n\tvar entries []*proto.LogEntry\n\tvar bytes int\n\n\tsend := func() {\n\t\tif len(entries) == 0 {\n\t\t\treturn\n\t\t}\n\n\t\tlog.Debug().\n\t\t\tInt(\"entries\", len(entries)).\n\t\t\tInt(\"bytes\", bytes).\n\t\t\tMsg(\"log drain: sending queued logs\")\n\n\t\tif err := c.sendLogs(ctx, entries); err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"log drain: could not send logs to server\")\n\t\t}\n\n\t\t// even if send failed, we don't have infinite memory; retry has already been used\n\t\tentries = entries[:0]\n\t\tbytes = 0\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase entry, ok := <-c.logs:\n\t\t\tif !ok {\n\t\t\t\tlog.Info().Msg(\"log drain: channel closed\")\n\t\t\t\tsend()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tentries = append(entries, entry)\n\t\t\tbytes += grpc_proto.Size(entry)\n\n\t\t\tif bytes >= maxLogBatchSize {\n\t\t\t\tsend()\n\t\t\t}\n\n\t\tcase <-time.After(maxLogFlushPeriod):\n\t\t\tsend()\n\t\t}\n\t}\n}\n\nfunc (c *client) sendLogs(ctx context.Context, entries []*proto.LogEntry) error {\n\treq := &proto.LogRequest{LogEntries: entries}\n\n\t// sendLogs intentionally does not gate on IsConnected — the original code\n\t// didn't either. backoff.Retry will keep trying through transient transport\n\t// errors until MaxElapsedTime elapses.\n\t_, err := retryRPC(ctx, c, \"log\", func() (*proto.Empty, error) {\n\t\tr, err := c.client.Log(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n\nfunc (c *client) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) {\n\treq := &proto.RegisterAgentRequest{\n\t\tInfo: &proto.AgentInfo{\n\t\t\tPlatform:     info.Platform,\n\t\t\tBackend:      info.Backend,\n\t\t\tVersion:      info.Version,\n\t\t\tCapacity:     int32(info.Capacity),\n\t\t\tCustomLabels: info.CustomLabels,\n\t\t},\n\t}\n\n\tres, err := c.client.RegisterAgent(ctx, req)\n\treturn res.GetAgentId(), err\n}\n\nfunc (c *client) UnregisterAgent(ctx context.Context) error {\n\t_, err := c.client.UnregisterAgent(ctx, &proto.Empty{})\n\treturn err\n}\n\nfunc (c *client) ReportHealth(ctx context.Context) error {\n\treq := &proto.ReportHealthRequest{Status: \"I am alive!\"}\n\n\t_, err := retryRPC(ctx, c, \"report_health\", func() (*proto.Empty, error) {\n\t\tif !c.IsConnected() {\n\t\t\treturn nil, errNotConnected\n\t\t}\n\t\tr, err := c.client.ReportHealth(ctx, req)\n\t\treturn r, classifyRPCErr(ctx, err)\n\t})\n\treturn err\n}\n"
  },
  {
    "path": "agent/runner.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"google.golang.org/grpc/metadata\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\tpipeline_runtime \"go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst shutdownTimeout = time.Second * 5\n\ntype Runner struct {\n\tclient   rpc.Peer\n\tfilter   rpc.Filter\n\thostname string\n\tcounter  *State\n\tbackend  backend_types.Backend\n}\n\nfunc NewRunner(workEngine rpc.Peer, f rpc.Filter, h string, state *State, backend backend_types.Backend) Runner {\n\treturn Runner{\n\t\tclient:   workEngine,\n\t\tfilter:   f,\n\t\thostname: h,\n\t\tcounter:  state,\n\t\tbackend:  backend,\n\t}\n}\n\nfunc GetShutdownContext() (context.Context, context.CancelFunc) {\n\treturn context.WithTimeout(context.Background(), shutdownTimeout)\n}\n\n// TODO: refactor this big function into subfunctions in it's own subpackage\n\n// Run executes a workflow using a backend, tracks its state and reports the state back to the server.\nfunc (r *Runner) Run(runnerCtx context.Context) error {\n\tlog.Debug().Msg(\"request next execution\")\n\n\t// Preserve metadata AND cancellation from runnerCtx.\n\tmeta, _ := metadata.FromOutgoingContext(runnerCtx)\n\tctxMeta := metadata.NewOutgoingContext(runnerCtx, meta)\n\n\t// Fetch next workflow from the queue\n\tworkflow, err := r.client.Next(runnerCtx, r.filter)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif workflow == nil {\n\t\treturn nil\n\t}\n\n\t// Compute workflow timeout\n\ttimeout := time.Hour\n\tif minutes := workflow.Timeout; minutes != 0 {\n\t\ttimeout = time.Duration(minutes) * time.Minute\n\t}\n\n\trepoName := extractRepositoryName(workflow.Config)       // hack\n\tpipelineNumber := extractPipelineNumber(workflow.Config) // hack\n\n\t// Track workflow execution in runner state\n\tr.counter.Add(workflow.ID, timeout, repoName, pipelineNumber)\n\tdefer r.counter.Done(workflow.ID)\n\n\tlogger := log.With().\n\t\tStr(\"repo\", repoName).\n\t\tStr(\"pipeline\", pipelineNumber).\n\t\tStr(\"workflow_id\", workflow.ID).\n\t\tLogger()\n\n\tlogger.Debug().Msg(\"received execution\")\n\n\t// Workflow execution context.\n\t// This context is the SINGLE source of truth for cancellation.\n\tworkflowCtx, _ := context.WithTimeout(ctxMeta, timeout) //nolint:govet\n\tworkflowCtx, cancelWorkflowCtx := context.WithCancelCause(workflowCtx)\n\tdefer cancelWorkflowCtx(nil)\n\n\t// Add sigterm support for internal context.\n\t// Required to be able to terminate the running workflow by external signals.\n\tworkflowCtx = utils.WithContextSigtermCallback(workflowCtx, func() {\n\t\tlogger.Error().Msg(\"received sigterm termination signal\")\n\t\t// WithContextSigtermCallback would cancel the context too, but  we want our own custom error\n\t\tcancelWorkflowCtx(pipeline_errors.ErrCancel)\n\t})\n\n\t// Listen for remote cancel events (UI / API).\n\t// When canceled, we MUST cancel the workflow context\n\t// so that workflow execution stops immediately.\n\tgo func() {\n\t\tlogger.Debug().Msg(\"start listening for server side cancel signal\")\n\n\t\tif canceled, err := r.client.Wait(workflowCtx, workflow.ID); err != nil {\n\t\t\tlogger.Error().Err(err).Msg(\"server returned unexpected err while waiting for workflow to finish run\")\n\t\t\tcancelWorkflowCtx(err)\n\t\t} else {\n\t\t\tif canceled {\n\t\t\t\tlogger.Debug().Err(err).Msg(\"server side cancel signal received\")\n\t\t\t\tcancelWorkflowCtx(pipeline_errors.ErrCancel)\n\t\t\t}\n\t\t\t// Wait returned without error, meaning the workflow finished normally\n\t\t\tlogger.Debug().Msg(\"cancel listener exited normally\")\n\t\t}\n\t}()\n\n\t// Periodically extend the workflow lease while running\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-workflowCtx.Done():\n\t\t\t\tlogger.Debug().Msg(\"workflow context done\")\n\t\t\t\treturn\n\n\t\t\tcase <-time.After(constant.TaskTimeout / 3):\n\t\t\t\tlogger.Debug().Msg(\"renewing workflow lease\")\n\t\t\t\tif err := r.client.Extend(workflowCtx, workflow.ID); err != nil {\n\t\t\t\t\tlogger.Error().Err(err).Msg(\"failed to extend workflow lease\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\tstate := rpc.WorkflowState{\n\t\tStarted: time.Now().Unix(),\n\t}\n\n\tif err := r.client.Init(runnerCtx, workflow.ID, state); err != nil {\n\t\tlogger.Error().Err(err).Msg(\"signaling workflow initialization to server failed\")\n\t\t// We have an error, maybe the server is currently unreachable or other server-side errors occurred.\n\t\t// So let's clean up and end this not yet started workflow run.\n\t\tcancelWorkflowCtx(err)\n\t\treturn err\n\t}\n\n\t// Enrich workflow env with agent info\n\t// TODO: find better way to track this state\n\tfor _, stage := range workflow.Config.Stages {\n\t\tfor _, step := range stage.Steps {\n\t\t\tstep.Environment[\"CI_MACHINE\"] = r.hostname\n\t\t\tstep.Environment[\"CI_SYSTEM_PLATFORM\"] = runtime.GOOS + \"/\" + runtime.GOARCH\n\t\t}\n\t}\n\n\t// Run pipeline\n\terr = pipeline_runtime.New(\n\t\tworkflow.Config,\n\t\tr.backend,\n\t\tpipeline_runtime.WithContext(workflowCtx),\n\t\tpipeline_runtime.WithTaskUUID(fmt.Sprint(workflow.ID)),\n\t\tpipeline_runtime.WithLogger(r.createLogger(logger, workflow)),\n\t\tpipeline_runtime.WithTracer(r.createTracer(ctxMeta, logger, workflow)),\n\t\tpipeline_runtime.WithDescription(map[string]string{\n\t\t\t\"workflow_id\":     workflow.ID,\n\t\t\t\"repo\":            repoName,\n\t\t\t\"pipeline_number\": pipelineNumber,\n\t\t}),\n\t).Run(runnerCtx)\n\n\tstate.Finished = time.Now().Unix()\n\n\tif err != nil {\n\t\tstate.Error = err.Error()\n\t\tif errors.Is(err, pipeline_errors.ErrCancel) {\n\t\t\tstate.Canceled = true\n\t\t\t// cleanup joined error messages\n\t\t\tstate.Error = pipeline_errors.ErrCancel.Error()\n\t\t}\n\t}\n\n\tlogger.Debug().\n\t\tStr(\"error\", state.Error).\n\t\tBool(\"canceled\", state.Canceled).\n\t\tMsg(\"workflow finished\")\n\n\t// Update workflow state\n\tdoneCtx := runnerCtx //nolint:contextcheck\n\tif doneCtx.Err() != nil {\n\t\tshutdownCtx, shutdownCtxCancel := GetShutdownContext()\n\t\tdefer shutdownCtxCancel()\n\t\tdoneCtx = shutdownCtx\n\t}\n\n\tif err := r.client.Done(doneCtx, workflow.ID, state); err != nil {\n\t\tlogger.Error().Err(err).Msg(\"failed to update workflow status\")\n\t} else {\n\t\tlogger.Debug().Msg(\"signaling workflow stopped done\")\n\t}\n\n\treturn nil\n}\n\nfunc extractRepositoryName(config *backend_types.Config) string {\n\treturn config.Stages[0].Steps[0].Environment[\"CI_REPO\"]\n}\n\nfunc extractPipelineNumber(config *backend_types.Config) string {\n\treturn config.Stages[0].Steps[0].Environment[\"CI_PIPELINE_NUMBER\"]\n}\n"
  },
  {
    "path": "agent/state.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"sync\"\n\t\"time\"\n)\n\ntype State struct {\n\tsync.Mutex `json:\"-\"`\n\tPolling    int             `json:\"polling_count\"`\n\tRunning    int             `json:\"running_count\"`\n\tMetadata   map[string]Info `json:\"running\"`\n}\n\ntype Info struct {\n\tID       string        `json:\"id\"`\n\tRepo     string        `json:\"repository\"`\n\tPipeline string        `json:\"pipeline_number\"`\n\tStarted  time.Time     `json:\"pipeline_started\"`\n\tTimeout  time.Duration `json:\"pipeline_timeout\"`\n}\n\nfunc (s *State) Add(id string, timeout time.Duration, repo, pipeline string) {\n\ts.Lock()\n\ts.Polling--\n\ts.Running++\n\ts.Metadata[id] = Info{\n\t\tID:       id,\n\t\tRepo:     repo,\n\t\tPipeline: pipeline,\n\t\tTimeout:  timeout,\n\t\tStarted:  time.Now().UTC(),\n\t}\n\ts.Unlock()\n}\n\nfunc (s *State) Done(id string) {\n\ts.Lock()\n\ts.Polling++\n\ts.Running--\n\tdelete(s.Metadata, id)\n\ts.Unlock()\n}\n\nfunc (s *State) Healthy() bool {\n\ts.Lock()\n\tdefer s.Unlock()\n\tnow := time.Now()\n\tbuf := time.Hour // 1 hour buffer\n\tfor _, item := range s.Metadata {\n\t\tif now.After(item.Started.Add(item.Timeout).Add(buf)) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (s *State) WriteTo(w io.Writer) (int64, error) {\n\ts.Lock()\n\tout, err := json.Marshal(s)\n\ts.Unlock()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tret, err := w.Write(out)\n\treturn int64(ret), err\n}\n"
  },
  {
    "path": "agent/tracer.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n)\n\nfunc (r *Runner) createTracer(ctxMeta context.Context, logger zerolog.Logger, workflow *rpc.Workflow) tracing.TraceFunc {\n\treturn func(state *state.State) error {\n\t\tstepLogger := logger.With().\n\t\t\tStr(\"image\", state.CurrStep.Image).\n\t\t\tStr(\"workflow_id\", workflow.ID).\n\t\t\tErr(state.CurrStepState.Error).\n\t\t\tInt(\"exit_code\", state.CurrStepState.ExitCode).\n\t\t\tBool(\"exited\", state.CurrStepState.Exited).\n\t\t\tLogger()\n\n\t\tstepState := rpc.StepState{\n\t\t\tStepUUID: state.CurrStep.UUID,\n\t\t\tExited:   state.CurrStepState.Exited,\n\t\t\tExitCode: state.CurrStepState.ExitCode,\n\t\t\tStarted:  state.CurrStepState.Started,\n\t\t\tCanceled: errors.Is(state.CurrStepState.Error, pipeline_errors.ErrCancel),\n\t\t\tSkipped:  state.CurrStepState.Skipped,\n\t\t}\n\t\tif state.CurrStepState.Error != nil {\n\t\t\tstepState.Error = state.CurrStepState.Error.Error()\n\t\t}\n\t\tif state.CurrStepState.Exited {\n\t\t\tstepState.Finished = time.Now().Unix()\n\t\t}\n\n\t\tstepLogger.Debug().Msg(\"update step status\")\n\t\tdefer stepLogger.Debug().Msg(\"update step status complete\")\n\n\t\treturn r.client.Update(ctxMeta, workflow.ID, stepState)\n\t}\n}\n"
  },
  {
    "path": "checkmake.ini",
    "content": "[maxbodylength]\ndisabled = true\n"
  },
  {
    "path": "cli/README.md",
    "content": "# Woodpecker CLI\n\nCommand line client for the Woodpecker continuous integration server.\n\nPlease see the official documentation at <https://woodpecker-ci.org/docs/cli>\n"
  },
  {
    "path": "cli/admin/admin.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage admin\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin/loglevel\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin/org\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin/secret\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin/user\"\n)\n\n// Command exports the admin command set.\nvar Command = &cli.Command{\n\tName:  \"admin\",\n\tUsage: \"manage server settings\",\n\tCommands: []*cli.Command{\n\t\tloglevel.Command,\n\t\torg.Command,\n\t\tregistry.Command,\n\t\tsecret.Command,\n\t\tuser.Command,\n\t},\n}\n"
  },
  {
    "path": "cli/admin/loglevel/loglevel.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage loglevel\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the log-level command used to change the servers log-level.\nvar Command = &cli.Command{\n\tName:      \"log-level\",\n\tArgsUsage: \"[level]\",\n\tUsage:     \"retrieve log level from server, or set it with [level]\",\n\tAction:    logLevel,\n}\n\nfunc logLevel(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar ll *woodpecker.LogLevel\n\targ := c.Args().First()\n\tif arg != \"\" {\n\t\tlvl, err := zerolog.ParseLevel(arg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tll, err = client.SetLogLevel(&woodpecker.LogLevel{\n\t\t\tLevel: lvl.String(),\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tll, err = client.LogLevel()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlog.Info().Msgf(\"log level: %s\", ll.Level)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/admin/org/org_list.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage org\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar Command = &cli.Command{\n\tName:  \"org\",\n\tUsage: \"manage organizations\",\n\tCommands: []*cli.Command{\n\t\torgListCmd,\n\t},\n}\n\nvar orgListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list organizations\",\n\tArgsUsage: \"\",\n\tAction:    orgList,\n\tFlags: []cli.Flag{\n\t\tcommon.FormatFlag(tmplOrgList, true),\n\t},\n}\n\nfunc orgList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.ListOptions{}\n\n\tlist, err := client.OrgList(opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(orgFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, org := range list {\n\t\tif err := tmpl.Execute(os.Stdout, org); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for org list items.\nvar tmplOrgList = \"\\x1b[33m{{ .Name }} \\x1b[0m\" + `\nOrganization ID: {{ .ID }}\n`\n\nvar orgFuncMap = template.FuncMap{\n\t\"list\": func(s []string) string {\n\t\treturn strings.Join(s, \", \")\n\t},\n}\n"
  },
  {
    "path": "cli/admin/registry/registry.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the registry command set.\nvar Command = &cli.Command{\n\tName:  \"registry\",\n\tUsage: \"manage global registries\",\n\tCommands: []*cli.Command{\n\t\tregistryCreateCmd,\n\t\tregistryDeleteCmd,\n\t\tregistryListCmd,\n\t\tregistryShowCmd,\n\t\tregistryUpdateCmd,\n\t},\n}\n"
  },
  {
    "path": "cli/admin/registry/registry_add.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryCreateCmd = &cli.Command{\n\tName:   \"add\",\n\tUsage:  \"add a registry\",\n\tAction: registryCreate,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryCreate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\t_, err = client.GlobalRegistryCreate(registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/admin/registry/registry_list.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryListCmd = &cli.Command{\n\tName:   \"ls\",\n\tUsage:  \"list registries\",\n\tAction: registryList,\n\tFlags: []cli.Flag{\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.RegistryListOptions{}\n\n\tlist, err := client.GlobalRegistryList(opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, registry := range list {\n\t\tif err := tmpl.Execute(os.Stdout, registry); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for registry list information.\nvar tmplRegistryList = \"\\x1b[33m{{ .Address }} \\x1b[0m\" + `\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n`\n"
  },
  {
    "path": "cli/admin/registry/registry_rm.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryDeleteCmd = &cli.Command{\n\tName:   \"rm\",\n\tUsage:  \"remove a registry\",\n\tAction: registryDelete,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t},\n}\n\nfunc registryDelete(ctx context.Context, c *cli.Command) error {\n\thostname := c.String(\"hostname\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.GlobalRegistryDelete(hostname)\n}\n"
  },
  {
    "path": "cli/admin/registry/registry_set.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryUpdateCmd = &cli.Command{\n\tName:   \"update\",\n\tUsage:  \"update a registry\",\n\tAction: registryUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryUpdate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\t_, err = client.GlobalRegistryUpdate(registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/admin/registry/registry_show.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryShowCmd = &cli.Command{\n\tName:   \"show\",\n\tUsage:  \"show registry information\",\n\tAction: registryShow,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tformat   = c.String(\"format\") + \"\\n\"\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry, err := client.GlobalRegistry(hostname)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, registry)\n}\n"
  },
  {
    "path": "cli/admin/secret/secret.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the secret command.\nvar Command = &cli.Command{\n\tName:  \"secret\",\n\tUsage: \"manage global secrets\",\n\tCommands: []*cli.Command{\n\t\tsecretCreateCmd,\n\t\tsecretDeleteCmd,\n\t\tsecretListCmd,\n\t\tsecretShowCmd,\n\t\tsecretUpdateCmd,\n\t},\n}\n"
  },
  {
    "path": "cli/admin/secret/secret_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretCreateCmd = &cli.Command{\n\tName:   \"add\",\n\tUsage:  \"add a secret\",\n\tAction: secretCreate,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"secret limited to these events\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"secret limited to these images\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretCreate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif len(secret.Events) == 0 {\n\t\tsecret.Events = defaultSecretEvents\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\t_, err = client.GlobalSecretCreate(secret)\n\treturn err\n}\n\nvar defaultSecretEvents = []string{\n\twoodpecker.EventPush,\n\twoodpecker.EventTag,\n\twoodpecker.EventRelease,\n\twoodpecker.EventDeploy,\n}\n"
  },
  {
    "path": "cli/admin/secret/secret_list.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretListCmd = &cli.Command{\n\tName:   \"ls\",\n\tUsage:  \"list secrets\",\n\tAction: secretList,\n\tFlags: []cli.Flag{\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.SecretListOptions{}\n\n\tlist, err := client.GlobalSecretList(opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, registry := range list {\n\t\tif err := tmpl.Execute(os.Stdout, registry); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for secret list items.\nvar tmplSecretList = \"\\x1b[33m{{ .Name }} \\x1b[0m\" + `\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: <any>\n{{- end }}\n`\n\nvar secretFuncMap = template.FuncMap{\n\t\"list\": func(s []string) string {\n\t\treturn strings.Join(s, \", \")\n\t},\n}\n"
  },
  {
    "path": "cli/admin/secret/secret_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretDeleteCmd = &cli.Command{\n\tName:   \"rm\",\n\tUsage:  \"remove a secret\",\n\tAction: secretDelete,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t},\n}\n\nfunc secretDelete(ctx context.Context, c *cli.Command) error {\n\tsecretName := c.String(\"name\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.GlobalSecretDelete(secretName)\n}\n"
  },
  {
    "path": "cli/admin/secret/secret_set.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretUpdateCmd = &cli.Command{\n\tName:   \"update\",\n\tUsage:  \"update a secret\",\n\tAction: secretUpdate,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"secret limited to these events\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"secret limited to these images\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretUpdate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\t_, err = client.GlobalSecretUpdate(secret)\n\treturn err\n}\n"
  },
  {
    "path": "cli/admin/secret/secret_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretShowCmd = &cli.Command{\n\tName:   \"show\",\n\tUsage:  \"show secret information\",\n\tAction: secretShow,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tsecretName = c.String(\"name\")\n\t\tformat     = c.String(\"format\") + \"\\n\"\n\t)\n\n\tif secretName == \"\" {\n\t\treturn fmt.Errorf(\"secret name is missing\")\n\t}\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret, err := client.GlobalSecret(secretName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, secret)\n}\n"
  },
  {
    "path": "cli/admin/user/user.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage user\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the user command set.\nvar Command = &cli.Command{\n\tName:  \"user\",\n\tUsage: \"manage users\",\n\tCommands: []*cli.Command{\n\t\tuserAddCmd,\n\t\tuserListCmd,\n\t\tuserRemoveCmd,\n\t\tuserShowCmd,\n\t},\n}\n"
  },
  {
    "path": "cli/admin/user/user_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage user\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar userAddCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a user\",\n\tArgsUsage: \"<username>\",\n\tAction:    userAdd,\n}\n\nfunc userAdd(ctx context.Context, c *cli.Command) error {\n\tlogin := c.Args().First()\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tuser, err := client.UserPost(&woodpecker.User{Login: login})\n\tif err != nil {\n\t\treturn err\n\t}\n\tfmt.Printf(\"Successfully added user %s\\n\", user.Login)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/admin/user/user_list.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage user\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar userListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list all users\",\n\tArgsUsage: \" \",\n\tAction:    userList,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplUserList, false)},\n}\n\nfunc userList(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.UserListOptions{}\n\n\tusers, err := client.UserList(opt)\n\tif err != nil || len(users) == 0 {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, user := range users {\n\t\tif err := tmpl.Execute(os.Stdout, user); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for user list items.\nvar tmplUserList = `{{ .Login }}`\n"
  },
  {
    "path": "cli/admin/user/user_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage user\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar userRemoveCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a user\",\n\tArgsUsage: \"<username>\",\n\tAction:    userRemove,\n}\n\nfunc userRemove(ctx context.Context, c *cli.Command) error {\n\tlogin := c.Args().First()\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := client.UserDel(login); err != nil {\n\t\treturn err\n\t}\n\tfmt.Printf(\"Successfully removed user %s\\n\", login)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/admin/user/user_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage user\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar userShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show user information\",\n\tArgsUsage: \"<username>\",\n\tAction:    userShow,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplUserInfo, false)},\n}\n\nfunc userShow(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlogin := c.Args().First()\n\tif len(login) == 0 {\n\t\treturn fmt.Errorf(\"missing or invalid user login\")\n\t}\n\n\tuser, err := client.User(login)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, user)\n}\n\n// Template for user information.\nvar tmplUserInfo = `User: {{ .Login }}\nEmail: {{ .Email }}`\n"
  },
  {
    "path": "cli/common/flags.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n)\n\nvar GlobalFlags = append([]cli.Flag{\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONFIG\"),\n\t\tName:    \"config\",\n\t\tAliases: []string{\"c\"},\n\t\tUsage:   \"path to config file\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER\"),\n\t\tName:    \"server\",\n\t\tAliases: []string{\"s\"},\n\t\tUsage:   \"server address\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_TOKEN\"),\n\t\tName:    \"token\",\n\t\tAliases: []string{\"t\"},\n\t\tUsage:   \"server auth token\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DISABLE_UPDATE_CHECK\"),\n\t\tName:    \"disable-update-check\",\n\t\tUsage:   \"disable update check\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SKIP_VERIFY\"),\n\t\tName:    \"skip-verify\",\n\t\tUsage:   \"skip ssl verification\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"SOCKS_PROXY\"),\n\t\tName:    \"socks-proxy\",\n\t\tUsage:   \"socks proxy address\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"SOCKS_PROXY_OFF\"),\n\t\tName:    \"socks-proxy-off\",\n\t\tUsage:   \"socks proxy ignored\",\n\t},\n}, logger.GlobalLoggerFlags...)\n\n// FormatFlag return format flag with value set based on template\n// if hidden value is set, flag will be hidden.\nfunc FormatFlag(tmpl string, deprecated bool, hidden ...bool) *cli.StringFlag {\n\tusage := \"format output\"\n\tif deprecated {\n\t\tusage = fmt.Sprintf(\"%s (deprecated)\", usage)\n\t}\n\n\treturn &cli.StringFlag{\n\t\tName:   \"format\",\n\t\tUsage:  usage,\n\t\tValue:  tmpl,\n\t\tHidden: len(hidden) != 0,\n\t}\n}\n\n// OutputFlags returns a slice of cli.Flag containing output format options.\nfunc OutputFlags(def string) []cli.Flag {\n\treturn []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"output\",\n\t\t\tUsage: \"output format\",\n\t\t\tValue: def,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"output-no-headers\",\n\t\t\tUsage: \"don't print headers\",\n\t\t},\n\t}\n}\n\nvar RepoFlag = &cli.StringFlag{\n\tName:    \"repository\",\n\tAliases: []string{\"repo\"},\n\tUsage:   \"repository id or full name (e.g. 134 or octocat/hello-world)\",\n}\n\nvar OrgFlag = &cli.StringFlag{\n\tName:    \"organization\",\n\tAliases: []string{\"org\"},\n\tUsage:   \"organization id or full name (e.g. 123 or octocat)\",\n}\n"
  },
  {
    "path": "cli/common/hooks.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/update\"\n)\n\nvar (\n\twaitForUpdateCheck  context.Context\n\tcancelWaitForUpdate context.CancelCauseFunc\n)\n\nfunc Before(ctx context.Context, c *cli.Command) (context.Context, error) {\n\tif err := setupGlobalLogger(ctx, c); err != nil {\n\t\treturn ctx, err\n\t}\n\n\tgo func(context.Context) {\n\t\tif c.Bool(\"disable-update-check\") {\n\t\t\treturn\n\t\t}\n\n\t\t// Don't check for updates when the update command is executed\n\t\tif firstArg := c.Args().First(); firstArg == \"update\" {\n\t\t\treturn\n\t\t}\n\n\t\twaitForUpdateCheck, cancelWaitForUpdate = context.WithCancelCause(context.Background())\n\t\tdefer cancelWaitForUpdate(errors.New(\"update check finished\"))\n\n\t\tlog.Debug().Msg(\"checking for updates ...\")\n\n\t\tnewVersion, err := update.CheckForUpdate(waitForUpdateCheck, false) //nolint:contextcheck\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"failed to check for updates\")\n\t\t\treturn\n\t\t}\n\n\t\tif newVersion != nil {\n\t\t\tlog.Warn().Msgf(\"new version of woodpecker-cli is available: %s, update with: %s update\", newVersion.Version, c.Root().Name)\n\t\t} else {\n\t\t\tlog.Debug().Msgf(\"no update required\")\n\t\t}\n\t}(ctx)\n\n\treturn ctx, config.Load(ctx, c)\n}\n\nfunc After(_ context.Context, _ *cli.Command) error {\n\tif waitForUpdateCheck != nil {\n\t\tselect {\n\t\tcase <-waitForUpdateCheck.Done():\n\t\t// When the actual command already finished, we still wait 500ms for the update check to finish\n\t\tcase <-time.After(time.Millisecond * 500):\n\t\t\tlog.Debug().Msg(\"update check stopped due to timeout\")\n\t\t\tcancelWaitForUpdate(errors.New(\"update check timeout\"))\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/common/pipeline.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\nfunc DetectPipelineConfig() (isDir bool, config string, _ error) {\n\tfor _, config := range constant.DefaultConfigOrder {\n\t\tshouldBeDir := strings.HasSuffix(config, \"/\")\n\t\tconfig = strings.TrimSuffix(config, \"/\")\n\n\t\tif fi, err := os.Stat(config); err == nil && shouldBeDir == fi.IsDir() {\n\t\t\treturn fi.IsDir(), config, nil\n\t\t}\n\t}\n\n\treturn false, \"\", fmt.Errorf(\"could not detect pipeline config\")\n}\n\nfunc RunPipelineFunc(ctx context.Context, c *cli.Command, fileFunc, dirFunc func(context.Context, *cli.Command, string) error) error {\n\tif c.Args().Len() == 0 {\n\t\tisDir, path, err := DetectPipelineConfig()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif isDir {\n\t\t\treturn dirFunc(ctx, c, path)\n\t\t}\n\t\treturn fileFunc(ctx, c, path)\n\t}\n\n\tmultiArgs := c.Args().Len() > 1\n\tfor _, arg := range c.Args().Slice() {\n\t\tfi, err := os.Stat(arg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif multiArgs {\n\t\t\tfmt.Println(\"#\", fi.Name())\n\t\t}\n\t\tif fi.IsDir() {\n\t\t\tif err := dirFunc(ctx, c, arg); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\tif err := fileFunc(ctx, c, arg); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif multiArgs {\n\t\t\tfmt.Println(\"\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/common/zerologger.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n)\n\nfunc setupGlobalLogger(ctx context.Context, c *cli.Command) error {\n\treturn logger.SetupGlobalLogger(ctx, c, false)\n}\n"
  },
  {
    "path": "cli/context/context.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage context\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/output\"\n)\n\n// Command exports the context command set.\nvar Command = &cli.Command{\n\tName:        \"context\",\n\tAliases:     []string{\"ctx\"},\n\tUsage:       \"manage contexts\",\n\tDescription: \"Contexts can be used to manage users on one or multiple servers.\\nTo create a new context run the setup command\",\n\tCommands: []*cli.Command{\n\t\tlistCommand,\n\t\tuseCommand,\n\t\tdeleteCommand,\n\t\trenameCommand,\n\t},\n}\n\nvar listCommand = &cli.Command{\n\tName:    \"list\",\n\tAliases: []string{\"ls\"},\n\tUsage:   \"list all contexts\",\n\tFlags: append(common.OutputFlags(\"table\"), []cli.Flag{\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"output-no-headers\",\n\t\t\tUsage: \"do not print headers in output\",\n\t\t},\n\t}...),\n\tAction: listContexts,\n}\n\nvar useCommand = &cli.Command{\n\tName:      \"use\",\n\tUsage:     \"set the current context\",\n\tArgsUsage: \"<context-name>\",\n\tAction:    useContext,\n}\n\nvar deleteCommand = &cli.Command{\n\tName:      \"delete\",\n\tAliases:   []string{\"rm\"},\n\tUsage:     \"delete a context\",\n\tArgsUsage: \"<context-name>\",\n\tAction:    deleteContext,\n}\n\nvar renameCommand = &cli.Command{\n\tName:      \"rename\",\n\tUsage:     \"rename a context\",\n\tArgsUsage: \"<old-name> <new-name>\",\n\tAction:    renameContext,\n}\n\nfunc listContexts(_ context.Context, c *cli.Command) error {\n\tcontexts, err := config.LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(contexts.Contexts) == 0 {\n\t\tfmt.Println(\"No contexts found. Run 'woodpecker-cli setup' to create one.\")\n\t\treturn nil\n\t}\n\n\t_, outOpt := output.ParseOutputOptions(c.String(\"output\"))\n\tout := os.Stdout\n\tnoHeader := c.Bool(\"output-no-headers\")\n\ttable := output.NewTable(out)\n\n\t// Add custom field mapping\n\ttable.AddFieldFn(\"Name\", func(obj any) string {\n\t\tc, ok := obj.(config.Context)\n\t\tif !ok {\n\t\t\treturn \"???\"\n\t\t}\n\n\t\tif contexts.CurrentContext == c.Name {\n\t\t\treturn c.Name + \" *\"\n\t\t}\n\n\t\treturn c.Name\n\t})\n\ttable.AddFieldAlias(\"ServerURL\", \"Server URL\")\n\ttable.AddFieldAlias(\"LogLevel\", \"Log Level\")\n\ttable.AddFieldAlias(\"Name\", \"Name (selected)\")\n\n\tcols := []string{\"Name (selected)\", \"Server URL\"}\n\n\tif len(outOpt) > 0 {\n\t\tcols = outOpt\n\t}\n\tif !noHeader {\n\t\ttable.WriteHeader(cols)\n\t}\n\tfor _, c := range contexts.Contexts {\n\t\tif err := table.Write(cols, c); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn table.Flush()\n}\n\nfunc useContext(_ context.Context, c *cli.Command) error {\n\tcontextName := c.Args().First()\n\tif contextName == \"\" {\n\t\treturn fmt.Errorf(\"context name is required\")\n\t}\n\n\terr := config.SetCurrentContext(contextName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info().Msgf(\"Switched to context '%s'\", contextName)\n\treturn nil\n}\n\nfunc deleteContext(_ context.Context, c *cli.Command) error {\n\tcontextName := c.Args().First()\n\tif contextName == \"\" {\n\t\treturn fmt.Errorf(\"context name is required\")\n\t}\n\n\terr := config.DeleteContext(c, contextName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info().Msgf(\"Context '%s' deleted\", contextName)\n\treturn nil\n}\n\nfunc renameContext(_ context.Context, c *cli.Command) error {\n\tif c.Args().Len() < 2 { //nolint:mnd // min args\n\t\treturn fmt.Errorf(\"both old name and new name are required\")\n\t}\n\n\toldName := c.Args().Get(0)\n\tnewName := c.Args().Get(1)\n\n\terr := config.RenameContext(oldName, newName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info().Msgf(\"Context renamed from '%s' to '%s'\", oldName, newName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/exec/dummy.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage exec\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\nfunc init() { //nolint:gochecknoinits\n\tbackends = append(backends, dummy.New())\n}\n"
  },
  {
    "path": "cli/exec/exec.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage exec\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"maps\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"codeberg.org/6543/xyaml\"\n\t\"github.com/drone/envsubst\"\n\t\"github.com/oklog/ulid/v2\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"go.uber.org/multierr\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/lint\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\tpipeline_runtime \"go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime\"\n\tpipeline_utils \"go.woodpecker-ci.org/woodpecker/v3/pipeline/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// Command exports the exec command.\nvar Command = &cli.Command{\n\tName:      \"exec\",\n\tUsage:     \"execute a local pipeline\",\n\tArgsUsage: \"[path/to/.woodpecker.yaml]\",\n\tAction:    run,\n\tFlags:     slices.Concat(flags, docker.Flags, kubernetes.Flags, local.Flags),\n}\n\nvar backends = []backend_types.Backend{\n\tkubernetes.New(),\n\tdocker.New(),\n\tlocal.New(),\n}\n\nfunc run(ctx context.Context, c *cli.Command) error {\n\treturn common.RunPipelineFunc(ctx, c, execFile, execDir)\n}\n\nfunc execDir(ctx context.Context, c *cli.Command, dir string) error {\n\t// TODO: respect pipeline dependency\n\trepoPath := c.String(\"repo-path\")\n\tif repoPath != \"\" {\n\t\trepoPath, _ = filepath.Abs(repoPath)\n\t} else {\n\t\trepoPath, _ = filepath.Abs(filepath.Dir(dir))\n\t}\n\tif runtime.GOOS == \"windows\" && c.String(\"backend-engine\") != \"local\" {\n\t\trepoPath = convertPathForWindows(repoPath)\n\t}\n\n\tvar execErr error\n\n\t// TODO: respect depends_on and do parallel runs with output to multiple _windows_ e.g. tmux like\n\twalkErr := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error {\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\t// check if it is a regular file (not dir)\n\t\tif info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), \".yaml\") || strings.HasSuffix(info.Name(), \".yml\")) {\n\t\t\tfmt.Println(\"#\", info.Name())\n\t\t\terr := runExec(ctx, c, path, repoPath, false)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Print(err)\n\t\t\t\texecErr = multierr.Append(execErr, err)\n\t\t\t}\n\t\t\tfmt.Println(\"\")\n\t\t\treturn nil\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif walkErr != nil {\n\t\treturn walkErr\n\t}\n\n\treturn execErr\n}\n\nfunc execFile(ctx context.Context, c *cli.Command, file string) error {\n\trepoPath := c.String(\"repo-path\")\n\tif repoPath != \"\" {\n\t\trepoPath, _ = filepath.Abs(repoPath)\n\t} else {\n\t\trepoPath, _ = filepath.Abs(filepath.Dir(file))\n\t}\n\tif runtime.GOOS == \"windows\" && c.String(\"backend-engine\") != \"local\" {\n\t\trepoPath = convertPathForWindows(repoPath)\n\t}\n\treturn runExec(ctx, c, file, repoPath, true)\n}\n\nfunc runExec(ctx context.Context, c *cli.Command, file, repoPath string, singleExec bool) error {\n\tdat, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// if we use the local backend we should signal to run at $repoPath\n\tif c.String(\"backend-engine\") == \"local\" {\n\t\tlocal.CLIWorkaroundExecAtDir = repoPath\n\t}\n\n\taxes, err := matrix.ParseString(string(dat))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"parse matrix fail\")\n\t}\n\n\tif len(axes) == 0 {\n\t\taxes = append(axes, matrix.Axis{})\n\t}\n\tfor _, axis := range axes {\n\t\terr := execWithAxis(ctx, c, file, repoPath, axis, singleExec)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis, singleExec bool) error {\n\tmetadataWorkflow := &metadata.Workflow{}\n\tif !singleExec {\n\t\t// TODO: proper try to use the engine to generate the same metadata for workflows\n\t\t// https://github.com/woodpecker-ci/woodpecker/pull/3967\n\t\tmetadataWorkflow.Name = strings.TrimSuffix(strings.TrimSuffix(file, \".yaml\"), \".yml\")\n\t}\n\tmetadata, err := metadataFromContext(ctx, c, axis, metadataWorkflow)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not create metadata: %w\", err)\n\t} else if metadata == nil {\n\t\treturn fmt.Errorf(\"metadata is nil\")\n\t}\n\n\tenviron := metadata.Environ()\n\tmaps.Copy(environ, metadata.Workflow.Matrix)\n\tvar secrets []compiler.Secret\n\tfor key, val := range c.StringMap(\"secrets\") {\n\t\tsecrets = append(secrets, compiler.Secret{\n\t\t\tName:  key,\n\t\t\tValue: val,\n\t\t})\n\t}\n\tif secretsFile := c.String(\"secrets-file\"); secretsFile != \"\" {\n\t\tfileContent, err := os.ReadFile(secretsFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar m map[string]string\n\t\terr = xyaml.Unmarshal(fileContent, &m)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor key, val := range m {\n\t\t\tsecrets = append(secrets, compiler.Secret{\n\t\t\t\tName:  key,\n\t\t\t\tValue: val,\n\t\t\t})\n\t\t}\n\t}\n\n\tpipelineEnv := make(map[string]string)\n\tfor _, env := range c.StringSlice(\"env\") {\n\t\tbefore, after, _ := strings.Cut(env, \"=\")\n\t\tpipelineEnv[before] = after\n\t\tif oldVar, exists := environ[before]; exists {\n\t\t\t// override existing values, but print a warning\n\t\t\tlog.Warn().Msgf(\"environment variable '%s' had value '%s', but got overwritten\", before, oldVar)\n\t\t}\n\t\tenviron[before] = after\n\t}\n\n\ttmpl, err := envsubst.ParseFile(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconfStr, err := tmpl.Execute(func(name string) string {\n\t\treturn environ[name]\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconf, err := yaml.ParseString(confStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// emulate server behavior https://github.com/woodpecker-ci/woodpecker/blob/eebaa10d104cbc3fa7ce4c0e344b0b7978405135/server/pipeline/stepbuilder/stepBuilder.go#L289-L295\n\tprefix := \"wp_\" + ulid.Make().String()\n\n\t// configure volumes for local execution\n\tvolumes := c.StringSlice(\"volumes\")\n\tif c.Bool(\"local\") {\n\t\tvar (\n\t\t\tworkspaceBase = conf.Workspace.Base\n\t\t\tworkspacePath = conf.Workspace.Path\n\t\t)\n\t\tif workspaceBase == \"\" {\n\t\t\tworkspaceBase = c.String(\"workspace-base\")\n\t\t}\n\t\tif workspacePath == \"\" {\n\t\t\tworkspacePath = c.String(\"workspace-path\")\n\t\t}\n\n\t\tvolumes = append(volumes, prefix+\"_default:\"+workspaceBase)\n\t\tvolumes = append(volumes, repoPath+\":\"+path.Join(workspaceBase, workspacePath))\n\t}\n\n\tprivilegedPlugins := c.StringSlice(\"plugins-privileged\")\n\n\t// lint the yaml file\n\terr = linter.New(\n\t\tlinter.WithTrusted(linter.TrustedConfiguration{\n\t\t\tSecurity: c.Bool(\"repo-trusted-security\"),\n\t\t\tNetwork:  c.Bool(\"repo-trusted-network\"),\n\t\t\tVolumes:  c.Bool(\"repo-trusted-volumes\"),\n\t\t}),\n\t\tlinter.PrivilegedPlugins(privilegedPlugins),\n\t\tlinter.WithTrustedClonePlugins(constant.TrustedClonePlugins),\n\t).Lint([]*linter.WorkflowConfig{{\n\t\tFile:      path.Base(file),\n\t\tRawConfig: confStr,\n\t\tWorkflow:  conf,\n\t}})\n\tif err != nil {\n\t\tstr, err := lint.FormatLintError(file, err, false)\n\t\tfmt.Print(str)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// compiles the yaml file\n\tcompiled, err := compiler.New(\n\t\tcompiler.WithEscalated(\n\t\t\tprivilegedPlugins...,\n\t\t),\n\t\tcompiler.WithVolumes(volumes...),\n\t\tcompiler.WithWorkspace(\n\t\t\tc.String(\"workspace-base\"),\n\t\t\tc.String(\"workspace-path\"),\n\t\t),\n\t\tcompiler.WithNetworks(\n\t\t\tc.StringSlice(\"network\")...,\n\t\t),\n\t\tcompiler.WithPrefix(prefix),\n\t\tcompiler.WithProxy(compiler.ProxyOptions{\n\t\t\tNoProxy:    c.String(\"backend-no-proxy\"),\n\t\t\tHTTPProxy:  c.String(\"backend-http-proxy\"),\n\t\t\tHTTPSProxy: c.String(\"backend-https-proxy\"),\n\t\t}),\n\t\tcompiler.WithLocal(\n\t\t\tc.Bool(\"local\"),\n\t\t),\n\t\tcompiler.WithNetrc(\n\t\t\tc.String(\"netrc-username\"),\n\t\t\tc.String(\"netrc-password\"),\n\t\t\tc.String(\"netrc-machine\"),\n\t\t),\n\t\tcompiler.WithMetadata(*metadata),\n\t\tcompiler.WithSecret(secrets...),\n\t\tcompiler.WithEnviron(pipelineEnv),\n\t).Compile(conf)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbackendCtx := context.WithValue(ctx, backend_types.CliCommand, c)\n\tbackendEngine, err := backend.FindBackend(backendCtx, backends, c.String(\"backend-engine\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif _, err = backendEngine.Load(backendCtx); err != nil {\n\t\treturn err\n\t}\n\n\tpipelineCtx, cancel := context.WithTimeout(context.Background(), c.Duration(\"timeout\"))\n\tdefer cancel()\n\tpipelineCtx = utils.WithContextSigtermCallback(pipelineCtx, func() {\n\t\tfmt.Printf(\"ctrl+c received, terminating current pipeline '%s'\\n\", confStr)\n\t})\n\n\treturn pipeline_runtime.New(compiled, backendEngine,\n\t\tpipeline_runtime.WithContext(pipelineCtx), //nolint:contextcheck\n\t\tpipeline_runtime.WithLogger(defaultLogger),\n\t\tpipeline_runtime.WithDescription(map[string]string{\n\t\t\t\"CLI\": \"exec\",\n\t\t}),\n\t).Run(ctx)\n}\n\n// convertPathForWindows converts a path to use slash separators\n// for Windows. If the path is a Windows volume name like C:, it\n// converts it to an absolute root path starting with slash (e.g.\n// C: -> /c). Otherwise it just converts backslash separators to\n// slashes.\nfunc convertPathForWindows(path string) string {\n\tbase := filepath.VolumeName(path)\n\n\t// Check if path is volume name like C:\n\t//nolint:mnd\n\tif len(base) == 2 {\n\t\tpath = strings.TrimPrefix(path, base)\n\t\tbase = strings.ToLower(strings.TrimSuffix(base, \":\"))\n\t\treturn \"/\" + base + filepath.ToSlash(path)\n\t}\n\n\treturn filepath.ToSlash(path)\n}\n\nvar defaultLogger = logging.Logger(func(step *backend_types.Step, rc io.ReadCloser) error {\n\tlogWriter := NewLineWriter(step.Name, step.UUID)\n\treturn pipeline_utils.CopyLineByLine(logWriter, rc, pipeline.MaxLogLineLength)\n})\n"
  },
  {
    "path": "cli/exec/flags.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage exec\n\nimport (\n\t\"time\"\n\n\t\"github.com/urfave/cli/v3\"\n)\n\nvar flags = []cli.Flag{\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_LOCAL\"),\n\t\tName:    \"local\",\n\t\tUsage:   \"run from local directory\",\n\t\tValue:   true,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_REPO_PATH\"),\n\t\tName:    \"repo-path\",\n\t\tUsage:   \"path to local repository\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_METADATA_FILE\"),\n\t\tName:    \"metadata-file\",\n\t\tUsage:   \"path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags\",\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_TIMEOUT\"),\n\t\tName:    \"timeout\",\n\t\tUsage:   \"pipeline timeout\",\n\t\tValue:   time.Hour,\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_VOLUMES\"),\n\t\tName:    \"volumes\",\n\t\tUsage:   \"pipeline volumes\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_NETWORKS\"),\n\t\tName:    \"network\",\n\t\tUsage:   \"external networks\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_PLUGINS_PRIVILEGED\"),\n\t\tName:    \"plugins-privileged\",\n\t\tUsage:   \"Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND\"),\n\t\tName:    \"backend-engine\",\n\t\tUsage:   \"backend engine to run pipelines on\",\n\t\tValue:   \"auto-detect\",\n\t},\n\t&cli.StringMapFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SECRETS\"),\n\t\tName:    \"secrets\",\n\t\tUsage:   \"map of secrets, ex. 'secret=\\\"val\\\",secret2=\\\"value2\\\"'\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SECRETS_FILE\"),\n\t\tName:    \"secrets-file\",\n\t\tUsage:   \"path to yaml file with secrets map\",\n\t},\n\n\t//\n\t// backend options for pipeline compiler\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_NO_PROXY\", \"NO_PROXY\", \"no_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"NO_PROXY\\\" to steps\",\n\t\tName:    \"backend-no-proxy\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_HTTP_PROXY\", \"HTTP_PROXY\", \"http_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"HTTP_PROXY\\\" to steps\",\n\t\tName:    \"backend-http-proxy\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_HTTPS_PROXY\", \"HTTPS_PROXY\", \"https_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"HTTPS_PROXY\\\" to steps\",\n\t\tName:    \"backend-https-proxy\",\n\t},\n\n\t//\n\t// Please note the below flags should match the flags from\n\t// pipeline/frontend/metadata.go and should be kept synchronized.\n\t//\n\n\t//\n\t// workspace default\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_WORKSPACE_BASE\"),\n\t\tName:    \"workspace-base\",\n\t\tValue:   \"/woodpecker\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_WORKSPACE_PATH\"),\n\t\tName:    \"workspace-path\",\n\t\tValue:   \"src\",\n\t},\n\t//\n\t// netrc parameters\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_NETRC_USERNAME\"),\n\t\tName:    \"netrc-username\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_NETRC_PASSWORD\"),\n\t\tName:    \"netrc-password\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_NETRC_MACHINE\"),\n\t\tName:    \"netrc-machine\",\n\t},\n\t//\n\t// metadata parameters\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_SYSTEM_PLATFORM\"),\n\t\tName:    \"system-platform\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_SYSTEM_PLATFORM\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_SYSTEM_HOST\"),\n\t\tName:    \"system-host\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_SYSTEM_HOST\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_SYSTEM_NAME\"),\n\t\tName:    \"system-name\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_SYSTEM_NAME\\\".\",\n\t\tValue:   \"woodpecker\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_SYSTEM_URL\"),\n\t\tName:    \"system-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_SYSTEM_URL\\\".\",\n\t\tValue:   \"https://github.com/woodpecker-ci/woodpecker\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO\"),\n\t\tName:    \"repo\",\n\t\tUsage:   \"Set the full name to derive metadata environment variables \\\"CI_REPO\\\", \\\"CI_REPO_NAME\\\" and \\\"CI_REPO_OWNER\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_REMOTE_ID\"),\n\t\tName:    \"repo-remote-id\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_REMOTE_ID\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_URL\"),\n\t\tName:    \"repo-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_URL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_DEFAULT_BRANCH\"),\n\t\tName:    \"repo-default-branch\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_DEFAULT_BRANCH\\\".\",\n\t\tValue:   \"main\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_CLONE_URL\"),\n\t\tName:    \"repo-clone-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_CLONE_URL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_CLONE_SSH_URL\"),\n\t\tName:    \"repo-clone-ssh-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_CLONE_SSH_URL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_PRIVATE\"),\n\t\tName:    \"repo-private\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_PRIVATE\\\".\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_TRUSTED_NETWORK\"),\n\t\tName:    \"repo-trusted-network\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_TRUSTED_NETWORK\\\".\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_TRUSTED_VOLUMES\"),\n\t\tName:    \"repo-trusted-volumes\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_TRUSTED_VOLUMES\\\".\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"CI_REPO_TRUSTED_SECURITY\"),\n\t\tName:    \"repo-trusted-security\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_REPO_TRUSTED_SECURITY\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_NUMBER\"),\n\t\tName:    \"pipeline-number\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_NUMBER\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_PARENT\"),\n\t\tName:    \"pipeline-parent\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_PARENT\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_CREATED\"),\n\t\tName:    \"pipeline-created\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_CREATED\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_STARTED\"),\n\t\tName:    \"pipeline-started\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_STARTED\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_EVENT\"),\n\t\tName:    \"pipeline-event\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_EVENT\\\".\",\n\t\tValue:   \"manual\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_FORGE_URL\"),\n\t\tName:    \"pipeline-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_FORGE_URL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_DEPLOY_TARGET\"),\n\t\tName:    \"pipeline-deploy-to\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_DEPLOY_TARGET\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_DEPLOY_TASK\"),\n\t\tName:    \"pipeline-deploy-task\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_DEPLOY_TASK\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PIPELINE_FILES\"),\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PIPELINE_FILES\\\", either json formatted list of strings, or comma separated string list.\",\n\t\tName:    \"pipeline-changed-files\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_SHA\"),\n\t\tName:    \"commit-sha\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_SHA\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_REF\"),\n\t\tName:    \"commit-ref\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_REF\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_REFSPEC\"),\n\t\tName:    \"commit-refspec\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_REFSPEC\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_BRANCH\"),\n\t\tName:    \"commit-branch\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_BRANCH\\\".\",\n\t\tValue:   \"main\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_MESSAGE\"),\n\t\tName:    \"commit-message\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_MESSAGE\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_AUTHOR\"),\n\t\tName:    \"commit-author-name\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_AUTHOR\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_AUTHOR_AVATAR\"),\n\t\tName:    \"commit-author-avatar\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_AUTHOR_AVATAR\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_AUTHOR_EMAIL\"),\n\t\tName:    \"commit-author-email\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_AUTHOR_EMAIL\\\".\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_PULL_REQUEST_LABELS\"),\n\t\tName:    \"commit-pull-labels\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_PULL_REQUEST_LABELS\\\".\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_PULL_REQUEST_MILESTONE\"),\n\t\tName:    \"commit-pull-milestone\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_PULL_REQUEST_MILESTONE\\\".\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"CI_COMMIT_PRERELEASE\"),\n\t\tName:    \"commit-release-is-pre\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_COMMIT_PRERELEASE\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_NUMBER\"),\n\t\tName:    \"prev-pipeline-number\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_NUMBER\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_CREATED\"),\n\t\tName:    \"prev-pipeline-created\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_CREATED\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_STARTED\"),\n\t\tName:    \"prev-pipeline-started\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_STARTED\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_FINISHED\"),\n\t\tName:    \"prev-pipeline-finished\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_FINISHED\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_STATUS\"),\n\t\tName:    \"prev-pipeline-status\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_STATUS\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_EVENT\"),\n\t\tName:    \"prev-pipeline-event\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_EVENT\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_FORGE_URL\"),\n\t\tName:    \"prev-pipeline-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_FORGE_URL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_DEPLOY_TARGET\"),\n\t\tName:    \"prev-pipeline-deploy-to\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_DEPLOY_TARGET\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_PIPELINE_DEPLOY_TASK\"),\n\t\tName:    \"prev-pipeline-deploy-task\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_PIPELINE_DEPLOY_TASK\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_SHA\"),\n\t\tName:    \"prev-commit-sha\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_SHA\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_REF\"),\n\t\tName:    \"prev-commit-ref\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_REF\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_REFSPEC\"),\n\t\tName:    \"prev-commit-refspec\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_REFSPEC\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_BRANCH\"),\n\t\tName:    \"prev-commit-branch\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_BRANCH\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_MESSAGE\"),\n\t\tName:    \"prev-commit-message\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_MESSAGE\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_AUTHOR\"),\n\t\tName:    \"prev-commit-author-name\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_AUTHOR\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_AUTHOR_AVATAR\"),\n\t\tName:    \"prev-commit-author-avatar\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_AUTHOR_AVATAR\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_PREV_COMMIT_AUTHOR_EMAIL\"),\n\t\tName:    \"prev-commit-author-email\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_PREV_COMMIT_AUTHOR_EMAIL\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_WORKFLOW_NAME\"),\n\t\tName:    \"workflow-name\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_WORKFLOW_NAME\\\".\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"CI_WORKFLOW_NUMBER\"),\n\t\tName:    \"workflow-number\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_WORKFLOW_NUMBER\\\".\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"CI_ENV\"),\n\t\tName:    \"env\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_ENV\\\".\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_FORGE_TYPE\"),\n\t\tName:    \"forge-type\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_FORGE_TYPE\\\".\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"CI_FORGE_URL\"),\n\t\tName:    \"forge-url\",\n\t\tUsage:   \"Set the metadata environment variable \\\"CI_FORGE_URL\\\".\",\n\t},\n}\n"
  },
  {
    "path": "cli/exec/line.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage exec\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"time\"\n)\n\n// LineWriter sends logs to the client.\ntype LineWriter struct {\n\tstepName  string\n\tstepUUID  string\n\tnum       int\n\tstartTime time.Time\n}\n\n// NewLineWriter returns a new line reader.\nfunc NewLineWriter(stepName, stepUUID string) io.WriteCloser {\n\treturn &LineWriter{\n\t\tstepName:  stepName,\n\t\tstepUUID:  stepUUID,\n\t\tstartTime: time.Now().UTC(),\n\t}\n}\n\nfunc (w *LineWriter) Write(p []byte) (n int, err error) {\n\tfmt.Fprintf(os.Stderr, \"[%s:L%d:%ds] %s\", w.stepName, w.num, int64(time.Since(w.startTime).Seconds()), p)\n\tw.num++\n\treturn len(p), nil\n}\n\nfunc (w *LineWriter) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "cli/exec/metadata.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage exec\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// return the metadata from the cli context.\nfunc metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis, w *metadata.Workflow) (*metadata.Metadata, error) {\n\tm := &metadata.Metadata{}\n\n\tif w != nil {\n\t\tm.Workflow = *w\n\t}\n\n\tif c.IsSet(\"metadata-file\") {\n\t\tmetadataFile, err := os.Open(c.String(\"metadata-file\"))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tdefer metadataFile.Close()\n\n\t\tif err := json.NewDecoder(metadataFile).Decode(m); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tplatform := c.String(\"system-platform\")\n\tif platform == \"\" {\n\t\tplatform = runtime.GOOS + \"/\" + runtime.GOARCH\n\t}\n\n\tmetadataFileAndOverrideOrDefault(c, \"repo-name\", func(fullRepoName string) {\n\t\tif idx := strings.LastIndex(fullRepoName, \"/\"); idx != -1 {\n\t\t\tm.Repo.Owner = fullRepoName[:idx]\n\t\t\tm.Repo.Name = fullRepoName[idx+1:]\n\t\t}\n\t}, c.String)\n\n\tvar err error\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-changed-files\", func(changedFilesRaw string) {\n\t\tvar changedFiles []string\n\t\tif len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' {\n\t\t\tif jsonErr := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); jsonErr != nil {\n\t\t\t\terr = fmt.Errorf(\"pipeline-changed-files detected json but could not parse it: %w\", jsonErr)\n\t\t\t}\n\t\t} else {\n\t\t\tfor _, file := range strings.Split(changedFilesRaw, \",\") {\n\t\t\t\tchangedFiles = append(changedFiles, strings.TrimSpace(file))\n\t\t\t}\n\t\t}\n\t\tm.Curr.Commit.ChangedFiles = changedFiles\n\t}, c.String)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Repo\n\tmetadataFileAndOverrideOrDefault(c, \"repo-remote-id\", func(s string) { m.Repo.RemoteID = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-url\", func(s string) { m.Repo.ForgeURL = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-default-branch\", func(s string) { m.Repo.Branch = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-clone-url\", func(s string) { m.Repo.CloneURL = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-clone-ssh-url\", func(s string) { m.Repo.CloneSSHURL = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-private\", func(b bool) { m.Repo.Private = b }, c.Bool)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-trusted-network\", func(b bool) { m.Repo.Trusted.Network = b }, c.Bool)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-trusted-security\", func(b bool) { m.Repo.Trusted.Security = b }, c.Bool)\n\tmetadataFileAndOverrideOrDefault(c, \"repo-trusted-volumes\", func(b bool) { m.Repo.Trusted.Volumes = b }, c.Bool)\n\n\t// Current Pipeline\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-number\", func(i int64) { m.Curr.Number = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-parent\", func(i int64) { m.Curr.Parent = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-created\", func(i int64) { m.Curr.Created = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-started\", func(i int64) { m.Curr.Started = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-finished\", func(i int64) { m.Curr.Finished = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-status\", func(s string) { m.Curr.Status = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-event\", func(s string) { m.Curr.Event = metadata.Event(s) }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-url\", func(s string) { m.Curr.ForgeURL = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-deploy-to\", func(s string) { m.Curr.DeployTo = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"pipeline-deploy-task\", func(s string) { m.Curr.DeployTask = s }, c.String)\n\n\t// Current Pipeline Commit\n\tmetadataFileAndOverrideOrDefault(c, \"commit-sha\", func(s string) { m.Curr.Commit.Sha = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-ref\", func(s string) { m.Curr.Commit.Ref = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-refspec\", func(s string) { m.Curr.Commit.Refspec = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-branch\", func(s string) { m.Curr.Commit.Branch = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-message\", func(s string) { m.Curr.Commit.Message = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-author-name\", func(s string) { m.Curr.Commit.Author.Name = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-author-email\", func(s string) { m.Curr.Commit.Author.Email = s }, c.String)\n\t// TODO remove in next major\n\tmetadataFileAndOverrideOrDefault(c, \"commit-author-avatar\", func(s string) { m.Curr.Avatar = s }, c.String)\n\n\tmetadataFileAndOverrideOrDefault(c, \"commit-pull-labels\", func(sl []string) { m.Curr.Commit.PullRequestLabels = sl }, c.StringSlice)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-pull-milestone\", func(s string) { m.Curr.Commit.PullRequestMilestone = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"commit-release-is-pre\", func(b bool) { m.Curr.Commit.IsPrerelease = b }, c.Bool)\n\n\t// Previous Pipeline\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-number\", func(i int64) { m.Prev.Number = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-created\", func(i int64) { m.Prev.Created = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-started\", func(i int64) { m.Prev.Started = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-finished\", func(i int64) { m.Prev.Finished = i }, c.Int64)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-status\", func(s string) { m.Prev.Status = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-event\", func(s string) { m.Prev.Event = metadata.Event(s) }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-pipeline-url\", func(s string) { m.Prev.ForgeURL = s }, c.String)\n\n\t// Previous Pipeline Commit\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-sha\", func(s string) { m.Prev.Commit.Sha = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-ref\", func(s string) { m.Prev.Commit.Ref = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-refspec\", func(s string) { m.Prev.Commit.Refspec = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-branch\", func(s string) { m.Prev.Commit.Branch = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-message\", func(s string) { m.Prev.Commit.Message = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-author-name\", func(s string) { m.Prev.Commit.Author.Name = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-author-email\", func(s string) { m.Prev.Commit.Author.Email = s }, c.String)\n\t// TODO remove in next major\n\tmetadataFileAndOverrideOrDefault(c, \"prev-commit-author-avatar\", func(s string) { m.Prev.Avatar = s }, c.String)\n\n\t// Workflow\n\tmetadataFileAndOverrideOrDefault(c, \"workflow-name\", func(s string) { m.Workflow.Name = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"workflow-number\", func(i int64) { m.Workflow.Number = int(i) }, c.Int64)\n\tm.Workflow.Matrix = axis\n\n\t// System\n\tmetadataFileAndOverrideOrDefault(c, \"system-name\", func(s string) { m.Sys.Name = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"system-url\", func(s string) { m.Sys.URL = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"system-host\", func(s string) { m.Sys.Host = s }, c.String)\n\tm.Sys.Platform = platform\n\tm.Sys.Version = version.Version\n\n\t// Forge\n\tmetadataFileAndOverrideOrDefault(c, \"forge-type\", func(s string) { m.Forge.Type = s }, c.String)\n\tmetadataFileAndOverrideOrDefault(c, \"forge-url\", func(s string) { m.Forge.URL = s }, c.String)\n\n\treturn m, nil\n}\n\n// metadataFileAndOverrideOrDefault will either use the flag default or if metadata file is set only overload if explicit set.\nfunc metadataFileAndOverrideOrDefault[T any](c *cli.Command, flag string, setter func(T), getter func(string) T) {\n\tif !c.IsSet(\"metadata-file\") || c.IsSet(flag) {\n\t\tsetter(getter(flag))\n\t}\n}\n"
  },
  {
    "path": "cli/exec/metadata_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage exec\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix\"\n)\n\nfunc TestMetadataFromContext(t *testing.T) {\n\tsampleMetadata := &metadata.Metadata{\n\t\tRepo: metadata.Repo{Owner: \"test-user\", Name: \"test-repo\"},\n\t\tCurr: metadata.Pipeline{Number: 5},\n\t}\n\n\trunCommand := func(flags []cli.Flag, fn func(c *cli.Command)) {\n\t\tc := &cli.Command{\n\t\t\tFlags: flags,\n\t\t\tAction: func(_ context.Context, c *cli.Command) error {\n\t\t\t\tfn(c)\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\t\tassert.NoError(t, c.Run(t.Context(), []string{\"woodpecker-cli\"}))\n\t}\n\n\tt.Run(\"LoadFromFile\", func(t *testing.T) {\n\t\ttempFileName := createTempFile(t, sampleMetadata)\n\n\t\tflags := []cli.Flag{\n\t\t\t&cli.StringFlag{Name: \"metadata-file\"},\n\t\t}\n\n\t\trunCommand(flags, func(c *cli.Command) {\n\t\t\t_ = c.Set(\"metadata-file\", tempFileName)\n\n\t\t\tm, err := metadataFromContext(t.Context(), c, nil, nil)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, \"test-repo\", m.Repo.Name)\n\t\t\tassert.Equal(t, int64(5), m.Curr.Number)\n\t\t})\n\t})\n\n\tt.Run(\"OverrideFromFlags\", func(t *testing.T) {\n\t\ttempFileName := createTempFile(t, sampleMetadata)\n\n\t\tflags := []cli.Flag{\n\t\t\t&cli.StringFlag{Name: \"metadata-file\"},\n\t\t\t&cli.StringFlag{Name: \"repo-name\"},\n\t\t\t&cli.Int64Flag{Name: \"pipeline-number\"},\n\t\t}\n\n\t\trunCommand(flags, func(c *cli.Command) {\n\t\t\t_ = c.Set(\"metadata-file\", tempFileName)\n\t\t\t_ = c.Set(\"repo-name\", \"aUser/override-repo\")\n\t\t\t_ = c.Set(\"pipeline-number\", \"10\")\n\n\t\t\tm, err := metadataFromContext(t.Context(), c, nil, nil)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, \"override-repo\", m.Repo.Name)\n\t\t\tassert.Equal(t, int64(10), m.Curr.Number)\n\t\t})\n\t})\n\n\tt.Run(\"InvalidFile\", func(t *testing.T) {\n\t\ttempFile, err := os.CreateTemp(t.TempDir(), \"invalid.json\")\n\t\trequire.NoError(t, err)\n\t\tt.Cleanup(func() { os.Remove(tempFile.Name()) })\n\n\t\t_, err = tempFile.Write([]byte(\"invalid json\"))\n\t\trequire.NoError(t, err)\n\n\t\tflags := []cli.Flag{\n\t\t\t&cli.StringFlag{Name: \"metadata-file\"},\n\t\t}\n\n\t\trunCommand(flags, func(c *cli.Command) {\n\t\t\t_ = c.Set(\"metadata-file\", tempFile.Name())\n\n\t\t\t_, err = metadataFromContext(t.Context(), c, nil, nil)\n\t\t\tassert.Error(t, err)\n\t\t})\n\t})\n\n\tt.Run(\"DefaultValues\", func(t *testing.T) {\n\t\tflags := []cli.Flag{\n\t\t\t&cli.StringFlag{Name: \"repo-name\", Value: \"test/default-repo\"},\n\t\t\t&cli.Int64Flag{Name: \"pipeline-number\", Value: 1},\n\t\t}\n\n\t\trunCommand(flags, func(c *cli.Command) {\n\t\t\tm, err := metadataFromContext(t.Context(), c, nil, nil)\n\t\t\trequire.NoError(t, err)\n\t\t\tif assert.NotNil(t, m) {\n\t\t\t\tassert.Equal(t, \"test\", m.Repo.Owner)\n\t\t\t\tassert.Equal(t, \"default-repo\", m.Repo.Name)\n\t\t\t\tassert.Equal(t, int64(1), m.Curr.Number)\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"MatrixAxis\", func(t *testing.T) {\n\t\trunCommand([]cli.Flag{}, func(c *cli.Command) {\n\t\t\taxis := matrix.Axis{\"go\": \"1.16\", \"os\": \"linux\"}\n\t\t\tm, err := metadataFromContext(t.Context(), c, axis, nil)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.EqualValues(t, map[string]string{\"go\": \"1.16\", \"os\": \"linux\"}, m.Workflow.Matrix)\n\t\t})\n\t})\n}\n\nfunc createTempFile(t *testing.T, content any) string {\n\tt.Helper()\n\ttempFile, err := os.CreateTemp(t.TempDir(), \"metadata.json\")\n\trequire.NoError(t, err)\n\tt.Cleanup(func() { os.Remove(tempFile.Name()) })\n\n\terr = json.NewEncoder(tempFile).Encode(content)\n\trequire.NoError(t, err)\n\treturn tempFile.Name()\n}\n"
  },
  {
    "path": "cli/info/info.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage info\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\n// Command exports the info command.\nvar Command = &cli.Command{\n\tName:      \"info\",\n\tUsage:     \"show information about the current user\",\n\tArgsUsage: \" \",\n\tAction:    info,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplInfo, true)},\n}\n\nfunc info(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tuser, err := client.Self()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tmpl.Execute(os.Stdout, user)\n}\n\n// Template for user information.\nvar tmplInfo = `User: {{ .Login }}\nEmail: {{ .Email }}`\n"
  },
  {
    "path": "cli/internal/config/config.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"slices\"\n\n\t\"github.com/adrg/xdg\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"github.com/zalando/go-keyring\"\n)\n\ntype Config struct {\n\tServerURL string `json:\"server_url\"`\n\tToken     string `json:\"-\"`\n\tLogLevel  string `json:\"log_level\"`\n}\n\nfunc (c *Config) MergeIfNotSet(c2 *Config) {\n\tif c.ServerURL == \"\" {\n\t\tc.ServerURL = c2.ServerURL\n\t}\n\tif c.Token == \"\" {\n\t\tc.Token = c2.Token\n\t}\n\tif c.LogLevel == \"\" {\n\t\tc.LogLevel = c2.LogLevel\n\t}\n}\n\nvar skipSetupForCommands = []string{\"setup\", \"help\", \"h\", \"version\", \"update\", \"lint\", \"exec\", \"completion\", \"\", \"context\", \"ctx\"}\n\nfunc Load(ctx context.Context, c *cli.Command) error {\n\tif firstArg := c.Args().First(); slices.Contains(skipSetupForCommands, firstArg) {\n\t\treturn nil\n\t}\n\n\tcontextConfig, contextErr := GetCurrentContext(ctx, c)\n\tif contextErr == nil {\n\t\tif !c.IsSet(\"server\") {\n\t\t\terr := c.Set(\"server\", contextConfig.ServerURL)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif !c.IsSet(\"token\") {\n\t\t\terr := c.Set(\"token\", contextConfig.Token)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif !c.IsSet(\"log-level\") && contextConfig.LogLevel != \"\" {\n\t\t\terr := c.Set(\"log-level\", contextConfig.LogLevel)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tlog.Debug().Any(\"config\", contextConfig).Msg(\"loaded config from context\")\n\t\treturn nil\n\t}\n\n\t// TODO: remove with next major release\n\t// Fallback: try legacy config file (for backward compatibility)\n\tconfig, err := Get(ctx, c, c.String(\"config\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif config.ServerURL == \"\" || config.Token == \"\" {\n\t\tlog.Info().Msg(\"woodpecker-cli is not set up, run `woodpecker-cli setup` to create a context\")\n\t\treturn errors.New(\"woodpecker-cli is not configured\")\n\t}\n\n\terr = c.Set(\"server\", config.ServerURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = c.Set(\"token\", config.Token)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = c.Set(\"log-level\", config.LogLevel)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Debug().Any(\"config\", config).Msg(\"loaded config from legacy file\")\n\n\treturn nil\n}\n\nfunc getConfigPath(configPath string) (string, error) {\n\tif configPath != \"\" {\n\t\treturn configPath, nil\n\t}\n\n\tconfigPath, err := xdg.ConfigFile(\"woodpecker/config.json\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn configPath, nil\n}\n\nfunc Get(_ context.Context, c *cli.Command, _configPath string) (*Config, error) {\n\tconf := &Config{\n\t\tLogLevel:  c.String(\"log-level\"),\n\t\tToken:     c.String(\"token\"),\n\t\tServerURL: c.String(\"server\"),\n\t}\n\n\tconfigPath, err := getConfigPath(_configPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Debug().Str(\"configPath\", configPath).Msg(\"checking for config file\")\n\n\tcontent, err := os.ReadFile(configPath)\n\tswitch {\n\tcase err != nil && !os.IsNotExist(err):\n\t\tlog.Debug().Err(err).Msg(\"failed to read the config file\")\n\t\treturn nil, err\n\n\tcase err != nil && os.IsNotExist(err):\n\t\tlog.Debug().Msg(\"config file does not exist\")\n\n\tdefault:\n\t\tconfigFromFile := &Config{}\n\t\terr = json.Unmarshal(content, configFromFile)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tconf.MergeIfNotSet(configFromFile)\n\t\tlog.Debug().Msg(\"loaded config from file\")\n\t}\n\n\t// if server or token are explicitly set, use them\n\tif c.IsSet(\"server\") || c.IsSet(\"token\") {\n\t\treturn conf, nil\n\t}\n\n\t// load token from keyring\n\tservice := c.Root().Name\n\tsecret, err := keyring.Get(service, conf.ServerURL)\n\tif errors.Is(err, keyring.ErrUnsupportedPlatform) {\n\t\tlog.Warn().Msg(\"keyring is not supported on this platform\")\n\t\treturn conf, nil\n\t}\n\tif errors.Is(err, keyring.ErrNotFound) {\n\t\tlog.Warn().Msg(\"token not found in keyring\")\n\t\treturn conf, nil\n\t}\n\tconf.Token = secret\n\n\treturn conf, nil\n}\n\nfunc Save(_ context.Context, c *cli.Command, _configPath string, conf *Config) error {\n\tconfig, err := json.Marshal(conf)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconfigPath, err := getConfigPath(_configPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// save token to keyring\n\tservice := c.Root().Name\n\terr = keyring.Set(service, conf.ServerURL, conf.Token)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = os.WriteFile(configPath, config, 0o600)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/internal/config/config_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestConfigMerge(t *testing.T) {\n\tconfig := &Config{\n\t\tServerURL: \"http://localhost:8080\",\n\t\tToken:     \"1234567890\",\n\t\tLogLevel:  \"debug\",\n\t}\n\n\tconfigFromFile := &Config{\n\t\tServerURL: \"https://ci.woodpecker-ci.org\",\n\t\tToken:     \"\",\n\t\tLogLevel:  \"info\",\n\t}\n\n\tconfig.MergeIfNotSet(configFromFile)\n\n\tassert.Equal(t, config.ServerURL, \"http://localhost:8080\")\n\tassert.Equal(t, config.Token, \"1234567890\")\n\tassert.Equal(t, config.LogLevel, \"debug\")\n}\n"
  },
  {
    "path": "cli/internal/config/context.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/adrg/xdg\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"github.com/zalando/go-keyring\"\n)\n\n// Context represents a single CLI context with its connection details.\ntype Context struct {\n\tName      string `json:\"name\"`\n\tServerURL string `json:\"server_url\"`\n\tLogLevel  string `json:\"log_level,omitempty\"`\n}\n\n// Contexts holds all contexts and tracks the current active one.\ntype Contexts struct {\n\tCurrentContext string             `json:\"current_context\"`\n\tContexts       map[string]Context `json:\"contexts\"`\n}\n\nfunc getContextsPath() (string, error) {\n\tconfigPath, err := xdg.ConfigFile(\"woodpecker/contexts.json\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn configPath, nil\n}\n\n// LoadContexts loads all contexts from the contexts file.\nfunc LoadContexts() (*Contexts, error) {\n\tcontextsPath, err := getContextsPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcontent, err := os.ReadFile(contextsPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn &Contexts{\n\t\t\t\tContexts: make(map[string]Context),\n\t\t\t}, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tvar contexts Contexts\n\terr = json.Unmarshal(content, &contexts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif contexts.Contexts == nil {\n\t\tcontexts.Contexts = make(map[string]Context)\n\t}\n\n\treturn &contexts, nil\n}\n\n// SaveContexts saves all contexts to the contexts file.\nfunc SaveContexts(contexts *Contexts) error {\n\tdata, err := json.MarshalIndent(contexts, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcontextsPath, err := getContextsPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Ensure the directory exists.\n\tdir := filepath.Dir(contextsPath)\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\treturn err\n\t}\n\n\treturn os.WriteFile(contextsPath, data, 0o600)\n}\n\n// GetCurrentContext returns the current active context.\nfunc GetCurrentContext(ctx context.Context, c *cli.Command) (*Config, error) {\n\tcontexts, err := LoadContexts()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif contexts.CurrentContext == \"\" {\n\t\treturn nil, errors.New(\"no context is currently set\")\n\t}\n\n\tcontext, exists := contexts.Contexts[contexts.CurrentContext]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"current context '%s' not found\", contexts.CurrentContext)\n\t}\n\n\treturn GetContextConfig(c, &context)\n}\n\n// GetContextConfig loads the config for a specific context including the token from keyring.\nfunc GetContextConfig(c *cli.Command, ctx *Context) (*Config, error) {\n\tconf := &Config{\n\t\tServerURL: ctx.ServerURL,\n\t\tLogLevel:  ctx.LogLevel,\n\t}\n\n\t// Load token from keyring\n\tservice := c.Root().Name\n\tsecret, err := keyring.Get(service, ctx.ServerURL)\n\tif errors.Is(err, keyring.ErrUnsupportedPlatform) {\n\t\tlog.Warn().Msg(\"keyring is not supported on this platform\")\n\t\treturn conf, nil\n\t}\n\tif errors.Is(err, keyring.ErrNotFound) {\n\t\treturn nil, fmt.Errorf(\"token not found in keyring for context '%s'\", ctx.Name)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconf.Token = secret\n\treturn conf, nil\n}\n\n// AddOrUpdateContext adds or updates a context and optionally sets it as current.\nfunc AddOrUpdateContext(c *cli.Command, name, serverURL, token, logLevel string, setCurrent bool) error {\n\tcontexts, err := LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcontexts.Contexts[name] = Context{\n\t\tName:      name,\n\t\tServerURL: serverURL,\n\t\tLogLevel:  logLevel,\n\t}\n\n\tif setCurrent || contexts.CurrentContext == \"\" {\n\t\tcontexts.CurrentContext = name\n\t}\n\n\t// Save token to keyring\n\tservice := c.Root().Name\n\terr = keyring.Set(service, serverURL, token)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn SaveContexts(contexts)\n}\n\n// DeleteContext removes a context.\nfunc DeleteContext(c *cli.Command, name string) error {\n\tcontexts, err := LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcontext, exists := contexts.Contexts[name]\n\tif !exists {\n\t\treturn fmt.Errorf(\"context '%s' not found\", name)\n\t}\n\n\t// Try to delete token from keyring\n\tservice := c.Root().Name\n\terr = keyring.Delete(service, context.ServerURL)\n\tif err != nil && !errors.Is(err, keyring.ErrNotFound) {\n\t\tlog.Warn().Err(err).Msg(\"failed to delete token from keyring\")\n\t}\n\n\tdelete(contexts.Contexts, name)\n\n\t// If we deleted the current context, unset it\n\tif contexts.CurrentContext == name {\n\t\tcontexts.CurrentContext = \"\"\n\t}\n\n\treturn SaveContexts(contexts)\n}\n\n// SetCurrentContext sets the current active context.\nfunc SetCurrentContext(name string) error {\n\tcontexts, err := LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif _, exists := contexts.Contexts[name]; !exists {\n\t\treturn fmt.Errorf(\"context '%s' not found\", name)\n\t}\n\n\tcontexts.CurrentContext = name\n\treturn SaveContexts(contexts)\n}\n\n// RenameContext renames an existing context.\nfunc RenameContext(oldName, newName string) error {\n\tcontexts, err := LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcontext, exists := contexts.Contexts[oldName]\n\tif !exists {\n\t\treturn fmt.Errorf(\"context '%s' not found\", oldName)\n\t}\n\n\tif _, exists := contexts.Contexts[newName]; exists {\n\t\treturn fmt.Errorf(\"context '%s' already exists\", newName)\n\t}\n\n\t// Update the name in the context\n\tcontext.Name = newName\n\tcontexts.Contexts[newName] = context\n\tdelete(contexts.Contexts, oldName)\n\n\t// Update current context if necessary\n\tif contexts.CurrentContext == oldName {\n\t\tcontexts.CurrentContext = newName\n\t}\n\n\treturn SaveContexts(contexts)\n}\n"
  },
  {
    "path": "cli/internal/config/context_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/adrg/xdg\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestContextManagement(t *testing.T) {\n\t// Create a temporary directory for test contexts\n\ttmpDir := t.TempDir()\n\n\t// Override xdg directories for testing\n\tt.Setenv(\"HOME\", tmpDir)\n\txdg.Reload()\n\tcontextsFile, err := xdg.ConfigFile(\"woodpecker/contexts.json\")\n\t_ = os.Remove(contextsFile)\n\trequire.NoError(t, err)\n\n\tt.Run(\"LoadContexts returns empty when file doesn't exist\", func(t *testing.T) {\n\t\tcontexts, err := LoadContexts()\n\t\trequire.NoError(t, err)\n\t\tassert.NotNil(t, contexts)\n\t\tassert.Empty(t, contexts.Contexts)\n\t\tassert.Empty(t, contexts.CurrentContext)\n\t})\n\n\tt.Run(\"SaveContexts creates valid JSON\", func(t *testing.T) {\n\t\tcontexts := &Contexts{\n\t\t\tCurrentContext: \"test\",\n\t\t\tContexts: map[string]Context{\n\t\t\t\t\"test\": {\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tServerURL: \"https://test.example.com\",\n\t\t\t\t\tLogLevel:  \"info\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\terr := SaveContexts(contexts)\n\t\trequire.NoError(t, err)\n\n\t\t// Verify file exists and contains valid JSON\n\t\tdata, err := os.ReadFile(contextsFile)\n\t\trequire.NoError(t, err)\n\t\tassert.Contains(t, string(data), \"test.example.com\")\n\t})\n\n\tt.Run(\"LoadContexts reads saved contexts\", func(t *testing.T) {\n\t\tcontexts, err := LoadContexts()\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"test\", contexts.CurrentContext)\n\t\tassert.Len(t, contexts.Contexts, 1)\n\t\tassert.Equal(t, \"https://test.example.com\", contexts.Contexts[\"test\"].ServerURL)\n\t})\n\n\tt.Run(\"SetCurrentContext updates current context\", func(t *testing.T) {\n\t\tcontexts := &Contexts{\n\t\t\tCurrentContext: \"test\",\n\t\t\tContexts: map[string]Context{\n\t\t\t\t\"test\": {\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tServerURL: \"https://test.example.com\",\n\t\t\t\t},\n\t\t\t\t\"prod\": {\n\t\t\t\t\tName:      \"prod\",\n\t\t\t\t\tServerURL: \"https://prod.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\terr := SaveContexts(contexts)\n\t\trequire.NoError(t, err)\n\n\t\terr = SetCurrentContext(\"prod\")\n\t\trequire.NoError(t, err)\n\n\t\tcontexts, err = LoadContexts()\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"prod\", contexts.CurrentContext)\n\t})\n\n\tt.Run(\"SetCurrentContext fails for non-existent context\", func(t *testing.T) {\n\t\terr := SetCurrentContext(\"nonexistent\")\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not found\")\n\t})\n\n\tt.Run(\"RenameContext updates context name\", func(t *testing.T) {\n\t\tcontexts := &Contexts{\n\t\t\tCurrentContext: \"old\",\n\t\t\tContexts: map[string]Context{\n\t\t\t\t\"old\": {\n\t\t\t\t\tName:      \"old\",\n\t\t\t\t\tServerURL: \"https://test.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\terr := SaveContexts(contexts)\n\t\trequire.NoError(t, err)\n\n\t\terr = RenameContext(\"old\", \"new\")\n\t\trequire.NoError(t, err)\n\n\t\tcontexts, err = LoadContexts()\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"new\", contexts.CurrentContext)\n\t\tassert.Contains(t, contexts.Contexts, \"new\")\n\t\tassert.NotContains(t, contexts.Contexts, \"old\")\n\t\tassert.Equal(t, \"new\", contexts.Contexts[\"new\"].Name)\n\t})\n\n\tt.Run(\"RenameContext fails if target exists\", func(t *testing.T) {\n\t\tcontexts := &Contexts{\n\t\t\tContexts: map[string]Context{\n\t\t\t\t\"ctx1\": {Name: \"ctx1\", ServerURL: \"https://test1.example.com\"},\n\t\t\t\t\"ctx2\": {Name: \"ctx2\", ServerURL: \"https://test2.example.com\"},\n\t\t\t},\n\t\t}\n\t\terr := SaveContexts(contexts)\n\t\trequire.NoError(t, err)\n\n\t\terr = RenameContext(\"ctx1\", \"ctx2\")\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"already exists\")\n\t})\n}\n"
  },
  {
    "path": "cli/internal/util.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gitsight/go-vcsurl\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"golang.org/x/net/proxy\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// NewClient returns a new client from the CLI context.\nfunc NewClient(ctx context.Context, c *cli.Command) (woodpecker.Client, error) {\n\tvar (\n\t\tskip     = c.Bool(\"skip-verify\")\n\t\tsocks    = c.String(\"socks-proxy\")\n\t\tsocksOff = c.Bool(\"socks-proxy-off\")\n\t\ttoken    = c.String(\"token\")\n\t\tserver   = c.String(\"server\")\n\t)\n\tserver = strings.TrimRight(server, \"/\")\n\n\t// if no server url is provided we can default\n\t// to the hosted Woodpecker service.\n\tif len(server) == 0 {\n\t\treturn nil, fmt.Errorf(\"you must provide the Woodpecker server address\")\n\t}\n\tif len(token) == 0 {\n\t\treturn nil, fmt.Errorf(\"you must provide your Woodpecker access token\")\n\t}\n\n\t// attempt to find system CA certs\n\tcerts, err := x509.SystemCertPool()\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"failed to find system CA certs\")\n\t}\n\ttlsConfig := &tls.Config{\n\t\tRootCAs:            certs,\n\t\tInsecureSkipVerify: skip,\n\t}\n\n\tconfig := new(oauth2.Config)\n\tclient := config.Client(ctx,\n\t\t&oauth2.Token{\n\t\t\tAccessToken: token,\n\t\t},\n\t)\n\n\ttrans, _ := client.Transport.(*oauth2.Transport)\n\n\tvar baseTransport http.RoundTripper\n\tif len(socks) != 0 && !socksOff {\n\t\tdialer, err := proxy.SOCKS5(\"tcp\", socks, nil, proxy.Direct)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tbaseTransport = &http.Transport{\n\t\t\tTLSClientConfig: tlsConfig,\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t\tDial:            dialer.Dial,\n\t\t}\n\t} else {\n\t\tbaseTransport = &http.Transport{\n\t\t\tTLSClientConfig: tlsConfig,\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t}\n\t}\n\n\t// Wrap the base transport with User-Agent support\n\ttrans.Base = httputil.NewUserAgentRoundTripper(baseTransport, \"cli\")\n\n\treturn woodpecker.NewClient(server, client), nil\n}\n\nfunc getRepoFromGit(remoteName string) (string, error) {\n\tcmd := exec.Command(\"git\", \"remote\", \"get-url\", remoteName)\n\tstdout, err := cmd.Output()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"could not get remote url: %w\", err)\n\t}\n\n\tgitRemote := strings.TrimSpace(string(stdout))\n\n\tlog.Debug().Str(\"git-remote\", gitRemote).Msg(\"extracted remote url from git\")\n\n\tif len(gitRemote) == 0 {\n\t\treturn \"\", fmt.Errorf(\"no repository provided\")\n\t}\n\n\tu, err := vcsurl.Parse(gitRemote)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"could not parse git remote url: %w\", err)\n\t}\n\n\trepoFullName := u.FullName\n\tlog.Debug().Str(\"repo\", repoFullName).Msg(\"extracted repository from remote url\")\n\n\treturn repoFullName, nil\n}\n\n// ParseRepo parses the repository owner and name from a string.\nfunc ParseRepo(client woodpecker.Client, str string) (repoID int64, err error) {\n\tif str == \"\" {\n\t\tstr, err = getRepoFromGit(\"upstream\")\n\t\tif err != nil {\n\t\t\tlog.Debug().Err(err).Msg(\"could not get repository from git upstream remote\")\n\t\t}\n\t}\n\n\tif str == \"\" {\n\t\tstr, err = getRepoFromGit(\"origin\")\n\t\tif err != nil {\n\t\t\tlog.Debug().Err(err).Msg(\"could not get repository from git origin remote\")\n\t\t}\n\t}\n\n\tif str == \"\" {\n\t\treturn 0, fmt.Errorf(\"no repository provided\")\n\t}\n\n\tif strings.Contains(str, \"/\") {\n\t\trepo, err := client.RepoLookup(str)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\treturn repo.ID, nil\n\t}\n\n\treturn strconv.ParseInt(str, 10, 64)\n}\n\n// ParseKeyPair parses a key=value pair.\nfunc ParseKeyPair(p []string) map[string]string {\n\tparams := map[string]string{}\n\tfor _, i := range p {\n\t\tbefore, after, ok := strings.Cut(i, \"=\")\n\t\tif !ok || before == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparams[before] = after\n\t}\n\treturn params\n}\n\n/*\nParseStep parses the step id form a string which may either be the step PID (step number) or a step name.\nThese rules apply:\n\n- Step PID take precedence over step name when searching for a match.\n- First match is used, when there are multiple steps with the same name.\n\nStrictly speaking, this is not parsing, but a lookup.\n*/\nfunc ParseStep(client woodpecker.Client, repoID, number int64, stepArg string) (stepID int64, err error) {\n\tpipeline, err := client.Pipeline(repoID, number)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tstepPID, err := strconv.ParseInt(stepArg, 10, 64)\n\tif err == nil {\n\t\tfor _, wf := range pipeline.Workflows {\n\t\t\tfor _, step := range wf.Children {\n\t\t\t\tif int64(step.PID) == stepPID {\n\t\t\t\t\treturn step.ID, nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, wf := range pipeline.Workflows {\n\t\tfor _, step := range wf.Children {\n\t\t\tif step.Name == stepArg {\n\t\t\t\treturn step.ID, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"no step with number or name '%s' found\", stepArg)\n}\n"
  },
  {
    "path": "cli/internal/util_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseKeyPair(t *testing.T) {\n\ts := []string{\"FOO=bar\", \"BAR=\", \"BAZ=qux=quux\", \"INVALID\"}\n\tp := ParseKeyPair(s)\n\tassert.Equal(t, \"bar\", p[\"FOO\"])\n\tassert.Equal(t, \"qux=quux\", p[\"BAZ\"])\n\tval, exists := p[\"BAR\"]\n\tassert.Empty(t, val)\n\tassert.True(t, exists, \"missing a key with no value, keys with empty values are also valid\")\n\t_, exists = p[\"INVALID\"]\n\tassert.False(t, exists, \"keys without an equal sign suffix are invalid\")\n}\n"
  },
  {
    "path": "cli/lint/lint.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage lint\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\n// Command exports the info command.\nvar Command = &cli.Command{\n\tName:      \"lint\",\n\tUsage:     \"lint a pipeline configuration file\",\n\tArgsUsage: \"[path/to/.woodpecker.yaml]\",\n\tAction:    lint,\n\tFlags: []cli.Flag{\n\t\t&cli.StringSliceFlag{\n\t\t\tSources: cli.EnvVars(\"WOODPECKER_PLUGINS_PRIVILEGED\"),\n\t\t\tName:    \"plugins-privileged\",\n\t\t\tUsage:   \"allow plugins to run in privileged mode, if set empty, there is no\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tSources: cli.EnvVars(\"WOODPECKER_PLUGINS_TRUSTED_CLONE\"),\n\t\t\tName:    \"plugins-trusted-clone\",\n\t\t\tUsage:   \"plugins that are trusted to handle Git credentials in cloning steps\",\n\t\t\tValue:   constant.TrustedClonePlugins,\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tSources: cli.EnvVars(\"WOODPECKER_LINT_STRICT\"),\n\t\t\tName:    \"strict\",\n\t\t\tUsage:   \"treat warnings as errors\",\n\t\t},\n\t},\n}\n\nfunc lint(ctx context.Context, c *cli.Command) error {\n\treturn common.RunPipelineFunc(ctx, c, lintFile, lintDir)\n}\n\nfunc lintDir(ctx context.Context, c *cli.Command, dir string) error {\n\tvar errorStrings []string\n\tif err := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error {\n\t\tif e != nil {\n\t\t\treturn e\n\t\t}\n\n\t\t// check if it is a regular file (not dir)\n\t\tif info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), \".yaml\") || strings.HasSuffix(info.Name(), \".yml\")) {\n\t\t\tfmt.Println(\"#\", info.Name())\n\t\t\tif err := lintFile(ctx, c, path); err != nil {\n\t\t\t\terrorStrings = append(errorStrings, err.Error())\n\t\t\t}\n\t\t\tfmt.Println(\"\")\n\t\t\treturn nil\n\t\t}\n\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif len(errorStrings) != 0 {\n\t\treturn fmt.Errorf(\"ERRORS: %s\", strings.Join(errorStrings, \"; \"))\n\t}\n\treturn nil\n}\n\nfunc lintFile(_ context.Context, c *cli.Command, file string) error {\n\tfi, err := os.Open(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer fi.Close()\n\n\tbuf, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trawConfig := string(buf)\n\n\tparsedConfig, err := yaml.ParseString(rawConfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconfig := &linter.WorkflowConfig{\n\t\tFile:      path.Base(file),\n\t\tRawConfig: rawConfig,\n\t\tWorkflow:  parsedConfig,\n\t}\n\n\t// TODO: lint multiple files at once to allow checks for sth like \"depends_on\" to work\n\terr = linter.New(\n\t\tlinter.WithTrusted(linter.TrustedConfiguration{\n\t\t\tNetwork:  true,\n\t\t\tVolumes:  true,\n\t\t\tSecurity: true,\n\t\t}),\n\t\tlinter.PrivilegedPlugins(c.StringSlice(\"plugins-privileged\")),\n\t\tlinter.WithTrustedClonePlugins(c.StringSlice(\"plugins-trusted-clone\")),\n\t).Lint([]*linter.WorkflowConfig{config})\n\tif err != nil {\n\t\tstr, err := FormatLintError(config.File, err, c.Bool(\"strict\"))\n\n\t\tif str != \"\" {\n\t\t\tfmt.Print(str)\n\t\t}\n\n\t\treturn err\n\t}\n\n\tfmt.Println(\"✅ Config is valid\")\n\treturn nil\n}\n"
  },
  {
    "path": "cli/lint/utils.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage lint\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/muesli/termenv\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n)\n\nfunc FormatLintError(file string, err error, strict bool) (string, error) {\n\tif err == nil {\n\t\treturn \"\", nil\n\t}\n\n\toutput := termenv.NewOutput(os.Stdout)\n\tstr := \"\"\n\n\tamountErrors := 0\n\tamountWarnings := 0\n\tlinterErrors := pipeline_errors.GetPipelineErrors(err)\n\tfor _, err := range linterErrors {\n\t\tline := \"  \"\n\n\t\tif !strict && err.IsWarning {\n\t\t\tline = fmt.Sprintf(\"%s ⚠️ \", line)\n\t\t\tamountWarnings++\n\t\t} else {\n\t\t\tline = fmt.Sprintf(\"%s ❌\", line)\n\t\t\tamountErrors++\n\t\t}\n\n\t\tif data := pipeline_errors.GetLinterData(err); data != nil {\n\t\t\tline = fmt.Sprintf(\"%s %s\\t%s\", line, output.String(data.Field).Bold(), err.Message)\n\t\t} else {\n\t\t\tline = fmt.Sprintf(\"%s %s\", line, err.Message)\n\t\t}\n\n\t\t// TODO: use table output\n\t\tstr = fmt.Sprintf(\"%s%s\\n\", str, line)\n\t}\n\n\tif amountErrors > 0 {\n\t\tif amountWarnings > 0 {\n\t\t\tstr = fmt.Sprintf(\"🔥 %s has %d errors and warnings:\\n%s\", output.String(file).Underline(), len(linterErrors), str)\n\t\t} else {\n\t\t\tstr = fmt.Sprintf(\"🔥 %s has %d errors:\\n%s\", output.String(file).Underline(), len(linterErrors), str)\n\t\t}\n\t\treturn str, errors.New(\"config has errors\")\n\t}\n\n\tstr = fmt.Sprintf(\"⚠️  %s has %d warnings:\\n%s\", output.String(file).Underline(), len(linterErrors), str)\n\treturn str, nil\n}\n"
  },
  {
    "path": "cli/org/org.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage org\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/org/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/org/secret\"\n)\n\n// Command exports the org command set.\nvar Command = &cli.Command{\n\tName:  \"org\",\n\tUsage: \"manage organizations\",\n\tCommands: []*cli.Command{\n\t\tregistry.Command,\n\t\tsecret.Command,\n\t},\n}\n"
  },
  {
    "path": "cli/org/registry/registry.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the registry command set.\nvar Command = &cli.Command{\n\tName:  \"registry\",\n\tUsage: \"manage organization registries\",\n\tCommands: []*cli.Command{\n\t\tregistryCreateCmd,\n\t\tregistryDeleteCmd,\n\t\tregistryListCmd,\n\t\tregistryShowCmd,\n\t\tregistryUpdateCmd,\n\t},\n}\n\nfunc parseTargetArgs(client woodpecker.Client, c *cli.Command) (orgID int64, err error) {\n\torgIDOrName := c.String(\"organization\")\n\tif orgIDOrName == \"\" {\n\t\torgIDOrName = c.Args().First()\n\t}\n\n\tif orgIDOrName == \"\" {\n\t\tif err := cli.ShowSubcommandHelp(c); err != nil {\n\t\t\treturn -1, err\n\t\t}\n\t}\n\n\tif orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil {\n\t\treturn orgID, nil\n\t}\n\n\torg, err := client.OrgLookup(orgIDOrName)\n\tif err != nil {\n\t\treturn -1, err\n\t}\n\n\treturn org.ID, nil\n}\n"
  },
  {
    "path": "cli/org/registry/registry_add.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryCreateCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a registry\",\n\tArgsUsage: \"[org-id|org-full-name]\",\n\tAction:    registryCreate,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryCreate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.OrgRegistryCreate(orgID, registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/org/registry/registry_list.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list registries\",\n\tArgsUsage: \"[org-id|org-full-name]\",\n\tAction:    registryList,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.RegistryListOptions{}\n\n\tlist, err := client.OrgRegistryList(orgID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, registry := range list {\n\t\tif err := tmpl.Execute(os.Stdout, registry); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for registry list information.\nvar tmplRegistryList = \"\\x1b[33m{{ .Address }} \\x1b[0m\" + `\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n`\n"
  },
  {
    "path": "cli/org/registry/registry_rm.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryDeleteCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a registry\",\n\tArgsUsage: \"[org-id|org-full-name]\",\n\tAction:    registryDelete,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t},\n}\n\nfunc registryDelete(ctx context.Context, c *cli.Command) error {\n\thostname := c.String(\"hostname\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.OrgRegistryDelete(orgID, hostname)\n}\n"
  },
  {
    "path": "cli/org/registry/registry_set.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a registry\",\n\tArgsUsage: \"[org-id|org-full-name]\",\n\tAction:    registryUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryUpdate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.OrgRegistryUpdate(orgID, registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/org/registry/registry_show.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show registry information\",\n\tArgsUsage: \"[org-id|org-full-name]\",\n\tAction:    registryShow,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tformat   = c.String(\"format\") + \"\\n\"\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry, err := client.OrgRegistry(orgID, hostname)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, registry)\n}\n"
  },
  {
    "path": "cli/org/secret/secret.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the secret command.\nvar Command = &cli.Command{\n\tName:  \"secret\",\n\tUsage: \"manage secrets\",\n\tCommands: []*cli.Command{\n\t\tsecretCreateCmd,\n\t\tsecretDeleteCmd,\n\t\tsecretListCmd,\n\t\tsecretShowCmd,\n\t\tsecretUpdateCmd,\n\t},\n}\n\nfunc parseTargetArgs(client woodpecker.Client, c *cli.Command) (orgID int64, err error) {\n\torgIDOrName := c.String(\"organization\")\n\tif orgIDOrName == \"\" {\n\t\torgIDOrName = c.Args().First()\n\t}\n\n\tif orgIDOrName == \"\" {\n\t\tif err := cli.ShowSubcommandHelp(c); err != nil {\n\t\t\treturn -1, err\n\t\t}\n\t}\n\n\tif orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil {\n\t\treturn orgID, nil\n\t}\n\n\torg, err := client.OrgLookup(orgIDOrName)\n\tif err != nil {\n\t\treturn -1, err\n\t}\n\n\treturn org.ID, nil\n}\n"
  },
  {
    "path": "cli/org/secret/secret_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretCreateCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretCreate,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"secret limited to these events\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"secret limited to these images\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretCreate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif len(secret.Events) == 0 {\n\t\tsecret.Events = defaultSecretEvents\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.OrgSecretCreate(orgID, secret)\n\treturn err\n}\n\nvar defaultSecretEvents = []string{\n\twoodpecker.EventPush,\n\twoodpecker.EventTag,\n\twoodpecker.EventRelease,\n\twoodpecker.EventDeploy,\n}\n"
  },
  {
    "path": "cli/org/secret/secret_list.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list secrets\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretList,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.SecretListOptions{}\n\n\tlist, err := client.OrgSecretList(orgID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, secret := range list {\n\t\tif err := tmpl.Execute(os.Stdout, secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for secret list items.\nvar tmplSecretList = \"\\x1b[33m{{ .Name }} \\x1b[0m\" + `\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: <any>\n{{- end }}\n`\n\nvar secretFuncMap = template.FuncMap{\n\t\"list\": func(s []string) string {\n\t\treturn strings.Join(s, \", \")\n\t},\n}\n"
  },
  {
    "path": "cli/org/secret/secret_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretDeleteCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretDelete,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t},\n}\n\nfunc secretDelete(ctx context.Context, c *cli.Command) error {\n\tsecretName := c.String(\"name\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.OrgSecretDelete(orgID, secretName)\n}\n"
  },
  {
    "path": "cli/org/secret/secret_set.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"limit secret to these event\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"limit secret to these image\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretUpdate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.OrgSecretUpdate(orgID, secret)\n\treturn err\n}\n"
  },
  {
    "path": "cli/org/secret/secret_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show secret information\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretShow,\n\tFlags: []cli.Flag{\n\t\tcommon.OrgFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tsecretName = c.String(\"name\")\n\t\tformat     = c.String(\"format\") + \"\\n\"\n\t)\n\n\tif secretName == \"\" {\n\t\treturn fmt.Errorf(\"secret name is missing\")\n\t}\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torgID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret, err := client.OrgSecret(orgID, secretName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, secret)\n}\n"
  },
  {
    "path": "cli/output/output.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage output\n\nimport (\n\t\"errors\"\n\t\"strings\"\n)\n\nvar ErrOutputOptionRequired = errors.New(\"output option required\")\n\nfunc ParseOutputOptions(out string) (string, []string) {\n\tout, opt, found := strings.Cut(out, \"=\")\n\n\tif !found {\n\t\treturn out, nil\n\t}\n\n\tvar optList []string\n\n\tif opt != \"\" {\n\t\toptList = strings.Split(opt, \",\")\n\t}\n\n\treturn out, optList\n}\n"
  },
  {
    "path": "cli/output/output_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage output\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParseOutputOptions(t *testing.T) {\n\tt.Parallel()\n\n\ttestCases := []struct {\n\t\tin   string\n\t\tout  string\n\t\topts []string\n\t}{\n\t\t{\n\t\t\tin:  \"output\",\n\t\t\tout: \"output\",\n\t\t},\n\t\t{\n\t\t\tin:   \"output=a\",\n\t\t\tout:  \"output\",\n\t\t\topts: []string{\"a\"},\n\t\t},\n\t\t{\n\t\t\tin:  \"output=\",\n\t\t\tout: \"output\",\n\t\t},\n\t\t{\n\t\t\tin:   \"output=a,b\",\n\t\t\tout:  \"output\",\n\t\t\topts: []string{\"a\", \"b\"},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tout, opts := ParseOutputOptions(tc.in)\n\t\tassert.Equal(t, tc.out, out)\n\t\tassert.Equal(t, tc.opts, opts)\n\t}\n}\n"
  },
  {
    "path": "cli/output/table.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage output\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\t\"unicode\"\n\n\t\"github.com/go-viper/mapstructure/v2\"\n)\n\n// NewTable creates a new Table.\nfunc NewTable(out io.Writer) *Table {\n\tpadding := 2\n\n\treturn &Table{\n\t\tw:             tabwriter.NewWriter(out, 0, 0, padding, ' ', 0),\n\t\tcolumns:       map[string]bool{},\n\t\tfieldMapping:  map[string]FieldFn{},\n\t\tfieldAlias:    map[string]string{},\n\t\tallowedFields: map[string]bool{},\n\t}\n}\n\ntype FieldFn func(obj any) string\n\ntype writerFlusher interface {\n\tio.Writer\n\tFlush() error\n}\n\n// Table is a generic way to format object as a table.\ntype Table struct {\n\tw             writerFlusher\n\tcolumns       map[string]bool\n\tfieldMapping  map[string]FieldFn\n\tfieldAlias    map[string]string\n\tallowedFields map[string]bool\n}\n\n// Columns returns a list of known output columns.\nfunc (o *Table) Columns() (cols []string) {\n\tfor c := range o.columns {\n\t\tcols = append(cols, c)\n\t}\n\tsort.Strings(cols)\n\treturn cols\n}\n\n// AddFieldAlias overrides the field name to allow custom column headers.\nfunc (o *Table) AddFieldAlias(field, alias string) *Table {\n\to.fieldAlias[strings.ToLower(alias)] = field\n\treturn o\n}\n\n// AddFieldFn adds a function which handles the output of the specified field.\nfunc (o *Table) AddFieldFn(field string, fn FieldFn) *Table {\n\to.fieldMapping[strings.ToLower(field)] = fn\n\to.allowedFields[strings.ToLower(field)] = true\n\to.columns[strings.ToLower(field)] = true\n\treturn o\n}\n\n// AddAllowedFields reads all first level field names of the struct and allows them to be used.\nfunc (o *Table) AddAllowedFields(obj any) (*Table, error) {\n\tv := reflect.ValueOf(obj)\n\tif v.Kind() != reflect.Struct {\n\t\treturn o, fmt.Errorf(\"AddAllowedFields input must be a struct\")\n\t}\n\tt := v.Type()\n\tfor i := 0; i < v.NumField(); i++ {\n\t\tk := t.Field(i).Type.Kind()\n\t\tif k != reflect.Bool &&\n\t\t\tk != reflect.Float32 &&\n\t\t\tk != reflect.Float64 &&\n\t\t\tk != reflect.String &&\n\t\t\tk != reflect.Int &&\n\t\t\tk != reflect.Int64 {\n\t\t\t// only allow simple values\n\t\t\t// complex values need to be mapped via a FieldFn\n\t\t\tcontinue\n\t\t}\n\t\to.allowedFields[strings.ToLower(t.Field(i).Name)] = true\n\t\to.allowedFields[fieldName(t.Field(i).Name)] = true\n\t\to.columns[fieldName(t.Field(i).Name)] = true\n\t}\n\treturn o, nil\n}\n\n// RemoveAllowedField removes fields from the allowed list.\nfunc (o *Table) RemoveAllowedField(fields ...string) *Table {\n\tfor _, field := range fields {\n\t\tdelete(o.allowedFields, field)\n\t\tdelete(o.columns, field)\n\t}\n\treturn o\n}\n\n// ValidateColumns returns an error if invalid columns are specified.\nfunc (o *Table) ValidateColumns(cols []string) error {\n\tvar invalidCols []string\n\tfor _, col := range cols {\n\t\tif _, ok := o.allowedFields[strings.ToLower(col)]; !ok {\n\t\t\tinvalidCols = append(invalidCols, col)\n\t\t}\n\t}\n\tif len(invalidCols) > 0 {\n\t\treturn fmt.Errorf(\"invalid table columns: %s\", strings.Join(invalidCols, \",\"))\n\t}\n\treturn nil\n}\n\n// WriteHeader writes the table header.\nfunc (o *Table) WriteHeader(columns []string) {\n\tvar header []string\n\tfor _, col := range columns {\n\t\theader = append(header, strings.ReplaceAll(strings.ToUpper(col), \"_\", \" \"))\n\t}\n\t_, _ = fmt.Fprintln(o.w, strings.Join(header, \"\\t\"))\n}\n\nfunc (o *Table) Flush() error {\n\treturn o.w.Flush()\n}\n\n// Write writes a table line.\nfunc (o *Table) Write(columns []string, obj any) error {\n\tvar data map[string]any\n\n\tif err := mapstructure.Decode(obj, &data); err != nil {\n\t\treturn fmt.Errorf(\"failed to decode object: %w\", err)\n\t}\n\n\tdataL := map[string]any{}\n\tfor key, value := range data {\n\t\tdataL[strings.ToLower(key)] = value\n\t}\n\n\tvar out []string\n\tfor _, col := range columns {\n\t\tcolName := strings.ToLower(col)\n\t\tif alias, ok := o.fieldAlias[colName]; ok {\n\t\t\tcolName = strings.ToLower(alias)\n\t\t}\n\t\tif fn, ok := o.fieldMapping[strings.ReplaceAll(colName, \"_\", \"\")]; ok {\n\t\t\tout = append(out, sanitizeString(fn(obj)))\n\t\t\tcontinue\n\t\t}\n\t\tif value, ok := dataL[strings.ReplaceAll(colName, \"_\", \"\")]; ok {\n\t\t\tif value == nil {\n\t\t\t\tout = append(out, NA(\"\"))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif b, ok := value.(bool); ok {\n\t\t\t\tout = append(out, YesNo(b))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif s, ok := value.(string); ok {\n\t\t\t\tout = append(out, NA(sanitizeString(s)))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, sanitizeString(value))\n\t\t}\n\t}\n\t_, _ = fmt.Fprintln(o.w, strings.Join(out, \"\\t\"))\n\n\treturn nil\n}\n\nfunc NA(s string) string {\n\tif s == \"\" {\n\t\treturn \"-\"\n\t}\n\treturn s\n}\n\nfunc YesNo(b bool) string {\n\tif b {\n\t\treturn \"yes\"\n\t}\n\treturn \"no\"\n}\n\nfunc fieldName(name string) string {\n\tr := []rune(name)\n\tvar out []rune\n\tfor i := range r {\n\t\tif i > 0 && (unicode.IsUpper(r[i])) && (i+1 < len(r) && unicode.IsLower(r[i+1]) || unicode.IsLower(r[i-1])) {\n\t\t\tout = append(out, '_')\n\t\t}\n\t\tout = append(out, unicode.ToLower(r[i]))\n\t}\n\treturn string(out)\n}\n\nfunc sanitizeString(value any) string {\n\tstr := fmt.Sprintf(\"%v\", value)\n\treplacer := strings.NewReplacer(\"\\n\", \" \", \"\\r\", \" \")\n\treturn strings.TrimSpace(replacer.Replace(str))\n}\n"
  },
  {
    "path": "cli/output/table_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage output\n\nimport (\n\t\"bytes\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype writerFlusherStub struct {\n\tbytes.Buffer\n}\n\nfunc (s writerFlusherStub) Flush() error {\n\treturn nil\n}\n\ntype testFieldsStruct struct {\n\tName   string\n\tNumber int\n\tBool   bool\n}\n\nfunc TestTableOutput(t *testing.T) {\n\tvar wfs writerFlusherStub\n\tto := NewTable(os.Stdout)\n\tto.w = &wfs\n\n\tt.Run(\"AddAllowedFields\", func(t *testing.T) {\n\t\t_, _ = to.AddAllowedFields(testFieldsStruct{})\n\t\t_, ok := to.allowedFields[\"name\"]\n\t\tassert.True(t, ok)\n\t})\n\tt.Run(\"AddFieldAlias\", func(t *testing.T) {\n\t\tto.AddFieldAlias(\"WoodpeckerCI\", \"wp\")\n\t\talias, ok := to.fieldAlias[\"wp\"]\n\t\tassert.True(t, ok)\n\t\tassert.Equal(t, \"WoodpeckerCI\", alias)\n\t})\n\tt.Run(\"AddFieldOutputFn\", func(t *testing.T) {\n\t\tto.AddFieldFn(\"WoodpeckerCI\", FieldFn(func(_ any) string {\n\t\t\treturn \"WOODPECKER CI!!!\"\n\t\t}))\n\t\t_, ok := to.fieldMapping[\"woodpeckerci\"]\n\t\tassert.True(t, ok)\n\t})\n\tt.Run(\"ValidateColumns\", func(t *testing.T) {\n\t\terr := to.ValidateColumns([]string{\"non-existent\", \"NAME\"})\n\t\tassert.Error(t, err)\n\t\tassert.ErrorContains(t, err, \"non-existent\")\n\t\tassert.NotContains(t, err.Error(), \"name\")\n\n\t\tassert.NoError(t, to.ValidateColumns([]string{\"name\"}))\n\t})\n\tt.Run(\"WriteHeader\", func(t *testing.T) {\n\t\tto.WriteHeader([]string{\"wp\", \"name\"})\n\t\tassert.Equal(t, \"WP\\tNAME\\n\", wfs.String())\n\t\twfs.Reset()\n\t})\n\tt.Run(\"WriteLine\", func(t *testing.T) {\n\t\terr := to.Write([]string{\"wp\", \"name\", \"number\", \"bool\"}, &testFieldsStruct{\"test123\", 1000000000, true})\n\t\tassert.NoError(t, err)\n\t\terr = to.Write([]string{\"wp\", \"name\", \"number\", \"bool\"}, &testFieldsStruct{\"\", 1000000000, false})\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"WOODPECKER CI!!!\\ttest123\\t1000000000\\tyes\\nWOODPECKER CI!!!\\t-\\t1000000000\\tno\\n\", wfs.String())\n\t\twfs.Reset()\n\t})\n\tt.Run(\"Columns\", func(t *testing.T) {\n\t\tassert.Len(t, to.Columns(), 4)\n\t})\n}\n"
  },
  {
    "path": "cli/pipeline/approve.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar pipelineApproveCmd = &cli.Command{\n\tName:      \"approve\",\n\tUsage:     \"approve a pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline>\",\n\tAction:    pipelineApprove,\n}\n\nfunc pipelineApprove(ctx context.Context, c *cli.Command) (err error) {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnumber, err := strconv.ParseInt(c.Args().Get(1), 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.PipelineApprove(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Approving pipeline %s#%d\\n\", repoIDOrFullName, number)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/create.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar pipelineCreateCmd = &cli.Command{\n\tName:      \"create\",\n\tUsage:     \"create new pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    pipelineCreate,\n\tFlags: append(common.OutputFlags(\"table\"), []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:     \"branch\",\n\t\t\tUsage:    \"branch to create pipeline from\",\n\t\t\tRequired: true,\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"var\",\n\t\t\tUsage: \"key=value\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t}...),\n}\n\nfunc pipelineCreate(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbranch := c.String(\"branch\")\n\tvariables := make(map[string]string)\n\n\tfor _, vaz := range c.StringSlice(\"var\") {\n\t\tbefore, after, _ := strings.Cut(vaz, \"=\")\n\t\tif before != \"\" && after != \"\" {\n\t\t\tvariables[before] = after\n\t\t}\n\t}\n\n\toptions := &woodpecker.PipelineOptions{\n\t\tBranch:    branch,\n\t\tVariables: variables,\n\t}\n\n\tpipeline, err := client.PipelineCreate(repoID, options)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn pipelineOutput(c, []*woodpecker.Pipeline{pipeline})\n}\n"
  },
  {
    "path": "cli/pipeline/decline.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar pipelineDeclineCmd = &cli.Command{\n\tName:      \"decline\",\n\tUsage:     \"decline a pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline>\",\n\tAction:    pipelineDecline,\n}\n\nfunc pipelineDecline(ctx context.Context, c *cli.Command) (err error) {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnumber, err := strconv.ParseInt(c.Args().Get(1), 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.PipelineDecline(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Declining pipeline %s#%d\\n\", repoIDOrFullName, number)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/deploy/deploy.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage deploy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the deploy command.\nvar Command = &cli.Command{\n\tName:      \"deploy\",\n\tUsage:     \"trigger a pipeline with the 'deployment' event\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline> <environment>\",\n\tAction:    deploy,\n\tFlags: []cli.Flag{\n\t\tcommon.FormatFlag(tmplDeployInfo, false),\n\t\t&cli.StringFlag{\n\t\t\tName:  \"branch\",\n\t\t\tUsage: \"branch filter\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"event filter\",\n\t\t\tValue: woodpecker.EventPush,\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"status\",\n\t\t\tUsage: \"status filter\",\n\t\t\tValue: woodpecker.StatusSuccess,\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:    \"param\",\n\t\t\tAliases: []string{\"p\"},\n\t\t\tUsage:   \"custom parameters to inject into the step environment. Format: KEY=value\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc deploy(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepo := c.Args().First()\n\trepoID, err := internal.ParseRepo(client, repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tbranch := c.String(\"branch\")\n\tevent := c.String(\"event\")\n\tstatus := c.String(\"status\")\n\n\tif branch == \"\" {\n\t\trepo, err := client.Repo(repoID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tbranch = repo.Branch\n\t}\n\n\tpipelineArg := c.Args().Get(1)\n\tvar number int64\n\tif pipelineArg == \"last\" {\n\t\t// Fetch the pipeline number from the last pipeline\n\t\tpipelines, err := client.PipelineList(repoID, woodpecker.PipelineListOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, pipeline := range pipelines {\n\t\t\tif branch != \"\" && pipeline.Branch != branch {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif event != \"\" && pipeline.Event != event {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif status != \"\" && pipeline.Status != status {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif pipeline.Number > number {\n\t\t\t\tnumber = pipeline.Number\n\t\t\t}\n\t\t}\n\t\tif number == 0 {\n\t\t\treturn fmt.Errorf(\"cannot deploy failure pipeline\")\n\t\t}\n\t} else {\n\t\tnumber, err = strconv.ParseInt(pipelineArg, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tenvArgIndex := 2\n\tenv := c.Args().Get(envArgIndex)\n\tif env == \"\" {\n\t\treturn fmt.Errorf(\"please specify the target environment (i.e. production)\")\n\t}\n\n\topt := woodpecker.DeployOptions{\n\t\tDeployTo: env,\n\t\tParams:   internal.ParseKeyPair(c.StringSlice(\"param\")),\n\t}\n\n\tdeploy, err := client.Deploy(repoID, number, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, deploy)\n}\n\n// Template for deployment information.\nvar tmplDeployInfo = `Number: {{ .Number }}\nStatus: {{ .Status }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nMessage: {{ .Message }}\nAuthor: {{ .Author }}\nTarget: {{ .Deploy }}\n`\n"
  },
  {
    "path": "cli/pipeline/kill.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar pipelineKillCmd = &cli.Command{\n\tName:      \"kill\",\n\tUsage:     \"force kill a pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline>\",\n\tAction:    pipelineKill,\n\tHidden:    true,\n}\n\nfunc pipelineKill(ctx context.Context, c *cli.Command) (err error) {\n\tnumber, err := strconv.ParseInt(c.Args().Get(1), 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = client.PipelineDelete(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Force killing pipeline %s#%d\\n\", repoIDOrFullName, number)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/last.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar pipelineLastCmd = &cli.Command{\n\tName:      \"last\",\n\tUsage:     \"show latest pipeline information\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    pipelineLast,\n\tFlags: append(common.OutputFlags(\"table\"), []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"branch\",\n\t\t\tUsage: \"branch name\",\n\t\t\tValue: \"main\",\n\t\t},\n\t}...),\n}\n\nfunc pipelineLast(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.PipelineLastOptions{\n\t\tBranch: c.String(\"branch\"),\n\t}\n\n\tpipeline, err := client.PipelineLast(repoID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn pipelineOutput(c, []*woodpecker.Pipeline{pipeline})\n}\n"
  },
  {
    "path": "cli/pipeline/list.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n//nolint:mnd\nfunc buildPipelineListCmd() *cli.Command {\n\treturn &cli.Command{\n\t\tName:      \"ls\",\n\t\tUsage:     \"show pipeline history\",\n\t\tArgsUsage: \"<repo-id|repo-full-name>\",\n\t\tAction:    List,\n\t\tFlags: append(common.OutputFlags(\"table\"), []cli.Flag{\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"branch\",\n\t\t\t\tUsage: \"branch filter\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"event\",\n\t\t\t\tUsage: \"event filter\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"status\",\n\t\t\t\tUsage: \"status filter\",\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:  \"limit\",\n\t\t\t\tUsage: \"limit the list size\",\n\t\t\t\tValue: 25,\n\t\t\t},\n\t\t\t&cli.TimestampFlag{\n\t\t\t\tName:  \"before\",\n\t\t\t\tUsage: \"only return pipelines before this date (RFC3339)\",\n\t\t\t\tConfig: cli.TimestampConfig{\n\t\t\t\t\tLayouts: []string{\n\t\t\t\t\t\ttime.RFC3339,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&cli.TimestampFlag{\n\t\t\t\tName:  \"after\",\n\t\t\t\tUsage: \"only return pipelines after this date (RFC3339)\",\n\t\t\t\tConfig: cli.TimestampConfig{\n\t\t\t\t\tLayouts: []string{\n\t\t\t\t\t\ttime.RFC3339,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}...),\n\t}\n}\n\nfunc List(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpipelines, err := pipelineList(c, client)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn pipelineOutput(c, pipelines)\n}\n\nfunc pipelineList(c *cli.Command, client woodpecker.Client) ([]*woodpecker.Pipeline, error) {\n\trepoIDOrFullName := c.Args().First()\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\topt := woodpecker.PipelineListOptions{}\n\n\tif before := c.Timestamp(\"before\"); !before.IsZero() {\n\t\topt.Before = before\n\t}\n\tif after := c.Timestamp(\"after\"); !after.IsZero() {\n\t\topt.After = after\n\t}\n\n\tbranch := c.String(\"branch\")\n\tevent := c.String(\"event\")\n\tstatus := c.String(\"status\")\n\tlimit := c.Int(\"limit\")\n\n\tpipelines, err := shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) {\n\t\treturn client.PipelineList(repoID,\n\t\t\twoodpecker.PipelineListOptions{\n\t\t\t\tListOptions: woodpecker.ListOptions{\n\t\t\t\t\tPage: page,\n\t\t\t\t},\n\t\t\t\tBefore: opt.Before,\n\t\t\t\tAfter:  opt.After,\n\t\t\t\tBranch: branch,\n\t\t\t\tEvents: []string{event},\n\t\t\t\tStatus: status,\n\t\t\t},\n\t\t)\n\t}, limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn pipelines, nil\n}\n"
  },
  {
    "path": "cli/pipeline/list_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks\"\n)\n\nfunc TestPipelineList(t *testing.T) {\n\ttesttases := []struct {\n\t\tname        string\n\t\trepoID      int64\n\t\trepoErr     error\n\t\tpipelines   []*woodpecker.Pipeline\n\t\tpipelineErr error\n\t\targs        []string\n\t\texpected    []*woodpecker.Pipeline\n\t\twantErr     error\n\t}{\n\t\t{\n\t\t\tname:   \"success\",\n\t\t\trepoID: 1,\n\t\t\tpipelines: []*woodpecker.Pipeline{\n\t\t\t\t{ID: 1, Branch: \"main\", Event: \"push\", Status: \"success\"},\n\t\t\t\t{ID: 2, Branch: \"develop\", Event: \"pull_request\", Status: \"running\"},\n\t\t\t\t{ID: 3, Branch: \"main\", Event: \"push\", Status: \"failure\"},\n\t\t\t},\n\t\t\targs: []string{\"ls\", \"repo/name\"},\n\t\t\texpected: []*woodpecker.Pipeline{\n\t\t\t\t{ID: 1, Branch: \"main\", Event: \"push\", Status: \"success\"},\n\t\t\t\t{ID: 2, Branch: \"develop\", Event: \"pull_request\", Status: \"running\"},\n\t\t\t\t{ID: 3, Branch: \"main\", Event: \"push\", Status: \"failure\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"limit results\",\n\t\t\trepoID: 1,\n\t\t\tpipelines: []*woodpecker.Pipeline{\n\t\t\t\t{ID: 1, Branch: \"main\", Event: \"push\", Status: \"success\"},\n\t\t\t\t{ID: 2, Branch: \"develop\", Event: \"pull_request\", Status: \"running\"},\n\t\t\t\t{ID: 3, Branch: \"main\", Event: \"push\", Status: \"failure\"},\n\t\t\t},\n\t\t\targs: []string{\"ls\", \"--limit\", \"2\", \"repo/name\"},\n\t\t\texpected: []*woodpecker.Pipeline{\n\t\t\t\t{ID: 1, Branch: \"main\", Event: \"push\", Status: \"success\"},\n\t\t\t\t{ID: 2, Branch: \"develop\", Event: \"pull_request\", Status: \"running\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:        \"pipeline list error\",\n\t\t\trepoID:      1,\n\t\t\tpipelineErr: errors.New(\"pipeline error\"),\n\t\t\targs:        []string{\"ls\", \"repo/name\"},\n\t\t\twantErr:     errors.New(\"pipeline error\"),\n\t\t},\n\t}\n\n\tfor _, tt := range testtases {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockClient := mocks.NewMockClient(t)\n\t\t\tmockClient.On(\"PipelineList\", mock.Anything, mock.Anything).Return(func(_ int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error) {\n\t\t\t\tif tt.pipelineErr != nil {\n\t\t\t\t\treturn nil, tt.pipelineErr\n\t\t\t\t}\n\t\t\t\tif opt.Page == 1 {\n\t\t\t\t\treturn tt.pipelines, nil\n\t\t\t\t}\n\t\t\t\treturn []*woodpecker.Pipeline{}, nil\n\t\t\t}).Maybe()\n\t\t\tmockClient.On(\"RepoLookup\", mock.Anything).Return(&woodpecker.Repo{ID: tt.repoID}, nil)\n\n\t\t\tcommand := buildPipelineListCmd()\n\t\t\tcommand.Writer = io.Discard\n\t\t\tcommand.Action = func(_ context.Context, c *cli.Command) error {\n\t\t\t\tpipelines, err := pipelineList(c, mockClient)\n\t\t\t\tif tt.wantErr != nil {\n\t\t\t\t\tassert.EqualError(t, err, tt.wantErr.Error())\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.EqualValues(t, tt.expected, pipelines)\n\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t_ = command.Run(t.Context(), tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cli/pipeline/log/log.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the log command set.\nvar Command = &cli.Command{\n\tName:  \"log\",\n\tUsage: \"manage logs\",\n\tCommands: []*cli.Command{\n\t\tlogPurgeCmd,\n\t\tlogShowCmd,\n\t},\n}\n"
  },
  {
    "path": "cli/pipeline/log/log_purge.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar logPurgeCmd = &cli.Command{\n\tName:      \"purge\",\n\tUsage:     \"purge a log\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline> [step-number|step-name]\",\n\tAction:    logPurge,\n}\n\nfunc logPurge(ctx context.Context, c *cli.Command) (err error) {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoIDOrFullName := c.Args().First()\n\tif len(repoIDOrFullName) == 0 {\n\t\treturn fmt.Errorf(\"missing required argument repo-id / repo-full-name\")\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid repo '%s': %w\", repoIDOrFullName, err)\n\t}\n\n\tpipelineArg := c.Args().Get(1)\n\tif len(pipelineArg) == 0 {\n\t\treturn fmt.Errorf(\"missing required argument pipeline\")\n\t}\n\tnumber, err := strconv.ParseInt(pipelineArg, 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstepArg := c.Args().Get(2) //nolint:mnd\n\tvar stepID int64\n\tif len(stepArg) != 0 {\n\t\tstepID, err = internal.ParseStep(client, repoID, number, stepArg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif stepID > 0 {\n\t\tfmt.Printf(\"Purging logs for pipeline %s#%d step %d\\n\", repoIDOrFullName, number, stepID)\n\t\terr = client.StepLogsPurge(repoID, number, stepID)\n\t} else {\n\t\tfmt.Printf(\"Purging logs for pipeline %s#%d\\n\", repoIDOrFullName, number)\n\t\terr = client.LogsPurge(repoID, number)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/log/log_show.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar logShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show pipeline logs\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline> [step-number|step-name]\",\n\tAction:    logShow,\n}\n\nfunc logShow(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(repoIDOrFullName) == 0 {\n\t\treturn fmt.Errorf(\"missing required argument repo-id / repo-full-name\")\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid repo '%s': %w \", repoIDOrFullName, err)\n\t}\n\n\tpipelineArg := c.Args().Get(1)\n\tif len(pipelineArg) == 0 {\n\t\treturn fmt.Errorf(\"missing required argument pipeline\")\n\t}\n\tnumber, err := strconv.ParseInt(pipelineArg, 10, 64)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid pipeline '%s': %w\", pipelineArg, err)\n\t}\n\n\tstepArg := c.Args().Get(2) //nolint:mnd\n\tif len(stepArg) == 0 {\n\t\treturn pipelineLog(client, repoID, number)\n\t}\n\n\tstep, err := internal.ParseStep(client, repoID, number, stepArg)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid step '%s': %w\", stepArg, err)\n\t}\n\treturn stepLog(client, repoID, number, step)\n}\n\nfunc pipelineLog(client woodpecker.Client, repoID, number int64) error {\n\tpipeline, err := client.Pipeline(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(tmplPipelineLogs + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, workflow := range pipeline.Workflows {\n\t\tfor _, step := range workflow.Children {\n\t\t\tif err := tmpl.Execute(os.Stdout, map[string]any{\"workflow\": workflow, \"step\": step}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\terr := stepLog(client, repoID, number, step.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc stepLog(client woodpecker.Client, repoID, number, step int64) error {\n\tlogs, err := client.StepLogEntries(repoID, number, step)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, log := range logs {\n\t\tfmt.Println(string(log.Data))\n\t}\n\n\treturn nil\n}\n\n// template for pipeline ps information.\nvar tmplPipelineLogs = \"\\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\\x1b[0m\"\n"
  },
  {
    "path": "cli/pipeline/pipeline.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/output\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/pipeline/deploy\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/pipeline/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the pipeline command set.\nvar Command = &cli.Command{\n\tName:  \"pipeline\",\n\tUsage: \"manage pipelines\",\n\tCommands: []*cli.Command{\n\t\tpipelineApproveCmd,\n\t\tpipelineCreateCmd,\n\t\tpipelineDeclineCmd,\n\t\tdeploy.Command,\n\t\tpipelineKillCmd,\n\t\tpipelineLastCmd,\n\t\tbuildPipelineListCmd(),\n\t\tlog.Command,\n\t\tpipelinePsCmd,\n\t\tpipelinePurgeCmd,\n\t\tpipelineQueueCmd,\n\t\tpipelineShowCmd,\n\t\tpipelineStartCmd,\n\t\tpipelineStopCmd,\n\t},\n}\n\nfunc pipelineOutput(c *cli.Command, pipelines []*woodpecker.Pipeline, fd ...io.Writer) error {\n\toutFmt, outOpt := output.ParseOutputOptions(c.String(\"output\"))\n\tnoHeader := c.Bool(\"output-no-headers\")\n\n\tvar out io.Writer\n\tout = os.Stdout\n\tif len(fd) > 0 {\n\t\tout = fd[0]\n\t}\n\n\tswitch outFmt {\n\tcase \"go-template\":\n\t\tif len(outOpt) < 1 {\n\t\t\treturn fmt.Errorf(\"%w: missing template\", output.ErrOutputOptionRequired)\n\t\t}\n\n\t\ttmpl, err := template.New(\"_\").Parse(outOpt[0] + \"\\n\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tmpl.Execute(out, pipelines); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase \"go-format\":\n\t\tif len(outOpt) < 1 {\n\t\t\treturn fmt.Errorf(\"%w: missing template\", output.ErrOutputOptionRequired)\n\t\t}\n\n\t\ttmpl, err := template.New(\"_\").Parse(outOpt[0] + \"\\n\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, p := range pipelines {\n\t\t\tif err := tmpl.Execute(out, p); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\tcase \"table\":\n\t\tfallthrough\n\tdefault:\n\t\ttable := output.NewTable(out)\n\t\tcols := []string{\"Number\", \"Status\", \"Event\", \"Branch\", \"Message\", \"Author\"}\n\n\t\tif len(outOpt) > 0 {\n\t\t\tcols = outOpt\n\t\t}\n\t\tif !noHeader {\n\t\t\ttable.WriteHeader(cols)\n\t\t}\n\t\tfor _, resource := range pipelines {\n\t\t\tif err := table.Write(cols, resource); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\ttable.Flush()\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/pipeline_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nfunc TestPipelineOutput(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\targs     []string\n\t\texpected string\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"table output with default columns\",\n\t\t\targs:     []string{},\n\t\t\texpected: \"NUMBER  STATUS   EVENT  BRANCH  MESSAGE            AUTHOR\\n1       success  push   main    message multiline  John Doe\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"table output with custom columns\",\n\t\t\targs:     []string{\"output\", \"--output\", \"table=Number,Status,Branch\"},\n\t\t\texpected: \"NUMBER  STATUS   BRANCH\\n1       success  main\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"table output with no header\",\n\t\t\targs:     []string{\"output\", \"--output-no-headers\"},\n\t\t\texpected: \"1  success  push  main  message multiline  John Doe\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"go-template output\",\n\t\t\targs:     []string{\"output\", \"--output\", \"go-template={{range . }}{{.Number}} {{.Status}} {{.Branch}}{{end}}\"},\n\t\t\texpected: \"1 success main\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"go-format output\",\n\t\t\targs:     []string{\"output\", \"--output\", \"go-format={{.Number}} {{.Status}} {{.Branch}}\"},\n\t\t\texpected: \"1 success main\\n\",\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid go-template\",\n\t\t\targs:    []string{\"output\", \"--output\", \"go-template={{.InvalidField}}\"},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tpipelines := []*woodpecker.Pipeline{\n\t\t{\n\t\t\tNumber:  1,\n\t\t\tStatus:  \"success\",\n\t\t\tEvent:   \"push\",\n\t\t\tBranch:  \"main\",\n\t\t\tMessage: \"message\\nmultiline\",\n\t\t\tAuthor:  \"John Doe\\n\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcommand := &cli.Command{\n\t\t\t\tWriter: io.Discard,\n\t\t\t\tName:   \"output\",\n\t\t\t\tFlags:  common.OutputFlags(\"table\"),\n\t\t\t\tAction: func(_ context.Context, c *cli.Command) error {\n\t\t\t\t\tvar buf bytes.Buffer\n\t\t\t\t\terr := pipelineOutput(c, pipelines, &buf)\n\n\t\t\t\t\tif tt.wantErr {\n\t\t\t\t\t\tassert.Error(t, err)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tassert.NoError(t, err)\n\t\t\t\t\tassert.Equal(t, tt.expected, buf.String())\n\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_ = command.Run(t.Context(), tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cli/pipeline/ps.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar pipelinePsCmd = &cli.Command{\n\tName:      \"ps\",\n\tUsage:     \"show pipeline steps\",\n\tArgsUsage: \"<repo-id|repo-full-name> <pipeline>\",\n\tAction:    pipelinePs,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplPipelinePs, false)},\n}\n\nfunc pipelinePs(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid repo '%s': %w\", repoIDOrFullName, err)\n\t}\n\n\tpipelineArg := c.Args().Get(1)\n\tvar number int64\n\n\tif pipelineArg == \"last\" || len(pipelineArg) == 0 {\n\t\t// Fetch the pipeline number from the last pipeline\n\t\tpipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tnumber = pipeline.Number\n\t} else {\n\t\tnumber, err = strconv.ParseInt(pipelineArg, 10, 64)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid pipeline '%s': %w\", pipelineArg, err)\n\t\t}\n\t}\n\n\tpipeline, err := client.Pipeline(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, workflow := range pipeline.Workflows {\n\t\tfor _, step := range workflow.Children {\n\t\t\tif err := tmpl.Execute(os.Stdout, map[string]any{\"workflow\": workflow, \"step\": step}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// template for pipeline ps information.\nvar tmplPipelinePs = \"\\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\\x1b[0m\" + `\nStep: {{ .step.Name }}\nStarted: {{ .step.Started }}\nStopped: {{ .step.Stopped }}\nType: {{ .step.Type }}\nState: {{ .step.State }}\n`\n"
  },
  {
    "path": "cli/pipeline/purge.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n//nolint:mnd\nvar pipelinePurgeCmd = &cli.Command{\n\tName:      \"purge\",\n\tUsage:     \"purge pipelines\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    Purge,\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"branch\",\n\t\t\tUsage: \"remove pipelines of this branch only\",\n\t\t},\n\t\t&cli.DurationFlag{\n\t\t\tName:     \"older-than\",\n\t\t\tUsage:    \"remove pipelines older than the specified time limit\",\n\t\t\tRequired: true,\n\t\t},\n\t\t&cli.Int64Flag{\n\t\t\tName:  \"keep-min\",\n\t\t\tUsage: \"minimum number of pipelines to keep\",\n\t\t\tValue: 10,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"dry-run\",\n\t\t\tUsage: \"disable non-read api calls\",\n\t\t\tValue: false,\n\t\t},\n\t},\n}\n\nfunc Purge(ctx context.Context, c *cli.Command) error {\n\tstart := time.Now()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn pipelinePurge(c, client, start)\n}\n\nfunc pipelinePurge(c *cli.Command, client woodpecker.Client, start time.Time) (err error) {\n\trepoIDOrFullName := c.Args().First()\n\tif len(repoIDOrFullName) == 0 {\n\t\treturn fmt.Errorf(\"missing required argument repo-id / repo-full-name\")\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid repo '%s': %w\", repoIDOrFullName, err)\n\t}\n\n\tbranch := c.String(\"branch\")\n\tolderThan := c.Duration(\"older-than\")\n\tkeepMin := c.Int64(\"keep-min\")\n\tdryRun := c.Bool(\"dry-run\")\n\n\tvar before time.Time\n\tif !start.IsZero() {\n\t\tbefore = start.Add(-olderThan)\n\t}\n\n\tvar pipelinesKeep []*woodpecker.Pipeline\n\n\tif keepMin > 0 {\n\t\tpipelinesKeep, err = fetchPipelinesToKeep(client, repoID, branch, int(keepMin))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tpipelines, err := fetchPipelines(client, repoID, branch, before)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Create a map of pipeline IDs to keep\n\tkeepMap := make(map[int64]struct{})\n\tfor _, p := range pipelinesKeep {\n\t\tkeepMap[p.Number] = struct{}{}\n\t}\n\n\t// Filter pipelines to only include those not in keepMap\n\tvar pipelinesToPurge []*woodpecker.Pipeline\n\tfor _, p := range pipelines {\n\t\tif _, exists := keepMap[p.Number]; !exists {\n\t\t\tpipelinesToPurge = append(pipelinesToPurge, p)\n\t\t}\n\t}\n\n\tmsgPrefix := \"\"\n\tif dryRun {\n\t\tmsgPrefix = \"DRY-RUN: \"\n\t}\n\n\tfor i, p := range pipelinesToPurge {\n\t\t// cspell:words spurge\n\t\tlog.Debug().Msgf(\"%spurge %v/%v pipelines from repo '%v' (pipeline %v)\", msgPrefix, i+1, len(pipelinesToPurge), repoIDOrFullName, p.Number)\n\t\tif dryRun {\n\t\t\tcontinue\n\t\t}\n\n\t\terr := client.PipelineDelete(repoID, p.Number)\n\t\tif err != nil {\n\t\t\tvar clientErr *woodpecker.ClientError\n\t\t\tif errors.As(err, &clientErr) && clientErr.StatusCode == http.StatusUnprocessableEntity {\n\t\t\t\tlog.Error().Err(err).Msgf(\"failed to delete pipeline %d\", p.Number)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc fetchPipelinesToKeep(client woodpecker.Client, repoID int64, branch string, keepMin int) ([]*woodpecker.Pipeline, error) {\n\tif keepMin <= 0 {\n\t\treturn nil, nil\n\t}\n\treturn shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) {\n\t\treturn client.PipelineList(repoID,\n\t\t\twoodpecker.PipelineListOptions{\n\t\t\t\tListOptions: woodpecker.ListOptions{\n\t\t\t\t\tPage: page,\n\t\t\t\t},\n\t\t\t\tBranch: branch,\n\t\t\t},\n\t\t)\n\t}, keepMin)\n}\n\nfunc fetchPipelines(client woodpecker.Client, repoID int64, branch string, before time.Time) ([]*woodpecker.Pipeline, error) {\n\treturn shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) {\n\t\treturn client.PipelineList(repoID,\n\t\t\twoodpecker.PipelineListOptions{\n\t\t\t\tListOptions: woodpecker.ListOptions{\n\t\t\t\t\tPage: page,\n\t\t\t\t},\n\t\t\t\tBefore: before,\n\t\t\t\tBranch: branch,\n\t\t\t},\n\t\t)\n\t}, -1)\n}\n"
  },
  {
    "path": "cli/pipeline/purge_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks\"\n)\n\nfunc TestPipelinePurge(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\trepoID          int64\n\t\targs            []string\n\t\tpipelinesKeep   []*woodpecker.Pipeline\n\t\tpipelines       []*woodpecker.Pipeline\n\t\tmockDeleteError error\n\t\twantDelete      int\n\t\twantErr         error\n\t}{\n\t\t{\n\t\t\tname:   \"success with no pipelines to purge\",\n\t\t\trepoID: 1,\n\t\t\targs:   []string{\"purge\", \"--older-than\", \"1h\", \"repo/name\"},\n\t\t\tpipelinesKeep: []*woodpecker.Pipeline{\n\t\t\t\t{Number: 1},\n\t\t\t},\n\t\t\tpipelines: []*woodpecker.Pipeline{},\n\t\t},\n\t\t{\n\t\t\tname:   \"success with pipelines to purge\",\n\t\t\trepoID: 1,\n\t\t\targs:   []string{\"purge\", \"--older-than\", \"1h\", \"repo/name\"},\n\t\t\tpipelinesKeep: []*woodpecker.Pipeline{\n\t\t\t\t{Number: 1},\n\t\t\t},\n\t\t\tpipelines: []*woodpecker.Pipeline{\n\t\t\t\t{Number: 1},\n\t\t\t\t{Number: 2},\n\t\t\t\t{Number: 3},\n\t\t\t},\n\t\t\twantDelete: 2,\n\t\t},\n\t\t{\n\t\t\tname:   \"continue on 422 error\",\n\t\t\trepoID: 1,\n\t\t\targs:   []string{\"purge\", \"--older-than\", \"1h\", \"repo/name\"},\n\t\t\tpipelinesKeep: []*woodpecker.Pipeline{\n\t\t\t\t{Number: 1},\n\t\t\t},\n\t\t\tpipelines: []*woodpecker.Pipeline{\n\t\t\t\t{Number: 1},\n\t\t\t\t{Number: 2},\n\t\t\t\t{Number: 3},\n\t\t\t},\n\t\t\twantDelete: 2,\n\t\t\tmockDeleteError: &woodpecker.ClientError{\n\t\t\t\tStatusCode: 422,\n\t\t\t\tMessage:    \"test error\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockClient := mocks.NewMockClient(t)\n\t\t\tmockClient.On(\"RepoLookup\", mock.Anything).Maybe().Return(&woodpecker.Repo{ID: tt.repoID}, nil)\n\n\t\t\tmockClient.On(\"PipelineList\", mock.Anything, mock.Anything).Return(func(_ int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error) {\n\t\t\t\t// Return keep pipelines for first call\n\t\t\t\tif opt.Before.IsZero() {\n\t\t\t\t\tif opt.Page == 1 {\n\t\t\t\t\t\treturn tt.pipelinesKeep, nil\n\t\t\t\t\t}\n\t\t\t\t\treturn []*woodpecker.Pipeline{}, nil\n\t\t\t\t}\n\n\t\t\t\t// Return pipelines to purge for calls with Before filter\n\t\t\t\tif !opt.Before.IsZero() {\n\t\t\t\t\tif opt.Page == 1 {\n\t\t\t\t\t\treturn tt.pipelines, nil\n\t\t\t\t\t}\n\t\t\t\t\treturn []*woodpecker.Pipeline{}, nil\n\t\t\t\t}\n\n\t\t\t\treturn []*woodpecker.Pipeline{}, nil\n\t\t\t}).Maybe()\n\n\t\t\tif tt.mockDeleteError != nil {\n\t\t\t\tmockClient.On(\"PipelineDelete\", tt.repoID, mock.Anything).Return(tt.mockDeleteError)\n\t\t\t} else if tt.wantDelete > 0 {\n\t\t\t\tmockClient.On(\"PipelineDelete\", tt.repoID, mock.Anything).Return(nil).Times(tt.wantDelete)\n\t\t\t}\n\n\t\t\tcommand := pipelinePurgeCmd\n\t\t\tcommand.Writer = io.Discard\n\t\t\tcommand.Action = func(_ context.Context, c *cli.Command) error {\n\t\t\t\terr := pipelinePurge(c, mockClient, time.Unix(1, 1))\n\n\t\t\t\tif tt.wantErr != nil {\n\t\t\t\t\tassert.EqualError(t, err, tt.wantErr.Error())\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t_ = command.Run(t.Context(), tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cli/pipeline/queue.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar pipelineQueueCmd = &cli.Command{\n\tName:      \"queue\",\n\tUsage:     \"show pipeline queue\",\n\tArgsUsage: \" \",\n\tAction:    pipelineQueue,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplPipelineQueue, false)},\n}\n\nfunc pipelineQueue(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tpipelines, err := client.PipelineQueue()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(pipelines) == 0 {\n\t\tfmt.Println(\"there are no pending or running pipelines\")\n\t\treturn nil\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, pipeline := range pipelines {\n\t\tif err := tmpl.Execute(os.Stdout, pipeline); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for pipeline list information.\nvar tmplPipelineQueue = \"\\x1b[33m{{ .FullName }} #{{ .Number }} \\x1b[0m\" + `\nStatus: {{ .Status }}\nEvent: {{ .Event }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\nMessage: {{ .Message }}\n`\n"
  },
  {
    "path": "cli/pipeline/show.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar pipelineShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show pipeline information\",\n\tArgsUsage: \"<repo-id|repo-full-name> [pipeline]\",\n\tAction:    pipelineShow,\n\tFlags:     common.OutputFlags(\"table\"),\n}\n\nfunc pipelineShow(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tpipelineArg := c.Args().Get(1)\n\n\tvar number int64\n\tif pipelineArg == \"last\" || len(pipelineArg) == 0 {\n\t\t// Fetch the pipeline number from the last pipeline\n\t\tpipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnumber = pipeline.Number\n\t} else {\n\t\tnumber, err = strconv.ParseInt(pipelineArg, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tpipeline, err := client.Pipeline(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn pipelineOutput(c, []*woodpecker.Pipeline{pipeline})\n}\n"
  },
  {
    "path": "cli/pipeline/start.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar pipelineStartCmd = &cli.Command{\n\tName:      \"start\",\n\tUsage:     \"start a pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name> [pipeline]\",\n\tAction:    pipelineStart,\n\tFlags: []cli.Flag{\n\t\t&cli.StringSliceFlag{\n\t\t\tName:    \"param\",\n\t\t\tAliases: []string{\"p\"},\n\t\t\tUsage:   \"custom parameters to inject into the step environment. Format: KEY=value\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc pipelineStart(ctx context.Context, c *cli.Command) (err error) {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tpipelineArg := c.Args().Get(1)\n\tvar number int64\n\tif pipelineArg == \"last\" {\n\t\t// Fetch the pipeline number from the last pipeline\n\t\tpipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tnumber = pipeline.Number\n\t} else {\n\t\tif len(pipelineArg) == 0 {\n\t\t\treturn errors.New(\"missing step number\")\n\t\t}\n\t\tnumber, err = strconv.ParseInt(pipelineArg, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\topt := woodpecker.PipelineStartOptions{\n\t\tParams: internal.ParseKeyPair(c.StringSlice(\"param\")),\n\t}\n\n\tpipeline, err := client.PipelineStart(repoID, number, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Starting pipeline %s#%d\\n\", repoIDOrFullName, pipeline.Number)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/pipeline/stop.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar pipelineStopCmd = &cli.Command{\n\tName:      \"stop\",\n\tUsage:     \"stop a pipeline\",\n\tArgsUsage: \"<repo-id|repo-full-name> [pipeline]\",\n\tAction:    pipelineStop,\n}\n\nfunc pipelineStop(ctx context.Context, c *cli.Command) (err error) {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnumber, err := strconv.ParseInt(c.Args().Get(1), 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = client.PipelineStop(repoID, number)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Stopping pipeline %s#%d\\n\", repoIDOrFullName, number)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/cron/cron.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the cron command set.\nvar Command = &cli.Command{\n\tName:  \"cron\",\n\tUsage: \"manage cron jobs\",\n\tCommands: []*cli.Command{\n\t\tcronCreateCmd,\n\t\tcronDeleteCmd,\n\t\tcronListCmd,\n\t\tcronShowCmd,\n\t\tcronUpdateCmd,\n\t},\n}\n"
  },
  {
    "path": "cli/repo/cron/cron_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar cronCreateCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a cron job\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    cronCreate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:     \"name\",\n\t\t\tUsage:    \"cron name\",\n\t\t\tRequired: true,\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"branch\",\n\t\t\tUsage: \"cron branch\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:     \"schedule\",\n\t\t\tUsage:    \"cron schedule\",\n\t\t\tRequired: true,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"enabled\",\n\t\t\tUsage: \"whether cron is enabled\",\n\t\t\tValue: true,\n\t\t},\n\t\tcommon.FormatFlag(tmplCronList, true),\n\t},\n}\n\nfunc cronCreate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tcronName         = c.String(\"name\")\n\t\tbranch           = c.String(\"branch\")\n\t\tschedule         = c.String(\"schedule\")\n\t\trepoIDOrFullName = c.String(\"repository\")\n\t\tformat           = c.String(\"format\") + \"\\n\"\n\t\tenabled          = c.Bool(\"enabled\")\n\t)\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcron := &woodpecker.Cron{\n\t\tName:     cronName,\n\t\tBranch:   branch,\n\t\tSchedule: schedule,\n\t\tEnabled:  enabled,\n\t}\n\tcron, err = client.CronCreate(repoID, cron)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, cron)\n}\n"
  },
  {
    "path": "cli/repo/cron/cron_list.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar cronListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list cron jobs\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    cronList,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\tcommon.FormatFlag(tmplCronList, true),\n\t},\n}\n\nfunc cronList(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tformat           = c.String(\"format\") + \"\\n\"\n\t\trepoIDOrFullName = c.String(\"repository\")\n\t)\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\topt := woodpecker.CronListOptions{}\n\tlist, err := client.CronList(repoID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, cron := range list {\n\t\tif err := tmpl.Execute(os.Stdout, cron); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// tTemplate for pipeline list information.\nvar tmplCronList = \"\\x1b[33m{{ .Name }} \\x1b[0m\" + `\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n`\n"
  },
  {
    "path": "cli/repo/cron/cron_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar cronDeleteCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a cron job\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    cronDelete,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:     \"id\",\n\t\t\tUsage:    \"cron id\",\n\t\t\tRequired: true,\n\t\t},\n\t},\n}\n\nfunc cronDelete(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tcronID           = c.Int64(\"id\")\n\t\trepoIDOrFullName = c.String(\"repository\")\n\t)\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = client.CronDelete(repoID, cronID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Println(\"Success\")\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/cron/cron_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar cronShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show cron job information\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    cronShow,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:     \"id\",\n\t\t\tUsage:    \"cron id\",\n\t\t\tRequired: true,\n\t\t},\n\t\tcommon.FormatFlag(tmplCronList, true),\n\t},\n}\n\nfunc cronShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tcronID           = c.Int64(\"id\")\n\t\trepoIDOrFullName = c.String(\"repository\")\n\t\tformat           = c.String(\"format\") + \"\\n\"\n\t)\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcron, err := client.CronGet(repoID, cronID)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, cron)\n}\n"
  },
  {
    "path": "cli/repo/cron/cron_update.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar cronUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a cron job\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    cronUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:     \"id\",\n\t\t\tUsage:    \"cron id\",\n\t\t\tRequired: true,\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"cron name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"branch\",\n\t\t\tUsage: \"cron branch\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"schedule\",\n\t\t\tUsage: \"cron schedule\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"enabled\",\n\t\t\tUsage: \"whether cron is enabled\",\n\t\t\tValue: true,\n\t\t},\n\t\tcommon.FormatFlag(tmplCronList, true),\n\t},\n}\n\nfunc cronUpdate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\trepoIDOrFullName = c.String(\"repository\")\n\t\tcronID           = c.Int64(\"id\")\n\t\tjobName          = c.String(\"name\")\n\t\tbranch           = c.String(\"branch\")\n\t\tschedule         = c.String(\"schedule\")\n\t\tformat           = c.String(\"format\") + \"\\n\"\n\t\tenabled          = c.Bool(\"enabled\")\n\t)\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcron := &woodpecker.Cron{\n\t\tID:       cronID,\n\t\tName:     jobName,\n\t\tBranch:   branch,\n\t\tSchedule: schedule,\n\t\tEnabled:  enabled,\n\t}\n\tcron, err = client.CronUpdate(repoID, cron)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, cron)\n}\n"
  },
  {
    "path": "cli/repo/registry/registry.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the registry command set.\nvar Command = &cli.Command{\n\tName:  \"registry\",\n\tUsage: \"manage registries\",\n\tCommands: []*cli.Command{\n\t\tregistryCreateCmd,\n\t\tregistryDeleteCmd,\n\t\tregistryListCmd,\n\t\tregistryShowCmd,\n\t\tregistryUpdateCmd,\n\t},\n}\n\nfunc parseTargetArgs(client woodpecker.Client, c *cli.Command) (repoID int64, err error) {\n\trepoIDOrFullName := c.String(\"repository\")\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\n\treturn internal.ParseRepo(client, repoIDOrFullName)\n}\n"
  },
  {
    "path": "cli/repo/registry/registry_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryCreateCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a registry\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    registryCreate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryCreate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.RegistryCreate(repoID, registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/repo/registry/registry_list.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list registries\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    registryList,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.RegistryListOptions{}\n\n\tlist, err := client.RegistryList(repoID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, registry := range list {\n\t\tif err := tmpl.Execute(os.Stdout, registry); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for registry list information.\nvar tmplRegistryList = \"\\x1b[33m{{ .Address }} \\x1b[0m\" + `\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n`\n"
  },
  {
    "path": "cli/repo/registry/registry_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryDeleteCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a registry\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    registryDelete,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t},\n}\n\nfunc registryDelete(ctx context.Context, c *cli.Command) error {\n\thostname := c.String(\"hostname\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.RegistryDelete(repoID, hostname)\n}\n"
  },
  {
    "path": "cli/repo/registry/registry_set.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar registryUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a registry\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    registryUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"username\",\n\t\t\tUsage: \"registry username\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"password\",\n\t\t\tUsage: \"registry password\",\n\t\t},\n\t},\n}\n\nfunc registryUpdate(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tusername = c.String(\"username\")\n\t\tpassword = c.String(\"password\")\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry := &woodpecker.Registry{\n\t\tAddress:  hostname,\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n\tif strings.HasPrefix(registry.Password, \"@\") {\n\t\tpath := strings.TrimPrefix(registry.Password, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tregistry.Password = string(out)\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.RegistryUpdate(repoID, registry)\n\treturn err\n}\n"
  },
  {
    "path": "cli/repo/registry/registry_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar registryShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show registry information\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    registryShow,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"hostname\",\n\t\t\tUsage: \"registry hostname\",\n\t\t\tValue: \"docker.io\",\n\t\t},\n\t\tcommon.FormatFlag(tmplRegistryList, true),\n\t},\n}\n\nfunc registryShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\thostname = c.String(\"hostname\")\n\t\tformat   = c.String(\"format\") + \"\\n\"\n\t)\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tregistry, err := client.Registry(repoID, hostname)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, registry)\n}\n"
  },
  {
    "path": "cli/repo/repo.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/output\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/repo/cron\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/repo/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/repo/secret\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the repository command.\nvar Command = &cli.Command{\n\tName:  \"repo\",\n\tUsage: \"manage repositories\",\n\tCommands: []*cli.Command{\n\t\trepoAddCmd,\n\t\trepoChownCmd,\n\t\tcron.Command,\n\t\trepoListCmd,\n\t\tregistry.Command,\n\t\trepoRemoveCmd,\n\t\trepoRepairCmd,\n\t\tsecret.Command,\n\t\trepoShowCmd,\n\t\trepoSyncCmd,\n\t\trepoUpdateCmd,\n\t},\n}\n\nfunc repoOutput(c *cli.Command, repos []*woodpecker.Repo, fd ...io.Writer) error {\n\toutFmt, outOpt := output.ParseOutputOptions(c.String(\"output\"))\n\tnoHeader := c.Bool(\"output-no-headers\")\n\n\tlegacyFmt := c.String(\"format\")\n\tif legacyFmt != \"\" {\n\t\tlog.Warn().Msgf(\"the --format flag is deprecated, please use --output instead\")\n\n\t\toutFmt = \"go-template\"\n\t\toutOpt = []string{fmt.Sprintf(\"{{range . }}%s{{ print \\\"\\\\n\\\" }}{{end}}\", legacyFmt)}\n\t}\n\n\tvar out io.Writer\n\tout = os.Stdout\n\tif len(fd) > 0 {\n\t\tout = fd[0]\n\t}\n\n\tswitch outFmt {\n\tcase \"go-template\":\n\t\tif len(outOpt) < 1 {\n\t\t\treturn fmt.Errorf(\"%w: missing template\", output.ErrOutputOptionRequired)\n\t\t}\n\n\t\ttmpl, err := template.New(\"_\").Parse(outOpt[0] + \"\\n\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tmpl.Execute(out, repos); err != nil {\n\t\t\treturn err\n\t\t}\n\tcase \"go-format\":\n\t\tif len(outOpt) < 1 {\n\t\t\treturn fmt.Errorf(\"%w: missing template\", output.ErrOutputOptionRequired)\n\t\t}\n\n\t\ttmpl, err := template.New(\"_\").Parse(outOpt[0] + \"\\n\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, r := range repos {\n\t\t\tif err := tmpl.Execute(out, r); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\tcase \"table\":\n\t\tfallthrough\n\tdefault:\n\t\ttable := output.NewTable(out)\n\n\t\t// Add custom field mapping for nested Trusted fields\n\t\ttable.AddFieldFn(\"TrustedNetwork\", func(obj any) string {\n\t\t\trepo, ok := obj.(*woodpecker.Repo)\n\t\t\tif !ok {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn output.YesNo(repo.Trusted.Network)\n\t\t})\n\t\ttable.AddFieldFn(\"TrustedSecurity\", func(obj any) string {\n\t\t\trepo, ok := obj.(*woodpecker.Repo)\n\t\t\tif !ok {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn output.YesNo(repo.Trusted.Security)\n\t\t})\n\t\ttable.AddFieldFn(\"TrustedVolume\", func(obj any) string {\n\t\t\trepo, ok := obj.(*woodpecker.Repo)\n\t\t\tif !ok {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn output.YesNo(repo.Trusted.Volumes)\n\t\t})\n\n\t\ttable.AddFieldAlias(\"Is_Active\", \"Active\")\n\t\ttable.AddFieldAlias(\"Is_SCM_Private\", \"SCM_Private\")\n\n\t\tcols := []string{\"Full_Name\", \"Branch\", \"Forge_URL\", \"Visibility\", \"SCM_Private\", \"Active\", \"Allow_Pull\"}\n\n\t\tif len(outOpt) > 0 {\n\t\t\tcols = outOpt\n\t\t}\n\t\tif !noHeader {\n\t\t\ttable.WriteHeader(cols)\n\t\t}\n\t\tfor _, resource := range repos {\n\t\t\tif err := table.Write(cols, resource); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\ttable.Flush()\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/repo_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar repoAddCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a repository\",\n\tArgsUsage: \"<forge-remote-id>\",\n\tAction:    repoAdd,\n}\n\nfunc repoAdd(ctx context.Context, c *cli.Command) error {\n\t_forgeRemoteID := c.Args().First()\n\tforgeRemoteID, err := strconv.Atoi(_forgeRemoteID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid forge remote id: %s\", _forgeRemoteID)\n\t}\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.RepoPostOptions{\n\t\tForgeRemoteID: int64(forgeRemoteID),\n\t}\n\n\trepo, err := client.RepoPost(opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Successfully activated repository with forge remote %s\\n\", repo.FullName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/repo_chown.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar repoChownCmd = &cli.Command{\n\tName:      \"chown\",\n\tUsage:     \"assume ownership of a repository\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    repoChown,\n}\n\nfunc repoChown(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepo, err := client.RepoChown(repoID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Successfully assumed ownership of repository %s\\n\", repo.FullName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/repo_list.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar repoListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list all repos\",\n\tArgsUsage: \" \",\n\tAction:    List,\n\tFlags: append(common.OutputFlags(\"table\"), []cli.Flag{\n\t\tcommon.FormatFlag(\"\", true),\n\t\t&cli.StringFlag{\n\t\t\tName:  \"org\",\n\t\t\tUsage: \"filter by organization\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"all\",\n\t\t\tUsage: \"query all repos, including inactive ones\",\n\t\t},\n\t}...),\n}\n\nfunc List(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepos, err := repoList(c, client)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn repoOutput(c, repos)\n}\n\nfunc repoList(c *cli.Command, client woodpecker.Client) ([]*woodpecker.Repo, error) {\n\trepos := make([]*woodpecker.Repo, 0)\n\topt := woodpecker.RepoListOptions{\n\t\tAll: c.Bool(\"all\"),\n\t}\n\n\traw, err := client.RepoList(opt)\n\tif err != nil || len(raw) == 0 {\n\t\treturn nil, err\n\t}\n\n\torg := c.String(\"org\")\n\tfor _, repo := range raw {\n\t\tif org != \"\" && org != repo.Owner {\n\t\t\tcontinue\n\t\t}\n\t\trepos = append(repos, repo)\n\t}\n\treturn repos, nil\n}\n"
  },
  {
    "path": "cli/repo/repo_repair.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar repoRepairCmd = &cli.Command{\n\tName:      \"repair\",\n\tUsage:     \"repair repository webhooks\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    repoRepair,\n}\n\nfunc repoRepair(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := client.RepoRepair(repoID); err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Successfully repaired repository %s\\n\", repoIDOrFullName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/repo_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar repoRemoveCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a repository\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    repoRemove,\n}\n\nfunc repoRemove(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := client.RepoDel(repoID); err != nil {\n\t\treturn err\n\t}\n\tfmt.Printf(\"Successfully removed repository %s\\n\", repoIDOrFullName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/repo_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar repoShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show repository information\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    Show,\n\tFlags:     common.OutputFlags(\"table\"),\n}\n\nfunc Show(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepo, err := repoShow(c, client)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn repoOutput(c, []*woodpecker.Repo{repo})\n}\n\nfunc repoShow(c *cli.Command, client woodpecker.Client) (*woodpecker.Repo, error) {\n\trepoIDOrFullName := c.Args().First()\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trepo, err := client.Repo(repoID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn repo, nil\n}\n"
  },
  {
    "path": "cli/repo/repo_show_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks\"\n)\n\nfunc TestRepoShow(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\trepoID        int64\n\t\tmockRepo      *woodpecker.Repo\n\t\tmockError     error\n\t\texpectedError bool\n\t\texpected      *woodpecker.Repo\n\t\targs          []string\n\t}{\n\t\t{\n\t\t\tname:     \"valid repo by ID\",\n\t\t\trepoID:   123,\n\t\t\tmockRepo: &woodpecker.Repo{Name: \"test-repo\"},\n\t\t\texpected: &woodpecker.Repo{Name: \"test-repo\"},\n\t\t\targs:     []string{\"show\", \"123\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"valid repo by full name\",\n\t\t\trepoID:   456,\n\t\t\tmockRepo: &woodpecker.Repo{ID: 456, Name: \"repo\", Owner: \"owner\"},\n\t\t\texpected: &woodpecker.Repo{ID: 456, Name: \"repo\", Owner: \"owner\"},\n\t\t\targs:     []string{\"show\", \"owner/repo\"},\n\t\t},\n\t\t{\n\t\t\tname:          \"invalid repo ID\",\n\t\t\trepoID:        999,\n\t\t\texpectedError: true,\n\t\t\targs:          []string{\"show\", \"invalid\"},\n\t\t\tmockError:     errors.New(\"repo not found\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockClient := mocks.NewMockClient(t)\n\t\t\tmockClient.On(\"Repo\", tt.repoID).Return(tt.mockRepo, tt.mockError).Maybe()\n\t\t\tmockClient.On(\"RepoLookup\", \"owner/repo\").Return(tt.mockRepo, nil).Maybe()\n\n\t\t\tcommand := repoShowCmd\n\t\t\tcommand.Writer = io.Discard\n\t\t\tcommand.Action = func(_ context.Context, c *cli.Command) error {\n\t\t\t\toutput, err := repoShow(c, mockClient)\n\t\t\t\tif tt.expectedError {\n\t\t\t\t\tassert.Error(t, err)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expected, output)\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\t_ = command.Run(t.Context(), tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cli/repo/repo_sync.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"text/template\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar repoSyncCmd = &cli.Command{\n\tName:      \"sync\",\n\tUsage:     \"synchronize the repository list\",\n\tArgsUsage: \" \",\n\tAction:    repoSync,\n\tFlags:     []cli.Flag{common.FormatFlag(tmplRepoList, false)},\n}\n\n// TODO: remove this and add an option to the list cmd as we do not store the remote repo list anymore\nfunc repoSync(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.RepoListOptions{\n\t\tAll: true,\n\t}\n\n\trepos, err := client.RepoList(opt)\n\tif err != nil || len(repos) == 0 {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Parse(c.String(\"format\") + \"\\n\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\torg := c.String(\"org\")\n\tfor _, repo := range repos {\n\t\tif org != \"\" && org != repo.Owner {\n\t\t\tcontinue\n\t\t}\n\t\tif err := tmpl.Execute(os.Stdout, repo); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for repository list items.\nvar tmplRepoList = \"\\x1b[33m{{ .FullName }}\\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})\"\n"
  },
  {
    "path": "cli/repo/repo_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nfunc TestRepoOutput(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\targs     []string\n\t\texpected string\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"table output with default columns\",\n\t\t\targs:     []string{},\n\t\t\texpected: \"FULL NAME  BRANCH  FORGE URL        VISIBILITY  SCM PRIVATE  ACTIVE  ALLOW PULL\\norg/repo1  main    git.example.com  public      no           yes     yes\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"table output with custom columns\",\n\t\t\targs:     []string{\"output\", \"--output\", \"table=Name,Forge_URL,Trusted_Network\"},\n\t\t\texpected: \"NAME   FORGE URL        TRUSTED NETWORK\\nrepo1  git.example.com  yes\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"table output with no header\",\n\t\t\targs:     []string{\"output\", \"--output-no-headers\"},\n\t\t\texpected: \"org/repo1  main  git.example.com  public  no  yes  yes\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"go-template output\",\n\t\t\targs:     []string{\"output\", \"--output\", \"go-template={{range . }}{{.Name}} {{.ForgeURL}} {{.Trusted.Network}}{{end}}\"},\n\t\t\texpected: \"repo1 git.example.com true\\n\",\n\t\t},\n\t\t{\n\t\t\tname:     \"go-format output\",\n\t\t\targs:     []string{\"output\", \"--output\", \"go-format={{.Name}} {{.ForgeURL}} {{.Trusted.Network}}\"},\n\t\t\texpected: \"repo1 git.example.com true\\n\",\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid go-template\",\n\t\t\targs:    []string{\"output\", \"--output\", \"go-template={{.InvalidField}}\"},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\trepos := []*woodpecker.Repo{\n\t\t{\n\t\t\tName:       \"repo1\",\n\t\t\tFullName:   \"org/repo1\",\n\t\t\tForgeURL:   \"git.example.com\",\n\t\t\tBranch:     \"main\",\n\t\t\tVisibility: \"public\",\n\t\t\tIsActive:   true,\n\t\t\tAllowPull:  true,\n\t\t\tTrusted: woodpecker.TrustedConfiguration{\n\t\t\t\tNetwork: true,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcommand := &cli.Command{\n\t\t\t\tWriter: io.Discard,\n\t\t\t\tName:   \"output\",\n\t\t\t\tFlags:  common.OutputFlags(\"table\"),\n\t\t\t\tAction: func(_ context.Context, c *cli.Command) error {\n\t\t\t\t\tvar buf bytes.Buffer\n\t\t\t\t\terr := repoOutput(c, repos, &buf)\n\n\t\t\t\t\tif tt.wantErr {\n\t\t\t\t\t\tassert.Error(t, err)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\n\t\t\t\t\tassert.NoError(t, err)\n\t\t\t\t\tassert.Equal(t, tt.expected, buf.String())\n\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_ = command.Run(t.Context(), tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cli/repo/repo_update.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage repo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar repoUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a repository\",\n\tArgsUsage: \"<repo-id|repo-full-name>\",\n\tAction:    repoUpdate,\n\tFlags: []cli.Flag{\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"trusted-security\",\n\t\t\tUsage: \"repository is security trusted\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"trusted-volumes\",\n\t\t\tUsage: \"repository is volumes trusted\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"trusted-network\",\n\t\t\tUsage: \"repository is network trusted\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:   \"trusted\", // TODO: remove in next release\n\t\t\tUsage:  \"repository is trusted\",\n\t\t\tHidden: true,\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:   \"gated\", // TODO: remove in next release\n\t\t\tHidden: true,\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"require-approval\",\n\t\t\tUsage: \"repository requires approval for\",\n\t\t},\n\t\t&cli.DurationFlag{\n\t\t\tName:  \"timeout\",\n\t\t\tUsage: \"repository timeout\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"visibility\",\n\t\t\tUsage: \"repository visibility\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"config\",\n\t\t\tUsage: \"repository configuration path. Example: .woodpecker.yml\",\n\t\t},\n\t\t&cli.IntFlag{\n\t\t\tName:  \"pipeline-counter\",\n\t\t\tUsage: \"repository starting pipeline number\",\n\t\t},\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"unsafe\",\n\t\t\tUsage: \"allow unsafe operations\",\n\t\t},\n\t},\n}\n\nfunc repoUpdate(ctx context.Context, c *cli.Command) error {\n\trepoIDOrFullName := c.Args().First()\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepoID, err := internal.ParseRepo(client, repoIDOrFullName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar (\n\t\tvisibility      = c.String(\"visibility\")\n\t\tconfig          = c.String(\"config\")\n\t\ttimeout         = c.Duration(\"timeout\")\n\t\trequireApproval = c.String(\"require-approval\")\n\t\tpipelineCounter = c.Int(\"pipeline-counter\")\n\t\tunsafe          = c.Bool(\"unsafe\")\n\t)\n\n\tpatch := new(woodpecker.RepoPatch)\n\t// TODO remove in next release\n\tif c.IsSet(\"trusted\") {\n\t\ttrusted := c.Bool(\"trusted\")\n\t\tpatch.Trusted = &woodpecker.TrustedConfigurationPatch{\n\t\t\tNetwork:  &trusted,\n\t\t\tSecurity: &trusted,\n\t\t\tVolumes:  &trusted,\n\t\t}\n\t}\n\tif c.IsSet(\"trusted-security\") || c.IsSet(\"trusted-network\") || c.IsSet(\"trusted-volumes\") {\n\t\tpatch.Trusted = new(woodpecker.TrustedConfigurationPatch)\n\n\t\tif c.IsSet(\"trusted-security\") {\n\t\t\tt := c.Bool(\"trusted-security\")\n\t\t\tpatch.Trusted.Security = &t\n\t\t}\n\t\tif c.IsSet(\"trusted-network\") {\n\t\t\tt := c.Bool(\"trusted-network\")\n\t\t\tpatch.Trusted.Network = &t\n\t\t}\n\t\tif c.IsSet(\"trusted-volumes\") {\n\t\t\tt := c.Bool(\"trusted-volumes\")\n\t\t\tpatch.Trusted.Volumes = &t\n\t\t}\n\t}\n\n\t// TODO: remove in next release\n\tif c.IsSet(\"gated\") {\n\t\treturn fmt.Errorf(\"'gated' option has been set in version 2.8, use 'require-approval' in >= 3.0\")\n\t}\n\n\tif c.IsSet(\"require-approval\") {\n\t\tif mode := woodpecker.ApprovalMode(requireApproval); mode.Valid() {\n\t\t\tpatch.RequireApproval = &mode\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"update approval mode failed: '%s' is no valid mode\", mode)\n\t\t}\n\t}\n\tif c.IsSet(\"timeout\") {\n\t\tv := int64(timeout / time.Minute)\n\t\tpatch.Timeout = &v\n\t}\n\tif c.IsSet(\"config\") {\n\t\tpatch.Config = &config\n\t}\n\tif c.IsSet(\"visibility\") {\n\t\tswitch visibility {\n\t\tcase \"public\", \"private\", \"internal\":\n\t\t\tpatch.Visibility = &visibility\n\t\t}\n\t}\n\tif c.IsSet(\"pipeline-counter\") && !unsafe {\n\t\tfmt.Printf(\"Setting the pipeline counter is an unsafe operation that could put your repository in an inconsistent state. Please use --unsafe to proceed\")\n\t}\n\tif c.IsSet(\"pipeline-counter\") && unsafe {\n\t\tpatch.PipelineCounter = &pipelineCounter\n\t}\n\n\trepo, err := client.RepoPatch(repoID, patch)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfmt.Printf(\"Successfully updated repository %s\\n\", repo.FullName)\n\treturn nil\n}\n"
  },
  {
    "path": "cli/repo/secret/secret.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// Command exports the secret command.\nvar Command = &cli.Command{\n\tName:  \"secret\",\n\tUsage: \"manage secrets\",\n\tCommands: []*cli.Command{\n\t\tsecretCreateCmd,\n\t\tsecretDeleteCmd,\n\t\tsecretListCmd,\n\t\tsecretShowCmd,\n\t\tsecretUpdateCmd,\n\t},\n}\n\nfunc parseTargetArgs(client woodpecker.Client, c *cli.Command) (repoID int64, err error) {\n\trepoIDOrFullName := c.String(\"repository\")\n\tif repoIDOrFullName == \"\" {\n\t\trepoIDOrFullName = c.Args().First()\n\t}\n\n\treturn internal.ParseRepo(client, repoIDOrFullName)\n}\n"
  },
  {
    "path": "cli/repo/secret/secret_add.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretCreateCmd = &cli.Command{\n\tName:      \"add\",\n\tUsage:     \"add a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretCreate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"limit secret to these events\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"limit secret to these images\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretCreate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif len(secret.Events) == 0 {\n\t\tsecret.Events = defaultSecretEvents\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.SecretCreate(repoID, secret)\n\treturn err\n}\n\nvar defaultSecretEvents = []string{\n\twoodpecker.EventPush,\n\twoodpecker.EventTag,\n\twoodpecker.EventRelease,\n\twoodpecker.EventDeploy,\n}\n"
  },
  {
    "path": "cli/repo/secret/secret_list.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"html/template\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretListCmd = &cli.Command{\n\tName:      \"ls\",\n\tUsage:     \"list secrets\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretList,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretList(ctx context.Context, c *cli.Command) error {\n\tformat := c.String(\"format\") + \"\\n\"\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topt := woodpecker.SecretListOptions{}\n\n\tlist, err := client.SecretList(repoID, opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, secret := range list {\n\t\tif err := tmpl.Execute(os.Stdout, secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Template for secret list items.\nvar tmplSecretList = \"\\x1b[33m{{ .Name }} \\x1b[0m\" + `\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: <any>\n{{- end }}\n`\n\nvar secretFuncMap = template.FuncMap{\n\t\"list\": func(s []string) string {\n\t\treturn strings.Join(s, \", \")\n\t},\n}\n"
  },
  {
    "path": "cli/repo/secret/secret_rm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretDeleteCmd = &cli.Command{\n\tName:      \"rm\",\n\tUsage:     \"remove a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretDelete,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t},\n}\n\nfunc secretDelete(ctx context.Context, c *cli.Command) error {\n\tsecretName := c.String(\"name\")\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn client.SecretDelete(repoID, secretName)\n}\n"
  },
  {
    "path": "cli/repo/secret/secret_set.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\nvar secretUpdateCmd = &cli.Command{\n\tName:      \"update\",\n\tUsage:     \"update a secret\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretUpdate,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"value\",\n\t\t\tUsage: \"secret value\",\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"event\",\n\t\t\tUsage: \"limit secret to these events\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t\t&cli.StringSliceFlag{\n\t\t\tName:  \"image\",\n\t\t\tUsage: \"limit secret to these images\",\n\t\t\tConfig: cli.StringConfig{\n\t\t\t\tTrimSpace: true,\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc secretUpdate(ctx context.Context, c *cli.Command) error {\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret := &woodpecker.Secret{\n\t\tName:   strings.ToLower(c.String(\"name\")),\n\t\tValue:  c.String(\"value\"),\n\t\tImages: c.StringSlice(\"image\"),\n\t\tEvents: c.StringSlice(\"event\"),\n\t}\n\tif strings.HasPrefix(secret.Value, \"@\") {\n\t\tpath := strings.TrimPrefix(secret.Value, \"@\")\n\t\tout, err := os.ReadFile(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tsecret.Value = string(out)\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = client.SecretUpdate(repoID, secret)\n\treturn err\n}\n"
  },
  {
    "path": "cli/repo/secret/secret_show.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal\"\n)\n\nvar secretShowCmd = &cli.Command{\n\tName:      \"show\",\n\tUsage:     \"show secret information\",\n\tArgsUsage: \"[repo-id|repo-full-name]\",\n\tAction:    secretShow,\n\tFlags: []cli.Flag{\n\t\tcommon.RepoFlag,\n\t\t&cli.StringFlag{\n\t\t\tName:  \"name\",\n\t\t\tUsage: \"secret name\",\n\t\t},\n\t\tcommon.FormatFlag(tmplSecretList, true),\n\t},\n}\n\nfunc secretShow(ctx context.Context, c *cli.Command) error {\n\tvar (\n\t\tsecretName = c.String(\"name\")\n\t\tformat     = c.String(\"format\") + \"\\n\"\n\t)\n\n\tif secretName == \"\" {\n\t\treturn fmt.Errorf(\"secret name is missing\")\n\t}\n\n\tclient, err := internal.NewClient(ctx, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoID, err := parseTargetArgs(client, c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsecret, err := client.Secret(repoID, secretName)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttmpl, err := template.New(\"_\").Funcs(secretFuncMap).Parse(format)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn tmpl.Execute(os.Stdout, secret)\n}\n"
  },
  {
    "path": "cli/setup/setup.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/internal/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/setup/ui\"\n)\n\n// Command exports the setup command.\nvar Command = &cli.Command{\n\tName:      \"setup\",\n\tUsage:     \"setup the woodpecker-cli for the first time\",\n\tArgsUsage: \"[server]\",\n\tFlags: []cli.Flag{\n\t\t&cli.StringFlag{\n\t\t\tName:  \"server\",\n\t\t\tUsage: \"URL of the woodpecker server\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:  \"token\",\n\t\t\tUsage: \"token to authenticate with the woodpecker server\",\n\t\t},\n\t\t&cli.StringFlag{\n\t\t\tName:    \"context\",\n\t\t\tAliases: []string{\"ctx\"},\n\t\t\tUsage:   \"name for the context (defaults to 'default')\",\n\t\t},\n\t},\n\tAction: setup,\n}\n\nfunc setup(ctx context.Context, c *cli.Command) error {\n\tcontextName := c.String(\"context\")\n\tif contextName == \"\" {\n\t\tcontextName = \"default\"\n\t}\n\n\t// Check if context already exists\n\tcontexts, err := config.LoadContexts()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif existingCtx, exists := contexts.Contexts[contextName]; exists {\n\t\tsetupAgain, err := ui.Confirm(fmt.Sprintf(\"Context '%s' already exists (server: %s). Do you want to reconfigure it?\", contextName, existingCtx.ServerURL))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !setupAgain {\n\t\t\tlog.Info().Msg(\"configuration skipped\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tserverURL := c.String(\"server\")\n\tif serverURL == \"\" {\n\t\tserverURL = c.Args().First()\n\t}\n\n\tif serverURL == \"\" {\n\t\tserverURL, err = ui.Ask(\"Enter the URL of the woodpecker server\", \"https://ci.woodpecker-ci.org\", true)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif serverURL == \"\" {\n\t\t\treturn errors.New(\"server URL cannot be empty\")\n\t\t}\n\t}\n\n\tif !strings.Contains(serverURL, \"://\") {\n\t\tserverURL = \"https://\" + serverURL\n\t}\n\n\ttoken := c.String(\"token\")\n\tif token == \"\" {\n\t\ttoken, err = receiveTokenFromUI(ctx, serverURL)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif token == \"\" {\n\t\t\treturn errors.New(\"no token received from the UI\")\n\t\t}\n\t}\n\n\t// Save as context\n\terr = config.AddOrUpdateContext(c, contextName, serverURL, token, \"info\", true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info().Msgf(\"Context '%s' has been successfully created and set as current\", contextName)\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/setup/token_fetcher.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"charm.land/huh/v2/spinner\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n)\n\nfunc receiveTokenFromUI(c context.Context, serverURL string) (string, error) {\n\tport := randomPort()\n\n\ttokenReceived := make(chan string)\n\n\tsrv := &http.Server{Addr: fmt.Sprintf(\"127.0.0.1:%d\", port)}\n\tsrv.Handler = setupRouter(tokenReceived)\n\n\tgo func() {\n\t\tlog.Debug().Msgf(\"listening for token response on :%d\", port)\n\t\t_ = srv.ListenAndServe()\n\t}()\n\n\tdefer func() {\n\t\tlog.Debug().Msg(\"shutting down server\")\n\t\t_ = srv.Shutdown(c)\n\t}()\n\n\terr := openBrowser(fmt.Sprintf(\"%s/cli/auth?port=%d\", serverURL, port))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tspinnerCtx, spinnerDone := context.WithCancelCause(c)\n\tgo func() {\n\t\terr = spinner.New().\n\t\t\tTitle(\"Waiting for token ...\").\n\t\t\tContext(spinnerCtx).\n\t\t\tRun()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}()\n\n\t// wait for token to be received or timeout\n\tselect {\n\tcase token := <-tokenReceived:\n\t\tspinnerDone(nil)\n\t\treturn token, nil\n\tcase <-c.Done():\n\t\tspinnerDone(nil)\n\t\treturn \"\", c.Err()\n\tcase <-time.After(5 * time.Minute):\n\t\tspinnerDone(nil)\n\t\treturn \"\", errors.New(\"timed out waiting for token\")\n\t}\n}\n\nfunc setupRouter(tokenReceived chan string) *gin.Engine {\n\tgin.SetMode(gin.ReleaseMode)\n\te := gin.New()\n\te.UseRawPath = true\n\te.Use(gin.Recovery())\n\n\te.Use(func(c *gin.Context) {\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Credentials\", \"true\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With\")\n\t\tc.Writer.Header().Set(\"Access-Control-Allow-Methods\", \"POST, OPTIONS, GET, PUT\")\n\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(http.StatusNoContent)\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t})\n\n\te.POST(\"/token\", func(c *gin.Context) {\n\t\tdata := struct {\n\t\t\tToken string `json:\"token\"`\n\t\t}{}\n\n\t\terr := c.BindJSON(&data)\n\t\tif err != nil {\n\t\t\tlog.Debug().Err(err).Msg(\"failed to bind JSON\")\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\n\t\t\t\t\"error\": \"invalid request\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ttokenReceived <- data.Token\n\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"ok\": \"true\",\n\t\t})\n\t})\n\n\treturn e\n}\n\nfunc openBrowser(url string) error {\n\tvar err error\n\n\tlog.Debug().Msgf(\"opening browser with URL: %s\", url)\n\n\tswitch runtime.GOOS {\n\tcase \"linux\":\n\t\terr = exec.Command(\"xdg-open\", url).Start()\n\tcase \"windows\":\n\t\terr = exec.Command(\"rundll32\", \"url.dll,FileProtocolHandler\", url).Start()\n\tcase \"darwin\":\n\t\terr = exec.Command(\"open\", url).Start()\n\tdefault:\n\t\terr = fmt.Errorf(\"unsupported platform\")\n\t}\n\treturn err\n}\n\nfunc randomPort() int {\n\tconst minPort = 10000\n\tconst maxPort = 65535\n\n\tsource := rand.NewSource(time.Now().UnixNano())\n\trand := rand.New(source)\n\treturn rand.Intn(maxPort-minPort+1) + minPort\n}\n"
  },
  {
    "path": "cli/setup/ui/ask.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ui\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"charm.land/huh/v2\"\n)\n\nfunc Ask(prompt, placeholder string, required bool) (string, error) {\n\tvar input string\n\terr := huh.NewInput().\n\t\tTitle(prompt).\n\t\tValue(&input).\n\t\tPlaceholder(placeholder).Validate(func(s string) error {\n\t\tif required && strings.TrimSpace(s) == \"\" {\n\t\t\treturn errors.New(\"required\")\n\t\t}\n\t\treturn nil\n\t}).Run()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn strings.TrimSpace(input), nil\n}\n"
  },
  {
    "path": "cli/setup/ui/confirm.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ui\n\nimport (\n\t\"charm.land/huh/v2\"\n)\n\nfunc Confirm(prompt string) (bool, error) {\n\tvar confirm bool\n\terr := huh.NewConfirm().\n\t\tTitle(prompt).\n\t\tAffirmative(\"Yes!\").\n\t\tNegative(\"No.\").\n\t\tValue(&confirm).Run()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn confirm, err\n}\n"
  },
  {
    "path": "cli/update/command.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage update\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Command exports the update command.\nvar Command = &cli.Command{\n\tName:  \"update\",\n\tUsage: \"update the woodpecker-cli to the latest version\",\n\tFlags: []cli.Flag{\n\t\t&cli.BoolFlag{\n\t\t\tName:  \"force\",\n\t\t\tUsage: \"force update even if the latest version is already installed\",\n\t\t},\n\t},\n\tAction: update,\n}\n\nfunc update(ctx context.Context, c *cli.Command) error {\n\tlog.Info().Msg(\"checking for updates ...\")\n\n\tnewVersion, err := CheckForUpdate(ctx, c.Bool(\"force\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif newVersion == nil {\n\t\tfmt.Println(\"you are using the latest version of woodpecker-cli\")\n\t\treturn nil\n\t}\n\n\tlog.Info().Msgf(\"new version %s is available! Updating ...\", newVersion.Version)\n\n\tvar tarFilePath string\n\ttarFilePath, err = downloadNewVersion(ctx, newVersion.AssetURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Debug().Msgf(\"new version %s has been downloaded successfully! Installing ...\", newVersion.Version)\n\n\tbinFile, err := extractNewVersion(tarFilePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Debug().Msgf(\"new version %s has been extracted to %s\", newVersion.Version, binFile)\n\n\texecutablePathOrSymlink, err := os.Executable()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texecutablePath, err := filepath.EvalSymlinks(executablePathOrSymlink)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.Rename(binFile, executablePath); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info().Msgf(\"woodpecker-cli has been updated to version %s successfully!\", newVersion.Version)\n\n\treturn nil\n}\n"
  },
  {
    "path": "cli/update/tar.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage update\n\nimport (\n\t\"archive/tar\"\n\t\"compress/gzip\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nconst tarDirectoryMode fs.FileMode = 0x755\n\nfunc UnTar(dst string, r io.Reader) error {\n\tgzr, err := gzip.NewReader(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer gzr.Close()\n\n\ttr := tar.NewReader(gzr)\n\n\tfor {\n\t\theader, err := tr.Next()\n\n\t\tswitch {\n\t\tcase err == io.EOF:\n\t\t\treturn nil\n\n\t\tcase err != nil:\n\t\t\treturn err\n\n\t\tcase header == nil:\n\t\t\tcontinue\n\t\t}\n\n\t\ttarget := filepath.Join(dst, header.Name)\n\n\t\tswitch header.Typeflag {\n\t\tcase tar.TypeDir:\n\t\t\tif _, err := os.Stat(target); err != nil {\n\t\t\t\tif err := os.MkdirAll(target, tarDirectoryMode); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase tar.TypeReg:\n\t\t\tf, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif _, err := io.Copy(f, tr); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tf.Close()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cli/update/types.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage update\n\ntype VersionData struct {\n\tLatest string `json:\"latest\"`\n\tNext   string `json:\"next\"`\n\tRC     string `json:\"rc\"`\n}\n\ntype NewVersion struct {\n\tVersion  string\n\tAssetURL string\n}\n\nconst (\n\twoodpeckerVersionURL = \"https://woodpecker-ci.org/version.json\"\n\tgithubBinaryURL      = \"https://github.com/woodpecker-ci/woodpecker/releases/download/v%s/woodpecker-cli_%s_%s.tar.gz\"\n)\n"
  },
  {
    "path": "cli/update/updater.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage update\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc CheckForUpdate(ctx context.Context, force bool) (*NewVersion, error) {\n\treturn checkForUpdate(ctx, woodpeckerVersionURL, force)\n}\n\nfunc checkForUpdate(ctx context.Context, versionURL string, force bool) (*NewVersion, error) {\n\tlog.Debug().Msgf(\"current version: %s\", version.String())\n\n\tif (version.String() == \"dev\" || strings.HasPrefix(version.String(), \"next-\")) && !force {\n\t\tlog.Debug().Msgf(\"skipping update check for development/next versions\")\n\t\treturn nil, nil\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, errors.New(\"failed to fetch the latest release\")\n\t}\n\n\tvar versionData VersionData\n\tif err := json.NewDecoder(resp.Body).Decode(&versionData); err != nil {\n\t\treturn nil, err\n\t}\n\n\tupstreamVersion := versionData.Latest\n\tif strings.HasPrefix(version.String(), \"next-\") {\n\t\tupstreamVersion = versionData.Next\n\t} else if strings.HasSuffix(version.String(), \"rc-\") {\n\t\tupstreamVersion = versionData.RC\n\t}\n\n\tinstalledVersion := strings.TrimPrefix(version.Version, \"v\")\n\tupstreamVersion = strings.TrimPrefix(upstreamVersion, \"v\")\n\n\t// using the latest release\n\tif installedVersion == upstreamVersion && !force {\n\t\tlog.Debug().Msgf(\"no new version available\")\n\t\treturn nil, nil\n\t}\n\n\tlog.Debug().Msgf(\"new version available: %s\", upstreamVersion)\n\n\tassetURL := fmt.Sprintf(githubBinaryURL, upstreamVersion, runtime.GOOS, runtime.GOARCH)\n\treturn &NewVersion{\n\t\tVersion:  upstreamVersion,\n\t\tAssetURL: assetURL,\n\t}, nil\n}\n\nfunc downloadNewVersion(ctx context.Context, downloadURL string) (string, error) {\n\tlog.Debug().Msgf(\"downloading new version from %s ...\", downloadURL)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn \"\", errors.New(\"failed to download the new version\")\n\t}\n\n\tfile, err := os.CreateTemp(\"\", \"woodpecker-cli-*.tar.gz\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer file.Close()\n\n\tif _, err := io.Copy(file, resp.Body); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tlog.Debug().Msgf(\"new version downloaded to %s\", file.Name())\n\n\treturn file.Name(), nil\n}\n\nfunc extractNewVersion(tarFilePath string) (string, error) {\n\tlog.Debug().Msgf(\"extracting new version from %s ...\", tarFilePath)\n\n\ttarFile, err := os.Open(tarFilePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdefer tarFile.Close()\n\n\ttmpDir, err := os.MkdirTemp(\"\", \"woodpecker-cli-*\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\terr = UnTar(tmpDir, tarFile)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\terr = os.Remove(tarFilePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tlog.Debug().Msgf(\"new version extracted to %s\", tmpDir)\n\n\treturn path.Join(tmpDir, \"woodpecker-cli\"), nil\n}\n"
  },
  {
    "path": "cli/update/updater_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage update\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"testing\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc TestCheckForUpdate(t *testing.T) {\n\tversion.Version = \"1.0.0\"\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != \"/version.json\" {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t_, _ = io.WriteString(w, `{\"latest\": \"1.0.1\", \"next\": \"1.0.2\", \"rc\": \"1.0.3\"}`)\n\t}\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tnewVersion, err := checkForUpdate(t.Context(), ts.URL+\"/version.json\", false)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to check for updates: %v\", err)\n\t}\n\n\tif newVersion == nil || newVersion.Version != \"1.0.1\" {\n\t\tt.Fatalf(\"Expected a new version 1.0.1, got: %s\", newVersion)\n\t}\n}\n\nfunc TestDownloadNewVersion(t *testing.T) {\n\tdownloadFilePath := \"/woodpecker-cli_linux_amd64.tar.gz\"\n\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path != downloadFilePath {\n\t\t\thttp.NotFound(w, r)\n\t\t\treturn\n\t\t}\n\n\t\t_, _ = io.WriteString(w, `blob`)\n\t}\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tfile, err := downloadNewVersion(t.Context(), ts.URL+downloadFilePath)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to download new version: %v\", err)\n\t}\n\n\tif file == \"\" {\n\t\tt.Fatalf(\"Expected a file path, got: %s\", file)\n\t}\n\n\t_ = os.Remove(file)\n}\n"
  },
  {
    "path": "cmd/agent/core/agent.go",
    "content": "// Copyright 2023 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"golang.org/x/sync/errgroup\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/codes\"\n\tgrpc_credentials \"google.golang.org/grpc/credentials\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n\t\"google.golang.org/grpc/keepalive\"\n\t\"google.golang.org/grpc/metadata\"\n\t\"google.golang.org/grpc/status\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent\"\n\tagent_rpc \"go.woodpecker-ci.org/woodpecker/v3/agent/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nconst (\n\treportHealthInterval           = time.Second * 10\n\tauthInterceptorRefreshInterval = time.Minute * 30\n)\n\nfunc run(ctx context.Context, c *cli.Command, backends []types.Backend) error {\n\tlog.Info().Str(\"version\", version.String()).Msg(\"Starting Woodpecker agent\")\n\n\tagentCtx, ctxCancel := context.WithCancelCause(ctx)\n\tdefer func() {\n\t\tlog.Info().Msg(\"shutdown of whole agent\")\n\t\tctxCancel(nil)\n\t}()\n\n\tserviceWaitingGroup := errgroup.Group{}\n\n\tagentConfigPath := c.String(\"agent-config\")\n\thostname := c.String(\"hostname\")\n\tif len(hostname) == 0 {\n\t\thostname, _ = os.Hostname()\n\t}\n\n\tmaxWorkflows := c.Int(\"max-workflows\")\n\tsingleWorkflow := c.Bool(\"single-workflow\")\n\tif singleWorkflow && maxWorkflows > 1 {\n\t\tlog.Warn().Msgf(\"max-workflows forced from %d to 1 due to agent running single workflow mode.\", maxWorkflows)\n\t\tmaxWorkflows = 1\n\t}\n\n\tcounter.Polling = maxWorkflows\n\tcounter.Running = 0\n\n\tif c.Bool(\"healthcheck\") {\n\t\tserviceWaitingGroup.Go(\n\t\t\tfunc() error {\n\t\t\t\tserver := &http.Server{Addr: c.String(\"healthcheck-addr\")}\n\t\t\t\tgo func() {\n\t\t\t\t\t<-agentCtx.Done()\n\t\t\t\t\tlog.Info().Msg(\"shutdown healthcheck server ...\")\n\n\t\t\t\t\tshutdownCtx, shutdownCtxCancel := agent.GetShutdownContext()\n\t\t\t\t\tdefer shutdownCtxCancel()\n\n\t\t\t\t\tif err := server.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck\n\t\t\t\t\t\tlog.Error().Err(err).Msg(\"shutdown healthcheck server failed\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Info().Msg(\"healthcheck server stopped\")\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t\tif err := server.ListenAndServe(); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"cannot listen on address %s\", c.String(\"healthcheck-addr\"))\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t})\n\t}\n\n\tvar transport grpc.DialOption\n\tif c.Bool(\"grpc-secure\") {\n\t\tlog.Trace().Msg(\"use ssl for grpc\")\n\t\ttransport = grpc.WithTransportCredentials(grpc_credentials.NewTLS(&tls.Config{InsecureSkipVerify: c.Bool(\"grpc-skip-insecure\")}))\n\t} else {\n\t\ttransport = grpc.WithTransportCredentials(insecure.NewCredentials())\n\t}\n\n\tauthConn, err := grpc.NewClient(\n\t\tc.String(\"server\"),\n\t\ttransport,\n\t\tgrpc.WithKeepaliveParams(keepalive.ClientParameters{\n\t\t\tTime:    c.Duration(\"grpc-keepalive-time\"),\n\t\t\tTimeout: c.Duration(\"grpc-keepalive-timeout\"),\n\t\t}),\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not create new gRPC 'channel' for authentication: %w\", err)\n\t}\n\tdefer authConn.Close()\n\n\tagentConfig := readAgentConfig(agentConfigPath)\n\n\tagentToken := c.String(\"grpc-token\")\n\tgrpcClientCtx, grpcClientCtxCancel := context.WithCancelCause(context.Background())\n\tdefer grpcClientCtxCancel(nil)\n\tauthClient := agent_rpc.NewAuthGrpcClient(authConn, agentToken, agentConfig.AgentID)\n\tauthInterceptor, err := agent_rpc.NewAuthInterceptor(grpcClientCtx, authClient, authInterceptorRefreshInterval) //nolint:contextcheck\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent could not auth: %w\", err)\n\t}\n\n\t// Persist the agent ID received during auth so that crashloops reuse the\n\t// same server-side entry instead of creating a new one on every restart.\n\tif agentConfigPath != \"\" {\n\t\tagentConfig.AgentID = authClient.AgentID()\n\t\tif err := writeAgentConfig(agentConfig, agentConfigPath); err == nil {\n\t\t\tlog.Debug().Msgf(\"persisted agent ID %d after auth\", agentConfig.AgentID)\n\t\t}\n\t}\n\n\tconn, err := grpc.NewClient(\n\t\tc.String(\"server\"),\n\t\ttransport,\n\t\tgrpc.WithKeepaliveParams(keepalive.ClientParameters{\n\t\t\tTime:    c.Duration(\"grpc-keepalive-time\"),\n\t\t\tTimeout: c.Duration(\"grpc-keepalive-timeout\"),\n\t\t}),\n\t\tgrpc.WithUnaryInterceptor(authInterceptor.Unary()),\n\t\tgrpc.WithStreamInterceptor(authInterceptor.Stream()),\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not create new gRPC 'channel' for normal orchestration: %w\", err)\n\t}\n\tdefer conn.Close()\n\n\tclient := agent_rpc.NewGrpcClient(ctx, conn,\n\t\tagent_rpc.SetConnectionRetryTimeout(c.Duration(\"retry-timeout\")),\n\t)\n\tagentConfigPersisted := atomic.Bool{}\n\n\tgrpcCtx := metadata.NewOutgoingContext(grpcClientCtx, metadata.Pairs(\"hostname\", hostname))\n\n\t// check if grpc server version is compatible with agent\n\tgrpcServerVersion, err := client.Version(grpcCtx) //nolint:contextcheck\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not get grpc server version\")\n\t\treturn err\n\t}\n\tif grpcServerVersion.GrpcVersion != agent_rpc.ClientGrpcVersion {\n\t\terr := errors.New(\"GRPC version mismatch\")\n\t\tlog.Error().Err(err).Msgf(\"server version %s does report grpc version %d but we only understand %d\",\n\t\t\tgrpcServerVersion.ServerVersion,\n\t\t\tgrpcServerVersion.GrpcVersion,\n\t\t\tagent_rpc.ClientGrpcVersion)\n\t\treturn err\n\t}\n\n\t// new engine\n\tbackendCtx := context.WithValue(agentCtx, types.CliCommand, c)\n\tbackendName := c.String(\"backend-engine\")\n\tbackendEngine, err := backend.FindBackend(backendCtx, backends, backendName)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find backend engine '%s'\", backendName)\n\t\treturn err\n\t}\n\tif !backendEngine.IsAvailable(backendCtx) {\n\t\tlog.Error().Str(\"engine\", backendEngine.Name()).Msg(\"selected backend engine is unavailable\")\n\t\treturn fmt.Errorf(\"selected backend engine %s is unavailable\", backendEngine.Name())\n\t}\n\n\t// load engine (e.g. init api client)\n\tengInfo, err := backendEngine.Load(backendCtx)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"cannot load backend engine\")\n\t\treturn err\n\t}\n\tlog.Debug().Msgf(\"loaded %s backend engine\", backendEngine.Name())\n\n\tcustomLabels := make(map[string]string)\n\tif err := stringSliceAddToMap(c.StringSlice(\"labels\"), customLabels); err != nil {\n\t\treturn err\n\t}\n\tif len(customLabels) != 0 {\n\t\tlog.Debug().Msgf(\"custom labels detected: %#v\", customLabels)\n\t}\n\n\tagentConfig.AgentID, err = client.RegisterAgent(grpcCtx, rpc.AgentInfo{ //nolint:contextcheck\n\t\tVersion:      version.String(),\n\t\tBackend:      backendEngine.Name(),\n\t\tPlatform:     engInfo.Platform,\n\t\tCapacity:     maxWorkflows,\n\t\tCustomLabels: customLabels,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tserviceWaitingGroup.Go(func() error {\n\t\t// we close grpc client context once unregister was handled\n\t\tdefer grpcClientCtxCancel(nil)\n\t\t// we wait till agent context is done\n\t\t<-agentCtx.Done()\n\t\t// Remove stateless agents from server\n\t\tif !agentConfigPersisted.Load() {\n\t\t\tif client.IsConnected() {\n\t\t\t\tlog.Debug().Msg(\"unregister agent from server ...\")\n\t\t\t\terr := client.UnregisterAgent(grpcClientCtx)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Err(err).Msg(\"failed to unregister agent from server\")\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info().Msg(\"agent unregistered from server\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Debug().Msg(\"skipping unregister: server not connected\")\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif agentConfigPath != \"\" {\n\t\tif err := writeAgentConfig(agentConfig, agentConfigPath); err == nil {\n\t\t\tagentConfigPersisted.Store(true)\n\t\t}\n\t}\n\n\t// set default labels ...\n\tlabels := make(map[string]string)\n\tlabels[pipeline.LabelFilterHostname] = hostname\n\tlabels[pipeline.LabelFilterPlatform] = engInfo.Platform\n\tlabels[pipeline.LabelFilterBackend] = backendEngine.Name()\n\tlabels[pipeline.LabelFilterRepo] = \"*\" // allow all repos by default\n\t// ... and let it overwrite by custom ones\n\tmaps.Copy(labels, customLabels)\n\n\tlog.Debug().Any(\"labels\", labels).Msgf(\"agent configured with labels\")\n\n\tfilter := rpc.Filter{\n\t\tLabels: labels,\n\t}\n\n\tlog.Debug().Msgf(\"agent registered with ID %d\", agentConfig.AgentID)\n\n\tserviceWaitingGroup.Go(func() error {\n\t\tfor {\n\t\t\terr := client.ReportHealth(grpcCtx)\n\t\t\tif err != nil {\n\t\t\t\tlog.Err(err).Msg(\"failed to report health\")\n\t\t\t\t// Check if the error is due to context cancellation\n\t\t\t\tif grpcCtx.Err() != nil || agentCtx.Err() != nil {\n\t\t\t\t\tlog.Debug().Msg(\"terminating health reporting due to context cancellation\")\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tselect {\n\t\t\tcase <-agentCtx.Done():\n\t\t\t\tlog.Debug().Msg(\"terminating health reporting\")\n\t\t\t\treturn nil\n\t\t\tcase <-time.After(reportHealthInterval):\n\t\t\t}\n\t\t}\n\t})\n\n\t// https://go.dev/blog/go1.22 fixed scope for goroutines in loops\n\tfor i := range maxWorkflows {\n\t\tserviceWaitingGroup.Go(func() error {\n\t\t\trunner := agent.NewRunner(client, filter, hostname, counter, backendEngine)\n\t\t\tlog.Debug().Msgf(\"created new runner %d\", i)\n\n\t\t\tfor {\n\t\t\t\tif agentCtx.Err() != nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\n\t\t\t\tlog.Debug().Msg(\"polling new workflow\")\n\t\t\t\tif err := runner.Run(agentCtx); err != nil {\n\t\t\t\t\tif errors.Is(err, agent_rpc.ErrConnectionLost) {\n\t\t\t\t\t\tlog.Error().Err(err).Msg(\"connection to server lost, shutting down agent\")\n\t\t\t\t\t\tctxCancel(err)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\tif singleWorkflow {\n\t\t\t\t\t\tlog.Error().Err(err).Msg(\"runner done with error\")\n\t\t\t\t\t\tctxCancel(nil)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\tlog.Error().Err(err).Msg(\"runner error, retrying...\")\n\t\t\t\t\t// Check if context is canceled\n\t\t\t\t\tif agentCtx.Err() != nil {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\t// Wait a bit before retrying to avoid hammering the server\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-agentCtx.Done():\n\t\t\t\t\t\treturn nil\n\t\t\t\t\tcase <-time.After(time.Second * 5):\n\t\t\t\t\t\t// Continue to next iteration\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif singleWorkflow {\n\t\t\t\t\tlog.Info().Msg(\"shutdown single workflow runner\")\n\t\t\t\t\tctxCancel(nil)\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\tlog.Info().\n\t\tStr(\"version\", version.String()).\n\t\tStr(\"backend\", backendEngine.Name()).\n\t\tStr(\"platform\", engInfo.Platform).\n\t\tInt(\"parallel workflows\", maxWorkflows).\n\t\tBool(\"single workflow\", singleWorkflow).\n\t\tMsg(\"starting Woodpecker agent\")\n\n\treturn serviceWaitingGroup.Wait()\n}\n\nfunc runWithRetry(backendEngines []types.Backend) func(ctx context.Context, c *cli.Command) error {\n\treturn func(ctx context.Context, c *cli.Command) error {\n\t\tif err := logger.SetupGlobalLogger(ctx, c, true); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tinitHealth()\n\n\t\tretryCount := c.Int(\"connect-retry-count\")\n\t\tretryDelay := c.Duration(\"connect-retry-delay\")\n\t\tvar err error\n\t\tfor range retryCount {\n\t\t\tif err = run(ctx, c, backendEngines); status.Code(err) == codes.Unavailable {\n\t\t\t\tlog.Warn().Err(err).Msg(fmt.Sprintf(\"cannot connect to %s, retrying in %v\", c.String(\"server\"), retryDelay))\n\t\t\t\ttime.Sleep(retryDelay)\n\t\t\t} else {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n}\n\nfunc stringSliceAddToMap(sl []string, m map[string]string) error {\n\tif m == nil {\n\t\tm = make(map[string]string)\n\t}\n\tfor _, v := range utils.StringSliceDeleteEmpty(sl) {\n\t\tbefore, after, _ := strings.Cut(v, \"=\")\n\t\tswitch {\n\t\tcase before != \"\" && after != \"\":\n\t\t\tm[before] = after\n\t\tcase before != \"\":\n\t\t\treturn fmt.Errorf(\"key '%s' does not have a value assigned\", before)\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"empty string in slice\")\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/agent/core/agent_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestStringSliceAddToMap(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tsl       []string\n\t\tm        map[string]string\n\t\texpected map[string]string\n\t\terr      bool\n\t}{\n\t\t{\n\t\t\tname: \"add values to map\",\n\t\t\tsl:   []string{\"foo=bar\", \"baz=qux=nux\"},\n\t\t\tm:    make(map[string]string),\n\t\t\texpected: map[string]string{\n\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\"baz\": \"qux=nux\",\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty slice\",\n\t\t\tsl:       []string{},\n\t\t\tm:        make(map[string]string),\n\t\t\texpected: map[string]string{},\n\t\t\terr:      false,\n\t\t},\n\t\t{\n\t\t\tname:     \"missing value\",\n\t\t\tsl:       []string{\"foo\", \"baz=qux\"},\n\t\t\tm:        make(map[string]string),\n\t\t\texpected: map[string]string{},\n\t\t\terr:      true,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string in slice\",\n\t\t\tsl:       []string{\"foo=bar\", \"\", \"baz=qux\"},\n\t\t\tm:        make(map[string]string),\n\t\t\texpected: map[string]string{\"foo\": \"bar\", \"baz\": \"qux\"},\n\t\t\terr:      false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := stringSliceAddToMap(tt.sl, tt.m)\n\n\t\t\tif tt.err {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.EqualValues(t, tt.expected, tt.m)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "cmd/agent/core/config.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2019 Laszlo Fogas\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n)\n\ntype AgentConfig struct {\n\tAgentID int64 `json:\"agent_id\"`\n}\n\nconst defaultAgentIDValue = int64(-1)\n\nfunc readAgentConfig(agentConfigPath string) AgentConfig {\n\tconf := AgentConfig{\n\t\tAgentID: defaultAgentIDValue,\n\t}\n\n\tif agentConfigPath == \"\" {\n\t\treturn conf\n\t}\n\n\trawAgentConf, err := os.ReadFile(agentConfigPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tlog.Info().Msgf(\"no agent config found at '%s', start with defaults\", agentConfigPath)\n\t\t} else {\n\t\t\tlog.Error().Err(err).Msgf(\"could not open agent config at '%s'\", agentConfigPath)\n\t\t}\n\t\treturn conf\n\t}\n\tif strings.TrimSpace(string(rawAgentConf)) == \"\" {\n\t\treturn conf\n\t}\n\n\tif err := json.Unmarshal(rawAgentConf, &conf); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not parse agent config\")\n\t}\n\treturn conf\n}\n\nfunc writeAgentConfig(conf AgentConfig, agentConfigPath string) error {\n\trawAgentConf, err := json.Marshal(conf)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not marshal agent config\")\n\t\treturn err\n\t}\n\n\t// get old config\n\toldRawAgentConf, _ := os.ReadFile(agentConfigPath)\n\n\t// if config differ write to disk\n\tif !bytes.Equal(rawAgentConf, oldRawAgentConf) {\n\t\tif err := os.WriteFile(agentConfigPath, rawAgentConf, 0o644); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"could not persist agent config at '%s'\", agentConfigPath)\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/agent/core/config_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestReadAgentIDFileNotExists(t *testing.T) {\n\tassert.EqualValues(t, -1, readAgentConfig(\"foobar.conf\").AgentID)\n}\n\nfunc TestReadAgentIDFileExists(t *testing.T) {\n\ttmpF, errTmpF := os.CreateTemp(t.TempDir(), \"tmp_\")\n\trequire.NoError(t, errTmpF)\n\tdefer os.Remove(tmpF.Name())\n\n\t// there is an existing config\n\terrWrite := os.WriteFile(tmpF.Name(), []byte(`{\"agent_id\":3}`), 0o644)\n\trequire.NoError(t, errWrite)\n\n\t// read existing config\n\tactual := readAgentConfig(tmpF.Name())\n\tassert.EqualValues(t, AgentConfig{3}, actual)\n\n\t// update existing config and check\n\tactual.AgentID = 33\n\t_ = writeAgentConfig(actual, tmpF.Name())\n\tactual = readAgentConfig(tmpF.Name())\n\tassert.EqualValues(t, 33, actual.AgentID)\n\n\ttmpF2, errTmpF := os.CreateTemp(t.TempDir(), \"tmp_\")\n\trequire.NoError(t, errTmpF)\n\tdefer os.Remove(tmpF2.Name())\n\n\t// write new config\n\t_ = writeAgentConfig(actual, tmpF2.Name())\n\tactual = readAgentConfig(tmpF2.Name())\n\tassert.EqualValues(t, 33, actual.AgentID)\n}\n"
  },
  {
    "path": "cmd/agent/core/flags.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2019 Laszlo Fogas\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/urfave/cli/v3\"\n)\n\n//nolint:mnd\nvar flags = []cli.Flag{\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER\"),\n\t\tName:    \"server\",\n\t\tUsage:   \"server address\",\n\t\tValue:   \"localhost:9000\",\n\t},\n\t&cli.StringFlag{\n\t\tName:  \"grpc-token\",\n\t\tUsage: \"server-agent shared token\",\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_AGENT_SECRET_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_AGENT_SECRET\")),\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GRPC_SECURE\"),\n\t\tName:    \"grpc-secure\",\n\t\tUsage:   \"should the connection to WOODPECKER_SERVER be made using a secure transport\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GRPC_VERIFY\"),\n\t\tName:    \"grpc-skip-insecure\",\n\t\tUsage:   \"should the grpc server certificate be verified, only valid when WOODPECKER_GRPC_SECURE is true\",\n\t\tValue:   true,\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_RETRY_TIMEOUT\"),\n\t\tName:    \"retry-timeout\",\n\t\tUsage:   \"how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up, set to 0 to retry forever\",\n\t\tValue:   2 * time.Minute,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_HOSTNAME\"),\n\t\tName:    \"hostname\",\n\t\tUsage:   \"agent hostname\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_AGENT_CONFIG_FILE\"),\n\t\tName:    \"agent-config\",\n\t\tUsage:   \"agent config file path, if set empty the agent will be stateless and unregister on termination\",\n\t\tValue:   \"/etc/woodpecker/agent.conf\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_AGENT_LABELS\", \"WOODPECKER_FILTER_LABELS\"), // remove WOODPECKER_FILTER_LABELS in v4.x\n\t\tName:    \"labels\",\n\t\tAliases: []string{\"filter\"}, // remove in v4.x\n\t\tUsage:   \"List of labels to filter tasks on. An agent must be assigned every tag listed in a task to be selected.\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.IntFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_MAX_WORKFLOWS\", \"WOODPECKER_MAX_PROCS\"), // cspell:words PROCS\n\t\tName:    \"max-workflows\",\n\t\tUsage:   \"agent parallel workflows\",\n\t\tValue:   1,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_AGENT_SINGLE_WORKFLOW\"),\n\t\tName:    \"single-workflow\",\n\t\tUsage:   \"exit the agent after first workflow\",\n\t\tValue:   false,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_HEALTHCHECK\"),\n\t\tName:    \"healthcheck\",\n\t\tUsage:   \"enable healthcheck endpoint\",\n\t\tValue:   true,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_HEALTHCHECK_ADDR\"),\n\t\tName:    \"healthcheck-addr\",\n\t\tUsage:   \"healthcheck endpoint address\",\n\t\tValue:   \":3000\",\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_KEEPALIVE_TIME\"),\n\t\tName:    \"keepalive-time\",\n\t\tUsage:   \"after a duration of this time of no activity, the agent pings the server to check if the transport is still alive\",\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_KEEPALIVE_TIMEOUT\"),\n\t\tName:    \"keepalive-timeout\",\n\t\tUsage:   \"after pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity\",\n\t\tValue:   time.Second * 20,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND\"),\n\t\tName:    \"backend-engine\",\n\t\tUsage:   \"backend to run pipelines on\",\n\t\tValue:   \"auto-detect\",\n\t},\n\t&cli.IntFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONNECT_RETRY_COUNT\"),\n\t\tName:    \"connect-retry-count\",\n\t\tUsage:   \"number of times to retry connecting to the server\",\n\t\tValue:   5,\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONNECT_RETRY_DELAY\"),\n\t\tName:    \"connect-retry-delay\",\n\t\tUsage:   \"duration to wait before retrying to connect to the server\",\n\t\tValue:   time.Second * 2,\n\t},\n}\n"
  },
  {
    "path": "cmd/agent/core/health.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// The file implements some basic healthcheck logic based on the\n// following specification:\n//   https://github.com/mozilla-services/Dockerflow\n\nfunc initHealth() {\n\thttp.HandleFunc(\"/varz\", handleStats)\n\thttp.HandleFunc(\"/healthz\", handleHeartbeat)\n\thttp.HandleFunc(\"/version\", handleVersion)\n}\n\nfunc handleHeartbeat(w http.ResponseWriter, _ *http.Request) {\n\tif counter.Healthy() {\n\t\tw.WriteHeader(http.StatusOK)\n\t} else {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}\n}\n\nfunc handleVersion(w http.ResponseWriter, _ *http.Request) {\n\tw.WriteHeader(http.StatusOK)\n\tw.Header().Add(\"Content-Type\", \"text/json\")\n\terr := json.NewEncoder(w).Encode(versionResp{\n\t\tSource:  \"https://github.com/woodpecker-ci/woodpecker\",\n\t\tVersion: version.String(),\n\t})\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"handleVersion\")\n\t}\n}\n\nfunc handleStats(w http.ResponseWriter, _ *http.Request) {\n\tif counter.Healthy() {\n\t\tw.WriteHeader(http.StatusOK)\n\t} else {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}\n\tw.Header().Add(\"Content-Type\", \"text/json\")\n\tif _, err := counter.WriteTo(w); err != nil {\n\t\tlog.Error().Err(err).Msg(\"handleStats\")\n\t}\n}\n\ntype versionResp struct {\n\tVersion string `json:\"version\"`\n\tSource  string `json:\"source\"`\n}\n\n// Default statistics counter.\nvar counter = &agent.State{\n\tMetadata: map[string]agent.Info{},\n}\n\n// handles pinging the endpoint and returns an error if the\n// agent is in an unhealthy state.\nfunc pinger(ctx context.Context, c *cli.Command) error {\n\thealthcheckAddress := c.String(\"healthcheck-addr\")\n\tif strings.HasPrefix(healthcheckAddress, \":\") {\n\t\t// this seems sufficient according to https://pkg.go.dev/net#Dial\n\t\thealthcheckAddress = \"localhost\" + healthcheckAddress\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, \"http://\"+healthcheckAddress+\"/healthz\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"agent returned non-http.StatusOK status code\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/agent/core/health_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent\"\n)\n\nfunc TestHealthy(t *testing.T) {\n\ts := agent.State{}\n\ts.Metadata = map[string]agent.Info{}\n\n\ts.Add(\"1\", time.Hour, \"octocat/hello-world\", \"42\")\n\n\tassert.Equal(t, \"1\", s.Metadata[\"1\"].ID)\n\tassert.Equal(t, time.Hour, s.Metadata[\"1\"].Timeout)\n\tassert.Equal(t, \"octocat/hello-world\", s.Metadata[\"1\"].Repo)\n\n\ts.Metadata[\"1\"] = agent.Info{\n\t\tTimeout: time.Hour,\n\t\tStarted: time.Now().UTC(),\n\t}\n\tassert.True(t, s.Healthy(), \"want healthy status when timeout not exceeded, got false\")\n\n\ts.Metadata[\"1\"] = agent.Info{\n\t\tStarted: time.Now().UTC().Add(-(time.Minute * 30)),\n\t}\n\tassert.True(t, s.Healthy(), \"want healthy status when timeout+buffer not exceeded, got false\")\n\n\ts.Metadata[\"1\"] = agent.Info{\n\t\tStarted: time.Now().UTC().Add(-(time.Hour + time.Minute)),\n\t}\n\tassert.False(t, s.Healthy(), \"want unhealthy status when timeout+buffer not exceeded, got true\")\n}\n"
  },
  {
    "path": "cmd/agent/core/run.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage core\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"slices\"\n\n\t// Load config from .env file.\n\t_ \"github.com/joho/godotenv/autoload\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc GenApp(backends []backend_types.Backend) *cli.Command {\n\tapp := &cli.Command{}\n\tapp.Name = \"woodpecker-agent\"\n\tapp.Version = version.String()\n\tapp.Usage = \"woodpecker agent\"\n\tapp.Action = runWithRetry(backends)\n\tapp.Commands = []*cli.Command{\n\t\t{\n\t\t\tName:   \"ping\",\n\t\t\tUsage:  \"ping the agent\",\n\t\t\tAction: pinger,\n\t\t},\n\t}\n\tagentFlags := slices.Concat(flags, logger.GlobalLoggerFlags)\n\tfor _, b := range backends {\n\t\tagentFlags = slices.Concat(agentFlags, b.Flags())\n\t}\n\tapp.Flags = agentFlags\n\treturn app\n}\n\nfunc RunAgent(ctx context.Context, backends []backend_types.Backend) {\n\tapp := GenApp(backends)\n\n\tif err := app.Run(ctx, os.Args); err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"error running agent\") //nolint:forbidigo\n\t}\n}\n"
  },
  {
    "path": "cmd/agent/dummy.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage main\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\nfunc init() { //nolint:gochecknoinits\n\tbackends = append(backends, dummy.New())\n}\n"
  },
  {
    "path": "cmd/agent/main.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nvar backends = []backend_types.Backend{\n\tkubernetes.New(),\n\tdocker.New(),\n\tlocal.New(),\n}\n\nfunc main() {\n\tctx := utils.WithContextSigtermCallback(context.Background(), func() {\n\t\tlog.Info().Msg(\"termination signal is received, shutting down agent\")\n\t})\n\tcore.RunAgent(ctx, backends)\n}\n"
  },
  {
    "path": "cmd/agent/man.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build man\n\npackage main\n\nimport (\n\t\"fmt\"\n\n\tdocs \"github.com/urfave/cli-docs/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nvar backends = []backend_types.Backend{\n\tkubernetes.New(),\n\tdocker.New(),\n\tlocal.New(),\n}\n\nfunc main() {\n\tapp := core.GenApp(backends)\n\tmd, err := docs.ToMan(app)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfmt.Print(md)\n}\n"
  },
  {
    "path": "cmd/cli/app.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/admin\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/context\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/exec\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/info\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/lint\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/org\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/repo\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/setup\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/cli/update\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n//go:generate go run docs.go app.go\nfunc newApp() *cli.Command {\n\tapp := &cli.Command{}\n\tapp.Name = \"woodpecker-cli\"\n\tapp.Description = \"Woodpecker command line utility\"\n\tapp.Version = version.String()\n\tapp.Usage = \"command line utility\"\n\tapp.Flags = common.GlobalFlags\n\tapp.Before = common.Before\n\tapp.After = common.After\n\tapp.Suggest = true\n\tapp.ConfigureShellCompletionCommand = func(c *cli.Command) {\n\t\tc.Hidden = false\n\t\tc.Usage = \"generate completion script for the specified shell\"\n\t}\n\tapp.Commands = []*cli.Command{\n\t\tadmin.Command,\n\t\tcontext.Command,\n\t\texec.Command,\n\t\tinfo.Command,\n\t\tlint.Command,\n\t\torg.Command,\n\t\tpipeline.Command,\n\t\trepo.Command,\n\t\tsetup.Command,\n\t\tupdate.Command,\n\t}\n\n\treturn app\n}\n"
  },
  {
    "path": "cmd/cli/docs.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build generate\n\npackage main\n\nimport (\n\t\"os\"\n\n\tdocs \"github.com/urfave/cli-docs/v3\"\n)\n\nfunc main() {\n\tapp := newApp()\n\tmd, err := docs.ToMarkdown(app)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tfi, err := os.Create(\"../../docs/docs/40-cli.md\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer fi.Close()\n\tif _, err := fi.WriteString(\"# CLI\\n\\n\" + md); err != nil {\n\t\tpanic(err)\n\t}\n}\n"
  },
  {
    "path": "cmd/cli/main.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nfunc main() {\n\tctx := utils.WithContextSigtermCallback(context.Background(), func() {\n\t\tlog.Info().Msg(\"termination signal is received, terminate cli\")\n\t})\n\n\tapp := newApp()\n\tif err := app.Run(ctx, os.Args); err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"error running cli\") //nolint:forbidigo\n\t}\n}\n"
  },
  {
    "path": "cmd/cli/man.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build man\n\npackage main\n\nimport (\n\t\"fmt\"\n\n\tdocs \"github.com/urfave/cli-docs/v3\"\n)\n\nfunc main() {\n\tapp := newApp()\n\tmd, err := docs.ToMan(app)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfmt.Print(md)\n}\n"
  },
  {
    "path": "cmd/server/app.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc genApp() *cli.Command {\n\tapp := &cli.Command{}\n\tapp.Name = \"woodpecker-server\"\n\tapp.Version = version.String()\n\tapp.Usage = \"woodpecker server\"\n\tapp.Action = run\n\tapp.Commands = []*cli.Command{\n\t\t{\n\t\t\tName:   \"ping\",\n\t\t\tUsage:  \"ping the server\",\n\t\t\tAction: pinger,\n\t\t},\n\t}\n\tapp.Flags = flags\n\n\treturn app\n}\n"
  },
  {
    "path": "cmd/server/flags.go",
    "content": "// Copyright 2023 Woodpecker Authors\n// Copyright 2019 Laszlo Fogas\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n)\n\nvar flags = append([]cli.Flag{\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_LOG\", \"WOODPECKER_LOG_XORM\"),\n\t\tName:    \"db-log\",\n\t\tAliases: []string{\"log-xorm\"}, // TODO: remove in v4.0.0\n\t\tUsage:   \"enable logging in database engine (currently xorm)\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_LOG_SQL\", \"WOODPECKER_LOG_XORM_SQL\"),\n\t\tName:    \"db-log-sql\",\n\t\tAliases: []string{\"log-xorm-sql\"}, // TODO: remove in v4.0.0\n\t\tUsage:   \"enable logging of sql commands\",\n\t},\n\t&cli.IntFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_MAX_CONNECTIONS\"),\n\t\tName:    \"db-max-open-connections\",\n\t\tUsage:   \"max connections xorm is allowed create\",\n\t\tValue:   100,\n\t},\n\t&cli.IntFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_IDLE_CONNECTIONS\"),\n\t\tName:    \"db-max-idle-connections\",\n\t\tUsage:   \"amount of connections xorm will hold open\",\n\t\tValue:   2,\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_CONNECTION_TIMEOUT\"),\n\t\tName:    \"db-max-connection-timeout\",\n\t\tUsage:   \"time an active connection is allowed to stay open\",\n\t\tValue:   3 * time.Second,\n\t},\n\t&cli.UintFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_MAX_RETRIES\"),\n\t\tName:    \"db-max-retries\",\n\t\tUsage:   \"max number of retries for the initial connection to the database\",\n\t\tValue:   10,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_HOST\"),\n\t\tName:    \"server-host\",\n\t\tUsage:   \"server fully qualified url. Format: <scheme>://<host>[/<prefix path>]\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER_ADDR\"),\n\t\tName:    \"server-addr\",\n\t\tUsage:   \"server address\",\n\t\tValue:   \":8000\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER_ADDR_TLS\"),\n\t\tName:    \"server-addr-tls\",\n\t\tUsage:   \"port https with tls (:443)\",\n\t\tValue:   \":443\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER_CERT\"),\n\t\tName:    \"server-cert\",\n\t\tUsage:   \"server ssl cert path\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SERVER_KEY\"),\n\t\tName:    \"server-key\",\n\t\tUsage:   \"server ssl key path\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CUSTOM_CSS_FILE\"),\n\t\tName:    \"custom-css-file\",\n\t\tUsage:   \"file path for the server to serve a custom .CSS file, used for customizing the UI\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CUSTOM_JS_FILE\"),\n\t\tName:    \"custom-js-file\",\n\t\tUsage:   \"file path for the server to serve a custom .JS file, used for customizing the UI\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GRPC_ADDR\"),\n\t\tName:    \"grpc-addr\",\n\t\tUsage:   \"grpc address\",\n\t\tValue:   \":9000\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_GRPC_SECRET_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_GRPC_SECRET\")),\n\t\tName:  \"grpc-secret\",\n\t\tUsage: \"grpc jwt secret\",\n\t\tValue: \"secret\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_METRICS_SERVER_ADDR\"),\n\t\tName:    \"metrics-server-addr\",\n\t\tUsage:   \"metrics server address\",\n\t\tValue:   \"\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ADMIN\"),\n\t\tName:    \"admin\",\n\t\tUsage:   \"list of admin users\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ORGS\"),\n\t\tName:    \"orgs\",\n\t\tUsage:   \"list of approved organizations\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_REPO_OWNERS\"),\n\t\tName:    \"repo-owners\",\n\t\tUsage:   \"Repositories by those owners will be allowed to be used in woodpecker\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_OPEN\"),\n\t\tName:    \"open\",\n\t\tUsage:   \"enable open user registration\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_AUTHENTICATE_PUBLIC_REPOS\"),\n\t\tName:    \"authenticate-public-repos\",\n\t\tUsage:   \"Always use authentication to clone repositories even if they are public. Needed if the SCM requires to always authenticate as used by many companies.\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS\"),\n\t\tName:    \"default-allow-pull-requests\",\n\t\tUsage:   \"The default value for allowing pull requests on a repo.\",\n\t\tValue:   true,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_APPROVAL_MODE\"),\n\t\tName:    \"default-approval-mode\",\n\t\tUsage:   \"The default value for allowing pull requests on a repo.\",\n\t\tValue:   \"forks\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS\"),\n\t\tName:    \"default-cancel-previous-pipeline-events\",\n\t\tUsage:   \"List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\",\n\t\tValue:   []string{\"push\", \"pull_request\"},\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_CLONE_PLUGIN\", \"WOODPECKER_DEFAULT_CLONE_IMAGE\"),\n\t\tName:    \"default-clone-plugin\",\n\t\tAliases: []string{\"default-clone-image\"},\n\t\tUsage:   \"The default docker image to be used when cloning the repo\",\n\t\tValue:   constant.DefaultClonePlugin,\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_PIPELINE_TIMEOUT\"),\n\t\tName:    \"default-pipeline-timeout\",\n\t\tUsage:   \"The default time in minutes for a repo in minutes before a pipeline gets killed\",\n\t\tValue:   60,\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_MAX_PIPELINE_TIMEOUT\"),\n\t\tName:    \"max-pipeline-timeout\",\n\t\tUsage:   \"The maximum time in minutes you can set in the repo settings before a pipeline gets killed\",\n\t\tValue:   120,\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEFAULT_WORKFLOW_LABELS\"),\n\t\tName:    \"default-workflow-labels\",\n\t\tUsage:   \"The default label filter to set for workflows that has no label filter set. By default workflows will be allowed to run on any agent, if not specified in the workflow.\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SESSION_EXPIRES\"),\n\t\tName:    \"session-expires\",\n\t\tUsage:   \"session expiration time\",\n\t\tValue:   time.Hour * 72,\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_PLUGINS_PRIVILEGED\"),\n\t\tName:    \"plugins-privileged\",\n\t\tUsage:   \"Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_PLUGINS_TRUSTED_CLONE\"),\n\t\tName:    \"plugins-trusted-clone\",\n\t\tUsage:   \"Plugins which are trusted to handle Git credentials in clone steps\",\n\t\tValue:   constant.TrustedClonePlugins,\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_VOLUME\"),\n\t\tName:    \"volume\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DOCKER_CONFIG\"),\n\t\tName:    \"docker-config\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ENVIRONMENT\"),\n\t\tName:    \"environment\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_NETWORK\"),\n\t\tName:    \"network\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_AGENT_SECRET_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_AGENT_SECRET\")),\n\t\tName:  \"agent-secret\",\n\t\tUsage: \"server-agent shared password\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DISABLE_USER_AGENT_REGISTRATION\"),\n\t\tName:    \"disable-user-agent-registration\",\n\t\tUsage:   \"Disable user registered agents\",\n\t},\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_KEEPALIVE_MIN_TIME\"),\n\t\tName:    \"keepalive-min-time\",\n\t\tUsage:   \"server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONFIG_EXTENSION_ENDPOINT\", \"WOODPECKER_CONFIG_SERVICE_ENDPOINT\"), // TODO remove _SERVICE_ var in 4.0.0\n\t\tName:    \"config-extension-endpoint\",\n\t\tAliases: []string{\"config-service-endpoint\"}, // TODO: remove in v4.0.0\n\t\tUsage:   \"url used for calling global configuration service endpoint\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONFIG_EXTENSION_EXCLUSIVE\"),\n\t\tName:    \"config-extension-exclusive\",\n\t\tUsage:   \"whether global configuration service endpoint should be exclusive (skip forge)\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_CONFIG_EXTENSION_NETRC\"),\n\t\tName:    \"config-extension-netrc\",\n\t\tUsage:   \"whether global configuration extension should receive netrc data\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_REGISTRY_EXTENSION_ENDPOINT\"),\n\t\tName:    \"registry-extension-endpoint\",\n\t\tUsage:   \"url used for calling registry service endpoint\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_REGISTRY_EXTENSION_NETRC\"),\n\t\tName:    \"registry-extension-netrc\",\n\t\tUsage:   \"whether global registry extension should receive netrc data\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SECRET_EXTENSION_ENDPOINT\"),\n\t\tName:    \"secret-extension-endpoint\",\n\t\tUsage:   \"url used for calling external secret service endpoint\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_SECRET_EXTENSION_NETRC\"),\n\t\tName:    \"secret-extension-netrc\",\n\t\tUsage:   \"include netrc credentials in requests to secret service endpoint\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_EXTENSIONS_ALLOWED_HOSTS\"),\n\t\tName:    \"extensions-allowed-hosts\",\n\t\tUsage:   \"Hosts that are allowed to be contacted by extensions\",\n\t\tValue:   hostmatcher.MatchBuiltinExternal,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DATABASE_DRIVER\"),\n\t\tName:    \"db-driver\",\n\t\tAliases: []string{\"driver\"}, // TODO: remove in v4.0.0\n\t\tUsage:   \"database driver\",\n\t\tValue:   \"sqlite3\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_DATABASE_DATASOURCE_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_DATABASE_DATASOURCE\")),\n\t\tName:    \"db-datasource\",\n\t\tAliases: []string{\"datasource\"}, // TODO: remove in v4.0.0\n\t\tUsage:   \"database driver configuration string\",\n\t\tValue:   datasourceDefaultValue(),\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_PROMETHEUS_AUTH_TOKEN\")),\n\t\tName:  \"prometheus-auth-token\",\n\t\tUsage: \"token to secure prometheus metrics endpoint\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_STATUS_CONTEXT\", \"WOODPECKER_GITHUB_CONTEXT\", \"WOODPECKER_GITEA_CONTEXT\"),\n\t\tName:    \"status-context\",\n\t\tUsage:   \"status context prefix\",\n\t\tValue:   \"ci/woodpecker\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_STATUS_CONTEXT_FORMAT\"),\n\t\tName:    \"status-context-format\",\n\t\tUsage:   \"status context format\",\n\t\tValue:   \"{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_MIGRATIONS_ALLOW_LONG\"),\n\t\tName:    \"migrations-allow-long\",\n\t\tValue:   false,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ENABLE_SWAGGER\"),\n\t\tName:    \"enable-swagger\",\n\t\tValue:   true,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DISABLE_VERSION_CHECK\"),\n\t\tUsage:   \"Disable version check in admin web ui.\",\n\t\tName:    \"skip-version-check\",\n\t},\n\t&cli.UintFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT\"),\n\t\tUsage:   \"Maximum number of lines to show in a pipeline log, defaults to 5000.\",\n\t\tName:    \"max-pipeline-log-line-count\",\n\t\tValue:   5000,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_LOG_STORE\"),\n\t\tName:    \"log-store\",\n\t\tUsage:   \"log store to use ('database', 'addon' or 'file')\",\n\t\tValue:   \"database\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_LOG_STORE_FILE_PATH\"),\n\t\tName:    \"log-store-file-path\",\n\t\tUsage:   \"directory used for file based log storage or addon executable file path\",\n\t},\n\t//\n\t// backend options for pipeline compiler\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_NO_PROXY\", \"NO_PROXY\", \"no_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"NO_PROXY\\\" to steps\",\n\t\tName:    \"backend-no-proxy\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_HTTP_PROXY\", \"HTTP_PROXY\", \"http_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"HTTP_PROXY\\\" to steps\",\n\t\tName:    \"backend-http-proxy\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_HTTPS_PROXY\", \"HTTPS_PROXY\", \"https_proxy\"),\n\t\tUsage:   \"if set, pass the environment variable down as \\\"HTTPS_PROXY\\\" to steps\",\n\t\tName:    \"backend-https-proxy\",\n\t},\n\t// setting to have non breaking behavior till v4.0.0\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE\"),\n\t\tName:    \"force-ignore-service-failure\",\n\t\tUsage:   \"From v3.14.0 onwards, detached steps and services report their status back. To preserve the old behavior, service failures are ignored by default until v4.0.0.\",\n\t\tValue:   true,\n\t},\n\t//\n\t// resource limit parameters\n\t//\n\t&cli.DurationFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_FORGE_TIMEOUT\"),\n\t\tName:    \"forge-timeout\",\n\t\tUsage:   \"how many seconds before timeout when fetching the Woodpecker configuration from a Forge\",\n\t\tValue:   time.Second * 5,\n\t},\n\t&cli.UintFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_FORGE_RETRY\"),\n\t\tName:    \"forge-retry\",\n\t\tUsage:   \"How many retries of fetching the Woodpecker configuration from a forge are done before we fail\",\n\t\tValue:   3,\n\t},\n\t//\n\t// generic forge settings\n\t//\n\t&cli.StringFlag{\n\t\tName:    \"forge-url\",\n\t\tUsage:   \"url of the forge\",\n\t\tSources: cli.EnvVars(\"WOODPECKER_FORGE_URL\", \"WOODPECKER_GITHUB_URL\", \"WOODPECKER_GITLAB_URL\", \"WOODPECKER_GITEA_URL\", \"WOODPECKER_FORGEJO_URL\", \"WOODPECKER_BITBUCKET_URL\", \"WOODPECKER_BITBUCKET_DC_URL\"),\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(getFirstNonEmptyEnvVar(\n\t\t\t\t\"WOODPECKER_FORGE_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_GITHUB_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_GITLAB_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_GITEA_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_FORGEJO_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_BITBUCKET_CLIENT_FILE\",\n\t\t\t\t\"WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_FORGE_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITHUB_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITLAB_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITEA_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_FORGEJO_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_CLIENT\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_DC_CLIENT_ID\")),\n\t\tName:  \"forge-oauth-client\",\n\t\tUsage: \"oauth2 client id\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(getFirstNonEmptyEnvVar(\n\t\t\t\t\"WOODPECKER_FORGE_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_GITHUB_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_GITLAB_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_GITEA_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_FORGEJO_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_BITBUCKET_SECRET_FILE\",\n\t\t\t\t\"WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE\",\n\t\t\t)),\n\t\t\tcli.EnvVar(\"WOODPECKER_FORGE_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITHUB_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITLAB_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_GITEA_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_FORGEJO_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_SECRET\"),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_DC_CLIENT_SECRET\")),\n\t\tName:  \"forge-oauth-secret\",\n\t\tUsage: \"oauth2 client secret\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{\n\t\tName:  \"forge-skip-verify\",\n\t\tUsage: \"skip ssl verification\",\n\t\tSources: cli.EnvVars(\n\t\t\t\"WOODPECKER_FORGE_SKIP_VERIFY\",\n\t\t\t\"WOODPECKER_GITHUB_SKIP_VERIFY\",\n\t\t\t\"WOODPECKER_GITLAB_SKIP_VERIFY\",\n\t\t\t\"WOODPECKER_GITEA_SKIP_VERIFY\",\n\t\t\t\"WOODPECKER_FORGEJO_SKIP_VERIFY\",\n\t\t\t\"WOODPECKER_BITBUCKET_SKIP_VERIFY\"),\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_EXPERT_FORGE_OAUTH_HOST\"),\n\t\tName:    \"forge-oauth-host\",\n\t\tUsage:   \"fully qualified public forge url, used if forge url is not a public url. Format: <scheme>://<host>[/<prefix path>]\",\n\t},\n\t//\n\t// Addon\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ADDON_FORGE\"),\n\t\tName:    \"addon-forge\",\n\t\tUsage:   \"path to forge addon executable\",\n\t},\n\t//\n\t// GitHub\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GITHUB\"),\n\t\tName:    \"github\",\n\t\tUsage:   \"github driver is enabled\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GITHUB_MERGE_REF\"),\n\t\tName:    \"github-merge-ref\",\n\t\tUsage:   \"github pull requests use merge ref\",\n\t\tValue:   true,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GITHUB_PUBLIC_ONLY\"),\n\t\tName:    \"github-public-only\",\n\t\tUsage:   \"github tokens should only get access to public repos\",\n\t\tValue:   false,\n\t},\n\t//\n\t// Gitea\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GITEA\"),\n\t\tName:    \"gitea\",\n\t\tUsage:   \"gitea driver is enabled\",\n\t},\n\t//\n\t// Forgejo\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_FORGEJO\"),\n\t\tName:    \"forgejo\",\n\t\tUsage:   \"forgejo driver is enabled\",\n\t},\n\t//\n\t// Bitbucket\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BITBUCKET\"),\n\t\tName:    \"bitbucket\",\n\t\tUsage:   \"bitbucket driver is enabled\",\n\t},\n\t//\n\t// Gitlab\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_GITLAB\"),\n\t\tName:    \"gitlab\",\n\t\tUsage:   \"gitlab driver is enabled\",\n\t},\n\t//\n\t// Bitbucket DataCenter/Server (previously Stash)\n\t//\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BITBUCKET_DC\"),\n\t\tName:    \"bitbucket-dc\",\n\t\tUsage:   \"Bitbucket DataCenter/Server driver is enabled\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_DC_GIT_USERNAME\")),\n\t\tName:  \"bitbucket-dc-git-username\",\n\t\tUsage: \"Bitbucket DataCenter/Server service account username\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_BITBUCKET_DC_GIT_PASSWORD\")),\n\t\tName:  \"bitbucket-dc-git-password\",\n\t\tUsage: \"Bitbucket DataCenter/Server service account password\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{ // TODO: Remove this feature flag in next major version\n\t\tSources: cli.EnvVars(\"WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN\"),\n\t\tName:    \"bitbucket-dc-oauth-enable-oauth2-scope-project-admin\",\n\t\tUsage:   \"Bitbucket DataCenter/Server oauth2 scope should be configured to include PROJECT_ADMIN configuration.\",\n\t},\n\t//\n\t// development flags\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEV_WWW_PROXY\"),\n\t\tName:    \"www-proxy\",\n\t\tUsage:   \"serve the website by using a proxy (used for development)\",\n\t\tHidden:  true,\n\t},\n\t//\n\t// expert flags\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_EXPERT_WEBHOOK_HOST\"),\n\t\tName:    \"server-webhook-host\",\n\t\tUsage:   \"fully qualified woodpecker server url, called by the webhooks of the forge. Format: <scheme>://<host>[/<prefix path>]\",\n\t},\n\t//\n\t// secrets encryption in DB\n\t//\n\t&cli.StringFlag{\n\t\tSources: cli.NewValueSourceChain(\n\t\t\tcli.File(os.Getenv(\"WOODPECKER_ENCRYPTION_KEY_FILE\")),\n\t\t\tcli.EnvVar(\"WOODPECKER_ENCRYPTION_KEY\")),\n\t\tName:  \"encryption-raw-key\",\n\t\tUsage: \"Raw encryption key\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE\"),\n\t\tName:    \"encryption-tink-keyset\",\n\t\tUsage:   \"Google tink AEAD-compatible keyset file to encrypt secrets in DB\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_ENCRYPTION_DISABLE\"),\n\t\tName:    \"encryption-disable-flag\",\n\t\tUsage:   \"Flag to decrypt all encrypted data and disable encryption on server\",\n\t},\n}, logger.GlobalLoggerFlags...)\n\n// If woodpecker is running inside a container the default value for\n// the datasource is different from running outside a container.\nfunc datasourceDefaultValue() string {\n\t_, found := os.LookupEnv(\"WOODPECKER_IN_CONTAINER\")\n\tif found {\n\t\treturn \"/var/lib/woodpecker/woodpecker.sqlite\"\n\t}\n\treturn \"woodpecker.sqlite\"\n}\n\nfunc getFirstNonEmptyEnvVar(envVars ...string) string {\n\tfor _, envVar := range envVars {\n\t\tval := os.Getenv(envVar)\n\t\tif val != \"\" {\n\t\t\treturn val\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "cmd/server/grpc_server.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/keepalive\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tserver_rpc \"go.woodpecker-ci.org/woodpecker/v3/server/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc runGrpcServer(ctx context.Context, c *cli.Command, _store store.Store) error {\n\tlis, err := net.Listen(\"tcp\", c.String(\"grpc-addr\"))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to listen on grpc-addr: %w\", err)\n\t}\n\n\tjwtSecret := c.String(\"grpc-secret\")\n\tjwtManager := server_rpc.NewJWTManager(jwtSecret)\n\n\tauthorizer := server_rpc.NewAuthorizer(jwtManager)\n\tgrpcServer := grpc.NewServer(\n\t\tgrpc.StreamInterceptor(authorizer.StreamInterceptor),\n\t\tgrpc.UnaryInterceptor(authorizer.UnaryInterceptor),\n\t\tgrpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{\n\t\t\tMinTime: c.Duration(\"keepalive-min-time\"),\n\t\t}),\n\t)\n\n\twoodpeckerServer := server_rpc.NewWoodpeckerServer(\n\t\tserver.Config.Services.Scheduler,\n\t\tserver.Config.Services.Logs,\n\t\t_store,\n\t)\n\tproto.RegisterWoodpeckerServer(grpcServer, woodpeckerServer)\n\n\twoodpeckerAuthServer := server_rpc.NewWoodpeckerAuthServer(\n\t\tjwtManager,\n\t\tserver.Config.Server.AgentToken,\n\t\t_store,\n\t)\n\tproto.RegisterWoodpeckerAuthServer(grpcServer, woodpeckerAuthServer)\n\n\tgrpcCtx, cancel := context.WithCancelCause(ctx)\n\tdefer cancel(nil)\n\n\tgo func() {\n\t\t<-grpcCtx.Done()\n\t\tif grpcServer == nil {\n\t\t\treturn\n\t\t}\n\t\tlog.Info().Msg(\"terminating grpc service gracefully\")\n\t\tgrpcServer.GracefulStop()\n\t\tlog.Info().Msg(\"grpc service stopped\")\n\t}()\n\n\tif err := grpcServer.Serve(lis); err != nil {\n\t\t// signal that we don't have to stop the server gracefully anymore\n\t\tgrpcServer = nil\n\n\t\t// wrap the error so we know where it did come from\n\t\treturn fmt.Errorf(\"grpc server failed: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/server/health.go",
    "content": "// Copyright 2023 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n)\n\nconst pingTimeout = 1 * time.Second\n\n// handles pinging the endpoint and returns an error if the\n// server is in an unhealthy state.\nfunc pinger(_ context.Context, c *cli.Command) error {\n\tscheme := \"http\"\n\tserverAddr := c.String(\"server-addr\")\n\tif strings.HasPrefix(serverAddr, \":\") {\n\t\t// this seems sufficient according to https://pkg.go.dev/net#Dial\n\t\tserverAddr = \"localhost\" + serverAddr\n\t}\n\n\t// if woodpecker do ssl on it's own\n\tif c.String(\"server-cert\") != \"\" {\n\t\tscheme = \"https\"\n\t}\n\n\t// create the health url\n\thealthURL := fmt.Sprintf(\"%s://%s/healthz\", scheme, serverAddr)\n\tlog.Trace().Msgf(\"try to ping with url '%s'\", healthURL)\n\n\t// ask server if all is healthy\n\tclient := http.Client{Timeout: pingTimeout}\n\tresp, err := client.Get(healthURL)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"deadline exceeded\") {\n\t\t\treturn fmt.Errorf(\"ping timeout reached after %s\", pingTimeout)\n\t\t}\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode < 200 && resp.StatusCode >= 300 {\n\t\treturn fmt.Errorf(\"server returned bad status code\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/server/main.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !generate && !man\n\npackage main\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\t\"github.com/rs/zerolog/log\"\n\n\t_ \"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nfunc main() {\n\tctx := utils.WithContextSigtermCallback(context.Background(), func() {\n\t\tlog.Info().Msg(\"termination signal is received, shutting down server\")\n\t})\n\n\tapp := genApp()\n\n\tsetupOpenAPIStaticConfig()\n\n\tif err := app.Run(ctx, os.Args); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"error running server\")\n\t}\n}\n"
  },
  {
    "path": "cmd/server/man.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build man\n\npackage main\n\nimport (\n\t\"fmt\"\n\n\t_ \"github.com/joho/godotenv/autoload\"\n\tdocs \"github.com/urfave/cli-docs/v3\"\n\n\t_ \"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n)\n\nfunc main() {\n\tapp := genApp()\n\tmd, err := docs.ToMan(app)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfmt.Print(md)\n}\n"
  },
  {
    "path": "cmd/server/metrics_server.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc startMetricsCollector(ctx context.Context, _store store.Store) {\n\tpendingSteps := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pending_steps\",\n\t\tHelp:      \"Total number of pending pipeline steps.\",\n\t})\n\twaitingSteps := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"waiting_steps\",\n\t\tHelp:      \"Total number of pipeline waiting on deps.\",\n\t})\n\trunningSteps := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"running_steps\",\n\t\tHelp:      \"Total number of running pipeline steps.\",\n\t})\n\tworkers := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"worker_count\",\n\t\tHelp:      \"Total number of workers.\",\n\t})\n\tpipelines := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pipeline_total_count\",\n\t\tHelp:      \"Total number of pipelines.\",\n\t})\n\tusers := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"user_count\",\n\t\tHelp:      \"Total number of users.\",\n\t})\n\trepos := promauto.NewGauge(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"repo_count\",\n\t\tHelp:      \"Total number of repos.\",\n\t})\n\n\tgo func() {\n\t\tlog.Info().Msg(\"queue metric collector started\")\n\n\t\tfor {\n\t\t\tstats := server.Config.Services.Scheduler.Info(ctx)\n\t\t\tpendingSteps.Set(float64(stats.Stats.Pending))\n\t\t\twaitingSteps.Set(float64(stats.Stats.WaitingOnDeps))\n\t\t\trunningSteps.Set(float64(stats.Stats.Running))\n\t\t\tworkers.Set(float64(stats.Stats.Workers))\n\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tlog.Info().Msg(\"queue metric collector stopped\")\n\t\t\t\treturn\n\t\t\tcase <-time.After(queueInfoRefreshInterval):\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\tlog.Info().Msg(\"store metric collector started\")\n\n\t\tfor {\n\t\t\trepoCount, repoErr := _store.GetRepoCount()\n\t\t\tuserCount, userErr := _store.GetUserCount()\n\t\t\tpipelineCount, pipelineErr := _store.GetPipelineCount()\n\t\t\tpipelines.Set(float64(pipelineCount))\n\t\t\tusers.Set(float64(userCount))\n\t\t\trepos.Set(float64(repoCount))\n\n\t\t\tif err := errors.Join(repoErr, userErr, pipelineErr); err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"could not update store information for metrics\")\n\t\t\t}\n\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tlog.Info().Msg(\"store metric collector stopped\")\n\t\t\t\treturn\n\t\t\tcase <-time.After(storeInfoRefreshInterval):\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "cmd/server/openapi/docs.go",
    "content": "// Package openapi Code generated by swaggo/swag. DO NOT EDIT\npackage openapi\n\nimport \"github.com/swaggo/swag\"\n\nconst docTemplate = `{\n    \"schemes\": {{ marshal .Schemes }},\n    \"swagger\": \"2.0\",\n    \"info\": {\n        \"description\": \"{{escape .Description}}\",\n        \"title\": \"{{.Title}}\",\n        \"contact\": {\n            \"name\": \"Woodpecker CI\",\n            \"url\": \"https://woodpecker-ci.org/\"\n        },\n        \"version\": \"{{.Version}}\"\n    },\n    \"host\": \"{{.Host}}\",\n    \"basePath\": \"{{.BasePath}}\",\n    \"paths\": {\n        \"/agents\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"List agents\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Agent\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Creates a new agent with a random token\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Create a new agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the agent's data (only 'name' and 'no_schedule' are read)\",\n                        \"name\": \"agent\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/agents/{agent_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Get an agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Delete an agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Update an agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the agent's data\",\n                        \"name\": \"agentData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/agents/{agent_id}/tasks\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"List agent tasks\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Task\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/badges/{repo_id}/cc.xml\": {\n            \"get\": {\n                \"description\": \"CCMenu displays the pipeline status of projects on a CI server as an item in the Mac's menu bar.\\nMore details on how to install, you can find at http://ccmenu.org/\\nThe response format adheres to CCTray v1 Specification, https://cctray.org/v1/\",\n                \"produces\": [\n                    \"text/xml\"\n                ],\n                \"tags\": [\n                    \"Badges\"\n                ],\n                \"summary\": \"Provide pipeline status information to the CCMenu tool\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/badges/{repo_id}/status.svg\": {\n            \"get\": {\n                \"produces\": [\n                    \"image/svg+xml\"\n                ],\n                \"tags\": [\n                    \"Badges\"\n                ],\n                \"summary\": \"Get status of pipeline as SVG badge\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/html\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"List available pprof profiles (HTML)\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/block\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof stack traces that led to blocking on synchronization primitives\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/cmdline\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get the command line invocation of the current program\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/goroutine\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof stack traces of all current goroutines\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"Use debug=2 as a query parameter to export in the same format as an un-recovered panic\",\n                        \"name\": \"debug\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/heap\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof heap dump, a sampling of memory allocations of live objects\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"\",\n                        \"description\": \"You can specify gc=heap to run GC before taking the heap sample\",\n                        \"name\": \"gc\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/profile\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\\nAfter you get the profile file, use the go tool pprof command to investigate the profile.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof CPU profile\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"You can specify the duration in the seconds GET parameter.\",\n                        \"name\": \"seconds\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/symbol\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\\nLooks up the program counters listed in the request,\\nresponding with a table mapping program counters to function names.\\nThe requested program counters can be provided via GET + query parameters,\\nor POST + body parameters. Program counters shall be space delimited.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof program counters mapping to function names\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\\nLooks up the program counters listed in the request,\\nresponding with a table mapping program counters to function names.\\nThe requested program counters can be provided via GET + query parameters,\\nor POST + body parameters. Program counters shall be space delimited.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof program counters mapping to function names\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/threadcreate\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get pprof stack traces that led to the creation of new OS threads\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/debug/pprof/trace\": {\n            \"get\": {\n                \"description\": \"Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\\nAfter you get the profile file, use the go tool pprof command to investigate the profile.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Process profiling and debugging\"\n                ],\n                \"summary\": \"Get a trace of execution of the current program\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"You can specify the duration in the seconds GET parameter.\",\n                        \"name\": \"seconds\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/forges\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Forges\"\n                ],\n                \"summary\": \"List forges\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Forge\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Creates a new forge with a random token\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Forges\"\n                ],\n                \"summary\": \"Create a new forge\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the forge's data (only 'name' and 'no_schedule' are read)\",\n                        \"name\": \"forge\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/ForgeWithOAuthClientSecret\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Forge\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/forges/{forge_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Forges\"\n                ],\n                \"summary\": \"Get a forge\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the forge's id\",\n                        \"name\": \"forge_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Forge\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Forges\"\n                ],\n                \"summary\": \"Delete a forge\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the forge's id\",\n                        \"name\": \"forge_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Forges\"\n                ],\n                \"summary\": \"Update a forge\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the forge's id\",\n                        \"name\": \"forge_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the forge's data\",\n                        \"name\": \"forgeData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/ForgeWithOAuthClientSecret\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Forge\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/healthz\": {\n            \"get\": {\n                \"description\": \"If everything is fine, just a 204 will be returned, a 500 signals server state is unhealthy.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Health information\",\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    },\n                    \"500\": {\n                        \"description\": \"Internal Server Error\"\n                    }\n                }\n            }\n        },\n        \"/hook\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Incoming webhook from forge\",\n                \"parameters\": [\n                    {\n                        \"description\": \"the webhook payload; forge is automatically detected\",\n                        \"name\": \"hook\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"object\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/log-level\": {\n            \"get\": {\n                \"description\": \"Endpoint returns the current logging level. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Current log level\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"log-level\": {\n                                    \"type\": \"string\"\n                                }\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Endpoint sets the current logging level. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Set log level\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new log level, one of \\u003cdebug,trace,info,warn,error,fatal,panic,disabled\\u003e\",\n                        \"name\": \"log-level\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"log-level\": {\n                                    \"type\": \"string\"\n                                }\n                            }\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"log-level\": {\n                                    \"type\": \"string\"\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs\": {\n            \"get\": {\n                \"description\": \"Returns all registered orgs in the system. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Orgs\"\n                ],\n                \"summary\": \"List organizations\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Org\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/lookup/{org_full_name}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Orgs\"\n                ],\n                \"summary\": \"Lookup an organization by full name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the organizations full name / slug\",\n                        \"name\": \"org_full_name\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Org\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{id}\": {\n            \"delete\": {\n                \"description\": \"Deletes the given org. Requires admin rights.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Orgs\"\n                ],\n                \"summary\": \"Delete an organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization\"\n                ],\n                \"summary\": \"Get an organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Org\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/agents\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"List agents for an organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Agent\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Creates a new agent with a random token, scoped to the specified organization\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Create a new organization-scoped agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the agent's data (only 'name' and 'no_schedule' are read)\",\n                        \"name\": \"agent\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/agents/{agent_id}\": {\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Delete an organization-scoped agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Agents\"\n                ],\n                \"summary\": \"Update an organization-scoped agent\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the agent's id\",\n                        \"name\": \"agent_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the agent's updated data\",\n                        \"name\": \"agent\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Agent\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/permissions\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization permissions\"\n                ],\n                \"summary\": \"Get the permissions of the currently authenticated user for the given organization\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the organization's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/OrgPerm\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/registries\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization registries\"\n                ],\n                \"summary\": \"List organization registries\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Registry\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization registries\"\n                ],\n                \"summary\": \"Create an organization registry\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new registry\",\n                        \"name\": \"registryData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/registries/{registry}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization registries\"\n                ],\n                \"summary\": \"Get a organization registry by address\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's address\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Organization registries\"\n                ],\n                \"summary\": \"Delete an organization registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization registries\"\n                ],\n                \"summary\": \"Update an organization registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the update registry data\",\n                        \"name\": \"registryData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/secrets\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization secrets\"\n                ],\n                \"summary\": \"List organization secrets\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Secret\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization secrets\"\n                ],\n                \"summary\": \"Create an organization secret\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new secret\",\n                        \"name\": \"secretData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/orgs/{org_id}/secrets/{secret}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization secrets\"\n                ],\n                \"summary\": \"Get a organization secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Organization secrets\"\n                ],\n                \"summary\": \"Delete an organization secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Organization secrets\"\n                ],\n                \"summary\": \"Update an organization secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the org's id\",\n                        \"name\": \"org_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the update secret data\",\n                        \"name\": \"secretData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/SecretPatch\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/pipelines\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipeline queues\"\n                ],\n                \"summary\": \"List pipelines in queue\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Feed\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/queue/info\": {\n            \"get\": {\n                \"description\": \"Returns pipeline queue information with agent details\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipeline queues\"\n                ],\n                \"summary\": \"Get pipeline queue information\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/QueueInfo\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/queue/norunningpipelines\": {\n            \"get\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline queues\"\n                ],\n                \"summary\": \"Block til pipeline queue has a running item\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/queue/pause\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline queues\"\n                ],\n                \"summary\": \"Pause the pipeline queue\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/queue/resume\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline queues\"\n                ],\n                \"summary\": \"Resume the pipeline queue\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/registries\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Registries\"\n                ],\n                \"summary\": \"List global registries\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Registry\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Registries\"\n                ],\n                \"summary\": \"Create a global registry\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the registry object data\",\n                        \"name\": \"registry\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/registries/{registry}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Registries\"\n                ],\n                \"summary\": \"Get a global registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Registries\"\n                ],\n                \"summary\": \"Delete a global registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Registries\"\n                ],\n                \"summary\": \"Update a global registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry's name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the registry's data\",\n                        \"name\": \"registryData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos\": {\n            \"get\": {\n                \"description\": \"Returns a list of all repositories. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"List all repositories on the server\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"boolean\",\n                        \"description\": \"only list active repos\",\n                        \"name\": \"active\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Repo\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Activate a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the id of a repository at the forge\",\n                        \"name\": \"forge_remote_id\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/lookup/{repo_full_name}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Lookup a repository by full name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the repository full name / slug\",\n                        \"name\": \"repo_full_name\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/repair\": {\n            \"post\": {\n                \"description\": \"Executes a repair process on all repositories. Requires admin rights.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Repair all repositories on the server\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Get a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Delete a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Update a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the repository's information\",\n                        \"name\": \"repo\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/RepoPatch\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/branches\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Get branches of a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"type\": \"string\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/chown\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Change a repository's owner to the currently authenticated user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Repo\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/cron\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"List cron jobs\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Cron\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"Create a cron job\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new cron job\",\n                        \"name\": \"cronJob\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Cron\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Cron\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/cron/{cron}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"Get a cron job\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the cron job id\",\n                        \"name\": \"cron\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Cron\"\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"Start a cron job now\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the cron job id\",\n                        \"name\": \"cron\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"Delete a cron job\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the cron job id\",\n                        \"name\": \"cron\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository cron jobs\"\n                ],\n                \"summary\": \"Update a cron job\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the cron job id\",\n                        \"name\": \"cron\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the cron job data\",\n                        \"name\": \"cronJob\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/CronPatch\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Cron\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/logs/{pipeline_number}\": {\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline logs\"\n                ],\n                \"summary\": \"Deletes all logs of a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/logs/{pipeline_number}/{step_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipeline logs\"\n                ],\n                \"summary\": \"Get logs for a pipeline step\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the step id\",\n                        \"name\": \"step_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/LogEntry\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline logs\"\n                ],\n                \"summary\": \"Delete step logs of a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the step id\",\n                        \"name\": \"step_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/move\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Move a repository to a new owner\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the username to move the repository to\",\n                        \"name\": \"to\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/permissions\": {\n            \"get\": {\n                \"description\": \"The repository permission, according to the used access token.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Check current authenticated users access to the repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Perm\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines\": {\n            \"get\": {\n                \"description\": \"Get a list of pipelines for a repository.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"List repository pipelines\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"only return pipelines before this RFC3339 date\",\n                        \"name\": \"before\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"only return pipelines after this RFC3339 date\",\n                        \"name\": \"after\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"filter pipelines by branch\",\n                        \"name\": \"branch\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"filter pipelines by webhook events (comma separated)\",\n                        \"name\": \"event\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"filter pipelines by strings contained in ref\",\n                        \"name\": \"ref\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"filter pipelines by status\",\n                        \"name\": \"status\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Pipeline\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Trigger a manual pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the options for the pipeline to run\",\n                        \"name\": \"options\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/PipelineOptions\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Get a repositories pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline, OR 'latest'\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Restarts a pipeline optional with altered event, deploy or environment\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Restart a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"override the event type\",\n                        \"name\": \"event\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"override the target deploy value\",\n                        \"name\": \"deploy_to\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Delete a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}/approve\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Approve and start a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}/cancel\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Cancel a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}/config\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Get configuration files for a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Config\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}/decline\": {\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Decline a pipeline\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Pipeline\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pipelines/{pipeline_number}/metadata\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Pipelines\"\n                ],\n                \"summary\": \"Get metadata for a pipeline or a specific workflow, including previous pipeline info\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline_number\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/metadata.Metadata\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/pull_requests\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"List active pull requests of a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/PullRequest\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/registries\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository registries\"\n                ],\n                \"summary\": \"List registries\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Registry\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository registries\"\n                ],\n                \"summary\": \"Create a registry\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new registry data\",\n                        \"name\": \"registry\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/registries/{registry}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository registries\"\n                ],\n                \"summary\": \"Get a registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repository registries\"\n                ],\n                \"summary\": \"Delete a registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository registries\"\n                ],\n                \"summary\": \"Update a registry by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the registry name\",\n                        \"name\": \"registry\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the attributes for the registry\",\n                        \"name\": \"registryData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Registry\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/repair\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repositories\"\n                ],\n                \"summary\": \"Repair a repository\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/secrets\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository secrets\"\n                ],\n                \"summary\": \"List repository secrets\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Secret\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository secrets\"\n                ],\n                \"summary\": \"Create a repository secret\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the new secret\",\n                        \"name\": \"secret\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/repos/{repo_id}/secrets/{secretName}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository secrets\"\n                ],\n                \"summary\": \"Get a repository secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret name\",\n                        \"name\": \"secretName\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Repository secrets\"\n                ],\n                \"summary\": \"Delete a repository secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret name\",\n                        \"name\": \"secretName\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Repository secrets\"\n                ],\n                \"summary\": \"Update a repository secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret name\",\n                        \"name\": \"secretName\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the secret itself\",\n                        \"name\": \"secret\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/SecretPatch\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/secrets\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Secrets\"\n                ],\n                \"summary\": \"List global secrets\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Secret\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Secrets\"\n                ],\n                \"summary\": \"Create a global secret\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the secret object data\",\n                        \"name\": \"secret\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/secrets/{secret}\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Secrets\"\n                ],\n                \"summary\": \"Get a global secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Secrets\"\n                ],\n                \"summary\": \"Delete a global secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Secrets\"\n                ],\n                \"summary\": \"Update a global secret by name\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the secret's name\",\n                        \"name\": \"secret\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the secret's data\",\n                        \"name\": \"secretData\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/SecretPatch\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/Secret\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/signature/public-key\": {\n            \"get\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Get server's signature public key\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/stream/events\": {\n            \"get\": {\n                \"description\": \"With quic and http2 support\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Events\"\n                ],\n                \"summary\": \"Stream events like pipeline updates\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/stream/logs/{repo_id}/{pipeline}/{step_id}\": {\n            \"get\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Pipeline logs\"\n                ],\n                \"summary\": \"Stream logs of a pipeline step\",\n                \"parameters\": [\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the repository id\",\n                        \"name\": \"repo_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the number of the pipeline\",\n                        \"name\": \"pipeline\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"description\": \"the step id\",\n                        \"name\": \"step_id\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/user\": {\n            \"get\": {\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"User\"\n                ],\n                \"summary\": \"Get the currently authenticated user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/user/feed\": {\n            \"get\": {\n                \"description\": \"The feed lists the most recent pipeline for the currently authenticated user.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"User\"\n                ],\n                \"summary\": \"Get the currently authenticated users pipeline feed\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/Feed\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/user/repos\": {\n            \"get\": {\n                \"description\": \"Retrieve the currently authenticated User's Repository list\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"User\"\n                ],\n                \"summary\": \"Get user's repositories\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"boolean\",\n                        \"description\": \"query all repos, including inactive ones\",\n                        \"name\": \"all\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"filter repos by name\",\n                        \"name\": \"name\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/RepoLastPipeline\"\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        \"/user/token\": {\n            \"post\": {\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"User\"\n                ],\n                \"summary\": \"Return the token of the current user as string\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"description\": \"Reset's the current personal access token of the user and returns a new one.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"User\"\n                ],\n                \"summary\": \"Reset a token\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\"\n                    }\n                }\n            }\n        },\n        \"/users\": {\n            \"get\": {\n                \"description\": \"Returns all registered, active users in the system. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Users\"\n                ],\n                \"summary\": \"List users\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 1,\n                        \"description\": \"for response pagination, page offset number\",\n                        \"name\": \"page\",\n                        \"in\": \"query\"\n                    },\n                    {\n                        \"type\": \"integer\",\n                        \"default\": 50,\n                        \"description\": \"for response pagination, max items per page\",\n                        \"name\": \"perPage\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"array\",\n                            \"items\": {\n                                \"$ref\": \"#/definitions/User\"\n                            }\n                        }\n                    }\n                }\n            },\n            \"post\": {\n                \"description\": \"Creates a new user account with the specified external login. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Users\"\n                ],\n                \"summary\": \"Create a user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the user's data\",\n                        \"name\": \"user\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/users/{login}\": {\n            \"get\": {\n                \"description\": \"Returns a user with the specified login name. Requires admin rights.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Users\"\n                ],\n                \"summary\": \"Get a user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the user's login name\",\n                        \"name\": \"login\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"specify forge (else default will be used)\",\n                        \"name\": \"forge_id\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"specify user id at forge (else fallback to login)\",\n                        \"name\": \"forge_remote_id\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                }\n            },\n            \"delete\": {\n                \"description\": \"Deletes the given user. Requires admin rights.\",\n                \"produces\": [\n                    \"text/plain\"\n                ],\n                \"tags\": [\n                    \"Users\"\n                ],\n                \"summary\": \"Delete a user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the user's login name\",\n                        \"name\": \"login\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"specify forge (else default will be used)\",\n                        \"name\": \"forge_id\",\n                        \"in\": \"query\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"specify user id at forge (else fallback to login)\",\n                        \"name\": \"forge_remote_id\",\n                        \"in\": \"query\"\n                    }\n                ],\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No Content\"\n                    }\n                }\n            },\n            \"patch\": {\n                \"description\": \"Changes the data of an existing user. Requires admin rights.\",\n                \"consumes\": [\n                    \"application/json\"\n                ],\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"Users\"\n                ],\n                \"summary\": \"Update a user\",\n                \"parameters\": [\n                    {\n                        \"type\": \"string\",\n                        \"default\": \"Bearer \\u003cpersonal access token\\u003e\",\n                        \"description\": \"Insert your personal access token\",\n                        \"name\": \"Authorization\",\n                        \"in\": \"header\",\n                        \"required\": true\n                    },\n                    {\n                        \"type\": \"string\",\n                        \"description\": \"the user's login name\",\n                        \"name\": \"login\",\n                        \"in\": \"path\",\n                        \"required\": true\n                    },\n                    {\n                        \"description\": \"the user's data\",\n                        \"name\": \"user\",\n                        \"in\": \"body\",\n                        \"required\": true,\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"$ref\": \"#/definitions/User\"\n                        }\n                    }\n                }\n            }\n        },\n        \"/version\": {\n            \"get\": {\n                \"description\": \"Endpoint returns the server version and build information.\",\n                \"produces\": [\n                    \"application/json\"\n                ],\n                \"tags\": [\n                    \"System\"\n                ],\n                \"summary\": \"Get version\",\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"OK\",\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"source\": {\n                                    \"type\": \"string\"\n                                },\n                                \"version\": {\n                                    \"type\": \"string\"\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    },\n    \"definitions\": {\n        \"Agent\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"backend\": {\n                    \"type\": \"string\"\n                },\n                \"capacity\": {\n                    \"type\": \"integer\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"custom_labels\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"last_contact\": {\n                    \"type\": \"integer\"\n                },\n                \"last_work\": {\n                    \"description\": \"last time the agent did something, this value is used to determine if the agent is still doing work used by the autoscaler\",\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"no_schedule\": {\n                    \"type\": \"boolean\"\n                },\n                \"org_id\": {\n                    \"description\": \"OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default\",\n                    \"type\": \"integer\"\n                },\n                \"owner_id\": {\n                    \"type\": \"integer\"\n                },\n                \"platform\": {\n                    \"type\": \"string\"\n                },\n                \"token\": {\n                    \"type\": \"string\"\n                },\n                \"updated\": {\n                    \"type\": \"integer\"\n                },\n                \"version\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"CancelInfo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"canceled_by_step\": {\n                    \"type\": \"string\"\n                },\n                \"canceled_by_user\": {\n                    \"type\": \"string\"\n                },\n                \"superseded_by\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"Config\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"data\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"hash\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"Cron\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"creator_id\": {\n                    \"description\": \"TODO: drop with next major version\",\n                    \"type\": \"integer\"\n                },\n                \"enabled\": {\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"next_exec\": {\n                    \"type\": \"integer\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"schedule\": {\n                    \"description\": \"@weekly,\\t3min, ...\",\n                    \"type\": \"string\"\n                },\n                \"variables\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        },\n        \"CronPatch\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"enabled\": {\n                    \"type\": \"boolean\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"schedule\": {\n                    \"type\": \"string\"\n                },\n                \"variables\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        },\n        \"Feed\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"type\": \"string\"\n                },\n                \"author_avatar\": {\n                    \"type\": \"string\"\n                },\n                \"author_email\": {\n                    \"type\": \"string\"\n                },\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"commit\": {\n                    \"type\": \"string\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"event\": {\n                    \"type\": \"string\"\n                },\n                \"finished\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"number\": {\n                    \"type\": \"integer\"\n                },\n                \"ref\": {\n                    \"type\": \"string\"\n                },\n                \"refspec\": {\n                    \"type\": \"string\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"started\": {\n                    \"type\": \"integer\"\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"Forge\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"additional_options\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {}\n                },\n                \"client\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"oauth_host\": {\n                    \"description\": \"public url for oauth if different from url\",\n                    \"type\": \"string\"\n                },\n                \"skip_verify\": {\n                    \"type\": \"boolean\"\n                },\n                \"type\": {\n                    \"$ref\": \"#/definitions/model.ForgeType\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"ForgeWithOAuthClientSecret\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"additional_options\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {}\n                },\n                \"client\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"oauth_client_secret\": {\n                    \"type\": \"string\"\n                },\n                \"oauth_host\": {\n                    \"description\": \"public url for oauth if different from url\",\n                    \"type\": \"string\"\n                },\n                \"skip_verify\": {\n                    \"type\": \"boolean\"\n                },\n                \"type\": {\n                    \"$ref\": \"#/definitions/model.ForgeType\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"LogEntry\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"data\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"integer\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"line\": {\n                    \"type\": \"integer\"\n                },\n                \"step_id\": {\n                    \"type\": \"integer\"\n                },\n                \"time\": {\n                    \"type\": \"integer\"\n                },\n                \"type\": {\n                    \"$ref\": \"#/definitions/LogEntryType\"\n                }\n            }\n        },\n        \"LogEntryType\": {\n            \"type\": \"integer\",\n            \"enum\": [\n                0,\n                1,\n                2,\n                3,\n                4\n            ],\n            \"x-enum-varnames\": [\n                \"LogEntryStdout\",\n                \"LogEntryStderr\",\n                \"LogEntryExitCode\",\n                \"LogEntryMetadata\",\n                \"LogEntryProgress\"\n            ]\n        },\n        \"Org\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"forge_id\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"is_user\": {\n                    \"type\": \"boolean\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"OrgPerm\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"admin\": {\n                    \"type\": \"boolean\"\n                },\n                \"member\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"Perm\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"admin\": {\n                    \"type\": \"boolean\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"pull\": {\n                    \"type\": \"boolean\"\n                },\n                \"push\": {\n                    \"type\": \"boolean\"\n                },\n                \"synced\": {\n                    \"type\": \"integer\"\n                },\n                \"updated\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"Pipeline\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"type\": \"string\"\n                },\n                \"author_avatar\": {\n                    \"type\": \"string\"\n                },\n                \"author_email\": {\n                    \"type\": \"string\"\n                },\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"cancel_info\": {\n                    \"$ref\": \"#/definitions/CancelInfo\"\n                },\n                \"changed_files\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"commit\": {\n                    \"type\": \"string\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"cron\": {\n                    \"description\": \"name of the cron job\",\n                    \"type\": \"string\"\n                },\n                \"deploy_task\": {\n                    \"type\": \"string\"\n                },\n                \"deploy_to\": {\n                    \"type\": \"string\"\n                },\n                \"errors\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/errors.PipelineError\"\n                    }\n                },\n                \"event\": {\n                    \"$ref\": \"#/definitions/WebhookEvent\"\n                },\n                \"event_reason\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"finished\": {\n                    \"type\": \"integer\"\n                },\n                \"forge_url\": {\n                    \"type\": \"string\"\n                },\n                \"from_fork\": {\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"is_prerelease\": {\n                    \"type\": \"boolean\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"number\": {\n                    \"type\": \"integer\"\n                },\n                \"parent\": {\n                    \"type\": \"integer\"\n                },\n                \"pr_labels\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"pr_milestone\": {\n                    \"type\": \"string\"\n                },\n                \"ref\": {\n                    \"type\": \"string\"\n                },\n                \"refspec\": {\n                    \"type\": \"string\"\n                },\n                \"reviewed\": {\n                    \"type\": \"integer\"\n                },\n                \"reviewed_by\": {\n                    \"type\": \"string\"\n                },\n                \"sender\": {\n                    \"description\": \"uses reported user for webhooks and name of cron for cron pipelines\",\n                    \"type\": \"string\"\n                },\n                \"started\": {\n                    \"type\": \"integer\"\n                },\n                \"status\": {\n                    \"$ref\": \"#/definitions/StatusValue\"\n                },\n                \"timestamp\": {\n                    \"type\": \"integer\"\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                },\n                \"updated\": {\n                    \"type\": \"integer\"\n                },\n                \"variables\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"version\": {\n                    \"type\": \"string\"\n                },\n                \"workflows\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.Workflow\"\n                    }\n                }\n            }\n        },\n        \"PipelineOptions\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"variables\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        },\n        \"PullRequest\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"index\": {\n                    \"type\": \"string\"\n                },\n                \"title\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"QueueInfo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"paused\": {\n                    \"type\": \"boolean\"\n                },\n                \"pending\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.QueueTask\"\n                    }\n                },\n                \"running\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.QueueTask\"\n                    }\n                },\n                \"stats\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"pending_count\": {\n                            \"type\": \"integer\"\n                        },\n                        \"running_count\": {\n                            \"type\": \"integer\"\n                        },\n                        \"waiting_on_deps_count\": {\n                            \"type\": \"integer\"\n                        },\n                        \"worker_count\": {\n                            \"type\": \"integer\"\n                        }\n                    }\n                },\n                \"waiting_on_deps\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/model.QueueTask\"\n                    }\n                }\n            }\n        },\n        \"Registry\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"address\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"org_id\": {\n                    \"type\": \"integer\"\n                },\n                \"password\": {\n                    \"type\": \"string\"\n                },\n                \"readonly\": {\n                    \"type\": \"boolean\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"username\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"Repo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"active\": {\n                    \"type\": \"boolean\"\n                },\n                \"allow_deploy\": {\n                    \"type\": \"boolean\"\n                },\n                \"allow_pr\": {\n                    \"type\": \"boolean\"\n                },\n                \"approval_allowed_users\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"avatar_url\": {\n                    \"type\": \"string\"\n                },\n                \"cancel_previous_pipeline_events\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/WebhookEvent\"\n                    }\n                },\n                \"clone_url\": {\n                    \"type\": \"string\"\n                },\n                \"clone_url_ssh\": {\n                    \"type\": \"string\"\n                },\n                \"config_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"config_extension_exclusive\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_file\": {\n                    \"type\": \"string\"\n                },\n                \"default_branch\": {\n                    \"type\": \"string\"\n                },\n                \"forge_id\": {\n                    \"type\": \"integer\"\n                },\n                \"forge_remote_id\": {\n                    \"description\": \"ForgeRemoteID is the unique identifier for the repository on the forge.\",\n                    \"type\": \"string\"\n                },\n                \"forge_url\": {\n                    \"type\": \"string\"\n                },\n                \"full_name\": {\n                    \"type\": \"string\"\n                },\n                \"has_forge_name_conflict\": {\n                    \"description\": \"HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id\",\n                    \"type\": \"boolean\"\n                },\n                \"has_no_forge_repo\": {\n                    \"description\": \"HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore\",\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"netrc_trusted\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"org_id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"string\"\n                },\n                \"pr_enabled\": {\n                    \"type\": \"boolean\"\n                },\n                \"private\": {\n                    \"type\": \"boolean\"\n                },\n                \"registry_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"registry_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"require_approval\": {\n                    \"$ref\": \"#/definitions/model.ApprovalMode\"\n                },\n                \"secret_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"secret_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"timeout\": {\n                    \"type\": \"integer\"\n                },\n                \"trusted\": {\n                    \"$ref\": \"#/definitions/model.TrustedConfiguration\"\n                },\n                \"visibility\": {\n                    \"$ref\": \"#/definitions/RepoVisibility\"\n                }\n            }\n        },\n        \"RepoLastPipeline\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"active\": {\n                    \"type\": \"boolean\"\n                },\n                \"allow_deploy\": {\n                    \"type\": \"boolean\"\n                },\n                \"allow_pr\": {\n                    \"type\": \"boolean\"\n                },\n                \"approval_allowed_users\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"avatar_url\": {\n                    \"type\": \"string\"\n                },\n                \"cancel_previous_pipeline_events\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/WebhookEvent\"\n                    }\n                },\n                \"clone_url\": {\n                    \"type\": \"string\"\n                },\n                \"clone_url_ssh\": {\n                    \"type\": \"string\"\n                },\n                \"config_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"config_extension_exclusive\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_file\": {\n                    \"type\": \"string\"\n                },\n                \"default_branch\": {\n                    \"type\": \"string\"\n                },\n                \"forge_id\": {\n                    \"type\": \"integer\"\n                },\n                \"forge_remote_id\": {\n                    \"description\": \"ForgeRemoteID is the unique identifier for the repository on the forge.\",\n                    \"type\": \"string\"\n                },\n                \"forge_url\": {\n                    \"type\": \"string\"\n                },\n                \"full_name\": {\n                    \"type\": \"string\"\n                },\n                \"has_forge_name_conflict\": {\n                    \"description\": \"HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id\",\n                    \"type\": \"boolean\"\n                },\n                \"has_no_forge_repo\": {\n                    \"description\": \"HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore\",\n                    \"type\": \"boolean\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"last_pipeline\": {\n                    \"$ref\": \"#/definitions/Pipeline\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"netrc_trusted\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"org_id\": {\n                    \"type\": \"integer\"\n                },\n                \"owner\": {\n                    \"type\": \"string\"\n                },\n                \"pr_enabled\": {\n                    \"type\": \"boolean\"\n                },\n                \"private\": {\n                    \"type\": \"boolean\"\n                },\n                \"registry_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"registry_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"require_approval\": {\n                    \"$ref\": \"#/definitions/model.ApprovalMode\"\n                },\n                \"secret_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"secret_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"timeout\": {\n                    \"type\": \"integer\"\n                },\n                \"trusted\": {\n                    \"$ref\": \"#/definitions/model.TrustedConfiguration\"\n                },\n                \"visibility\": {\n                    \"$ref\": \"#/definitions/RepoVisibility\"\n                }\n            }\n        },\n        \"RepoPatch\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"allow_deploy\": {\n                    \"type\": \"boolean\"\n                },\n                \"allow_pr\": {\n                    \"type\": \"boolean\"\n                },\n                \"approval_allowed_users\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"cancel_previous_pipeline_events\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/WebhookEvent\"\n                    }\n                },\n                \"config_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"config_extension_exclusive\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"config_file\": {\n                    \"type\": \"string\"\n                },\n                \"netrc_trusted\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"registry_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"registry_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"require_approval\": {\n                    \"type\": \"string\"\n                },\n                \"secret_extension_endpoint\": {\n                    \"type\": \"string\"\n                },\n                \"secret_extension_netrc\": {\n                    \"type\": \"boolean\"\n                },\n                \"timeout\": {\n                    \"type\": \"integer\"\n                },\n                \"trusted\": {\n                    \"$ref\": \"#/definitions/model.TrustedConfigurationPatch\"\n                },\n                \"visibility\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"RepoVisibility\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"public\",\n                \"private\",\n                \"internal\"\n            ],\n            \"x-enum-varnames\": [\n                \"VisibilityPublic\",\n                \"VisibilityPrivate\",\n                \"VisibilityInternal\"\n            ]\n        },\n        \"Secret\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"events\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/WebhookEvent\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"images\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"note\": {\n                    \"type\": \"string\"\n                },\n                \"org_id\": {\n                    \"type\": \"integer\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"value\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"SecretPatch\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"events\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/WebhookEvent\"\n                    }\n                },\n                \"images\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"note\": {\n                    \"type\": \"string\"\n                },\n                \"value\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"StatusValue\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"skipped\",\n                \"pending\",\n                \"running\",\n                \"success\",\n                \"failure\",\n                \"killed\",\n                \"canceled\",\n                \"error\",\n                \"blocked\",\n                \"declined\",\n                \"created\"\n            ],\n            \"x-enum-comments\": {\n                \"StatusBlocked\": \"waiting for approval\",\n                \"StatusCanceled\": \"canceled but hasn't been started\",\n                \"StatusCreated\": \"created / internal use only\",\n                \"StatusDeclined\": \"blocked and declined\",\n                \"StatusError\": \"error with the config / while parsing / some other system problem\",\n                \"StatusFailure\": \"failed to finish (exit code != 0)\",\n                \"StatusKilled\": \"killed by user\",\n                \"StatusPending\": \"pending to be executed\",\n                \"StatusRunning\": \"currently running\",\n                \"StatusSkipped\": \"skipped as per condition of current workflow failed/success state\",\n                \"StatusSuccess\": \"successfully finished\"\n            },\n            \"x-enum-descriptions\": [\n                \"skipped as per condition of current workflow failed/success state\",\n                \"pending to be executed\",\n                \"currently running\",\n                \"successfully finished\",\n                \"failed to finish (exit code != 0)\",\n                \"killed by user\",\n                \"canceled but hasn't been started\",\n                \"error with the config / while parsing / some other system problem\",\n                \"waiting for approval\",\n                \"blocked and declined\",\n                \"created / internal use only\"\n            ],\n            \"x-enum-varnames\": [\n                \"StatusSkipped\",\n                \"StatusPending\",\n                \"StatusRunning\",\n                \"StatusSuccess\",\n                \"StatusFailure\",\n                \"StatusKilled\",\n                \"StatusCanceled\",\n                \"StatusError\",\n                \"StatusBlocked\",\n                \"StatusDeclined\",\n                \"StatusCreated\"\n            ]\n        },\n        \"Step\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"error\": {\n                    \"type\": \"string\"\n                },\n                \"exit_code\": {\n                    \"type\": \"integer\"\n                },\n                \"finished\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"pid\": {\n                    \"type\": \"integer\"\n                },\n                \"pipeline_id\": {\n                    \"type\": \"integer\"\n                },\n                \"ppid\": {\n                    \"type\": \"integer\"\n                },\n                \"started\": {\n                    \"type\": \"integer\"\n                },\n                \"state\": {\n                    \"$ref\": \"#/definitions/StatusValue\"\n                },\n                \"type\": {\n                    \"$ref\": \"#/definitions/StepType\"\n                },\n                \"uuid\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"StepType\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"clone\",\n                \"service\",\n                \"plugin\",\n                \"commands\",\n                \"cache\"\n            ],\n            \"x-enum-varnames\": [\n                \"StepTypeClone\",\n                \"StepTypeService\",\n                \"StepTypePlugin\",\n                \"StepTypeCommands\",\n                \"StepTypeCache\"\n            ]\n        },\n        \"Task\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"agent_id\": {\n                    \"type\": \"integer\"\n                },\n                \"dep_status\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"$ref\": \"#/definitions/StatusValue\"\n                    }\n                },\n                \"dependencies\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"labels\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"pid\": {\n                    \"type\": \"integer\"\n                },\n                \"pipeline_id\": {\n                    \"type\": \"integer\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"run_on\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        },\n        \"User\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"admin\": {\n                    \"description\": \"Admin indicates the user is a system administrator.\\n\\nNOTE: If the username is part of the WOODPECKER_ADMIN\\nenvironment variable, this value will be set to true on login.\",\n                    \"type\": \"boolean\"\n                },\n                \"avatar_url\": {\n                    \"description\": \"the avatar url for this user.\",\n                    \"type\": \"string\"\n                },\n                \"email\": {\n                    \"description\": \"Email is the email address for this user.\\n\\nrequired: true\",\n                    \"type\": \"string\"\n                },\n                \"forge_id\": {\n                    \"type\": \"integer\"\n                },\n                \"forge_remote_id\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"description\": \"the id for this user.\\n\\nrequired: true\",\n                    \"type\": \"integer\"\n                },\n                \"login\": {\n                    \"description\": \"Login is the username for this user.\\n\\nrequired: true\",\n                    \"type\": \"string\"\n                },\n                \"org_id\": {\n                    \"description\": \"OrgID is the of the user as model.Org.\",\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"WebhookEvent\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"push\",\n                \"pull_request\",\n                \"pull_request_closed\",\n                \"pull_request_metadata\",\n                \"tag\",\n                \"release\",\n                \"deployment\",\n                \"cron\",\n                \"manual\"\n            ],\n            \"x-enum-varnames\": [\n                \"EventPush\",\n                \"EventPull\",\n                \"EventPullClosed\",\n                \"EventPullMetadata\",\n                \"EventTag\",\n                \"EventRelease\",\n                \"EventDeploy\",\n                \"EventCron\",\n                \"EventManual\"\n            ]\n        },\n        \"errors.PipelineError\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"data\": {},\n                \"is_warning\": {\n                    \"type\": \"boolean\"\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"type\": {\n                    \"$ref\": \"#/definitions/errors.PipelineErrorType\"\n                }\n            }\n        },\n        \"errors.PipelineErrorType\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"linter\",\n                \"deprecation\",\n                \"compiler\",\n                \"generic\",\n                \"bad_habit\"\n            ],\n            \"x-enum-comments\": {\n                \"PipelineErrorTypeBadHabit\": \"some bad-habit error\",\n                \"PipelineErrorTypeCompiler\": \"some error with the config semantics\",\n                \"PipelineErrorTypeDeprecation\": \"using some deprecated feature\",\n                \"PipelineErrorTypeGeneric\": \"some generic error\",\n                \"PipelineErrorTypeLinter\": \"some error with the config syntax\"\n            },\n            \"x-enum-descriptions\": [\n                \"some error with the config syntax\",\n                \"using some deprecated feature\",\n                \"some error with the config semantics\",\n                \"some generic error\",\n                \"some bad-habit error\"\n            ],\n            \"x-enum-varnames\": [\n                \"PipelineErrorTypeLinter\",\n                \"PipelineErrorTypeDeprecation\",\n                \"PipelineErrorTypeCompiler\",\n                \"PipelineErrorTypeGeneric\",\n                \"PipelineErrorTypeBadHabit\"\n            ]\n        },\n        \"metadata.Author\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"email\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"metadata.Commit\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"$ref\": \"#/definitions/metadata.Author\"\n                },\n                \"branch\": {\n                    \"type\": \"string\"\n                },\n                \"changed_files\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"is_prerelease\": {\n                    \"type\": \"boolean\"\n                },\n                \"labels\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"message\": {\n                    \"type\": \"string\"\n                },\n                \"milestone\": {\n                    \"type\": \"string\"\n                },\n                \"ref\": {\n                    \"type\": \"string\"\n                },\n                \"refspec\": {\n                    \"type\": \"string\"\n                },\n                \"sha\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"metadata.Event\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"push\",\n                \"pull_request\",\n                \"pull_request_closed\",\n                \"pull_request_metadata\",\n                \"tag\",\n                \"release\",\n                \"deployment\",\n                \"cron\",\n                \"manual\"\n            ],\n            \"x-enum-varnames\": [\n                \"EventPush\",\n                \"EventPull\",\n                \"EventPullClosed\",\n                \"EventPullMetadata\",\n                \"EventTag\",\n                \"EventRelease\",\n                \"EventDeploy\",\n                \"EventCron\",\n                \"EventManual\"\n            ]\n        },\n        \"metadata.Forge\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"type\": {\n                    \"type\": \"string\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"metadata.Metadata\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"curr\": {\n                    \"$ref\": \"#/definitions/metadata.Pipeline\"\n                },\n                \"forge\": {\n                    \"$ref\": \"#/definitions/metadata.Forge\"\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"prev\": {\n                    \"$ref\": \"#/definitions/metadata.Pipeline\"\n                },\n                \"repo\": {\n                    \"$ref\": \"#/definitions/metadata.Repo\"\n                },\n                \"step\": {\n                    \"$ref\": \"#/definitions/metadata.Step\"\n                },\n                \"sys\": {\n                    \"$ref\": \"#/definitions/metadata.System\"\n                },\n                \"workflow\": {\n                    \"$ref\": \"#/definitions/metadata.Workflow\"\n                }\n            }\n        },\n        \"metadata.Pipeline\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"author\": {\n                    \"type\": \"string\"\n                },\n                \"avatar\": {\n                    \"type\": \"string\"\n                },\n                \"commit\": {\n                    \"$ref\": \"#/definitions/metadata.Commit\"\n                },\n                \"created\": {\n                    \"type\": \"integer\"\n                },\n                \"cron\": {\n                    \"type\": \"string\"\n                },\n                \"event\": {\n                    \"$ref\": \"#/definitions/metadata.Event\"\n                },\n                \"event_reason\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"finished\": {\n                    \"type\": \"integer\"\n                },\n                \"forge_url\": {\n                    \"type\": \"string\"\n                },\n                \"number\": {\n                    \"type\": \"integer\"\n                },\n                \"parent\": {\n                    \"type\": \"integer\"\n                },\n                \"started\": {\n                    \"type\": \"integer\"\n                },\n                \"status\": {\n                    \"type\": \"string\"\n                },\n                \"target\": {\n                    \"type\": \"string\"\n                },\n                \"task\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"metadata.Repo\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"clone_url\": {\n                    \"type\": \"string\"\n                },\n                \"clone_url_ssh\": {\n                    \"type\": \"string\"\n                },\n                \"default_branch\": {\n                    \"type\": \"string\"\n                },\n                \"forge_url\": {\n                    \"type\": \"string\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"owner\": {\n                    \"type\": \"string\"\n                },\n                \"private\": {\n                    \"type\": \"boolean\"\n                },\n                \"remote_id\": {\n                    \"type\": \"string\"\n                },\n                \"trusted\": {\n                    \"$ref\": \"#/definitions/metadata.TrustedConfiguration\"\n                }\n            }\n        },\n        \"metadata.Step\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"number\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"metadata.System\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"arch\": {\n                    \"type\": \"string\"\n                },\n                \"host\": {\n                    \"type\": \"string\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"url\": {\n                    \"type\": \"string\"\n                },\n                \"version\": {\n                    \"type\": \"string\"\n                }\n            }\n        },\n        \"metadata.TrustedConfiguration\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"network\": {\n                    \"type\": \"boolean\"\n                },\n                \"security\": {\n                    \"type\": \"boolean\"\n                },\n                \"volumes\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"metadata.Workflow\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"matrix\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"number\": {\n                    \"type\": \"integer\"\n                }\n            }\n        },\n        \"model.ApprovalMode\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"none\",\n                \"forks\",\n                \"pull_requests\",\n                \"all_events\"\n            ],\n            \"x-enum-comments\": {\n                \"RequireApprovalAllEvents\": \"require approval for all external events\",\n                \"RequireApprovalForks\": \"require approval for PRs from forks (default)\",\n                \"RequireApprovalNone\": \"require approval for no events\",\n                \"RequireApprovalPullRequests\": \"require approval for all PRs\"\n            },\n            \"x-enum-descriptions\": [\n                \"require approval for no events\",\n                \"require approval for PRs from forks (default)\",\n                \"require approval for all PRs\",\n                \"require approval for all external events\"\n            ],\n            \"x-enum-varnames\": [\n                \"RequireApprovalNone\",\n                \"RequireApprovalForks\",\n                \"RequireApprovalPullRequests\",\n                \"RequireApprovalAllEvents\"\n            ]\n        },\n        \"model.ForgeType\": {\n            \"type\": \"string\",\n            \"enum\": [\n                \"github\",\n                \"gitlab\",\n                \"gitea\",\n                \"forgejo\",\n                \"bitbucket\",\n                \"bitbucket-dc\",\n                \"addon\"\n            ],\n            \"x-enum-varnames\": [\n                \"ForgeTypeGithub\",\n                \"ForgeTypeGitlab\",\n                \"ForgeTypeGitea\",\n                \"ForgeTypeForgejo\",\n                \"ForgeTypeBitbucket\",\n                \"ForgeTypeBitbucketDatacenter\",\n                \"ForgeTypeAddon\"\n            ]\n        },\n        \"model.QueueTask\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"agent_id\": {\n                    \"type\": \"integer\"\n                },\n                \"agent_name\": {\n                    \"type\": \"string\"\n                },\n                \"dep_status\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"$ref\": \"#/definitions/StatusValue\"\n                    }\n                },\n                \"dependencies\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"id\": {\n                    \"type\": \"string\"\n                },\n                \"labels\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"pid\": {\n                    \"type\": \"integer\"\n                },\n                \"pipeline_id\": {\n                    \"type\": \"integer\"\n                },\n                \"pipeline_number\": {\n                    \"type\": \"integer\"\n                },\n                \"repo_id\": {\n                    \"type\": \"integer\"\n                },\n                \"run_on\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"type\": \"string\"\n                    }\n                }\n            }\n        },\n        \"model.TrustedConfiguration\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"network\": {\n                    \"type\": \"boolean\"\n                },\n                \"security\": {\n                    \"type\": \"boolean\"\n                },\n                \"volumes\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"model.TrustedConfigurationPatch\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"network\": {\n                    \"type\": \"boolean\"\n                },\n                \"security\": {\n                    \"type\": \"boolean\"\n                },\n                \"volumes\": {\n                    \"type\": \"boolean\"\n                }\n            }\n        },\n        \"model.Workflow\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"agent_id\": {\n                    \"type\": \"integer\"\n                },\n                \"children\": {\n                    \"type\": \"array\",\n                    \"items\": {\n                        \"$ref\": \"#/definitions/Step\"\n                    }\n                },\n                \"environ\": {\n                    \"type\": \"object\",\n                    \"additionalProperties\": {\n                        \"type\": \"string\"\n                    }\n                },\n                \"error\": {\n                    \"type\": \"string\"\n                },\n                \"finished\": {\n                    \"type\": \"integer\"\n                },\n                \"id\": {\n                    \"type\": \"integer\"\n                },\n                \"name\": {\n                    \"type\": \"string\"\n                },\n                \"pid\": {\n                    \"type\": \"integer\"\n                },\n                \"pipeline_id\": {\n                    \"type\": \"integer\"\n                },\n                \"platform\": {\n                    \"type\": \"string\"\n                },\n                \"started\": {\n                    \"type\": \"integer\"\n                },\n                \"state\": {\n                    \"$ref\": \"#/definitions/StatusValue\"\n                }\n            }\n        }\n    }\n}`\n\n// SwaggerInfo holds exported Swagger Info so clients can modify it\nvar SwaggerInfo = &swag.Spec{\n\tVersion:          \"\",\n\tHost:             \"\",\n\tBasePath:         \"/api\",\n\tSchemes:          []string{},\n\tTitle:            \"Woodpecker CI API\",\n\tDescription:      \"Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.\\nTo get a personal access token (PAT) for authentication, please log in your Woodpecker server,\\nand go to you personal profile page, by clicking the user icon at the top right.\",\n\tInfoInstanceName: \"swagger\",\n\tSwaggerTemplate:  docTemplate,\n\tLeftDelim:        \"{{\",\n\tRightDelim:       \"}}\",\n}\n\nfunc init() {\n\tswag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)\n}\n"
  },
  {
    "path": "cmd/server/openapi.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// Generate docs/openapi.json via:\n//go:generate go run github.com/swaggo/swag/cmd/swag init -g cmd/server/openapi.go --outputTypes go -output openapi -d ../../\n//go:generate go run openapi_json_gen.go openapi.go\n//go:generate go run github.com/getkin/kin-openapi/cmd/validate ../../docs/openapi.json\n\n// setupOpenAPIStaticConfig initializes static content (version) for the OpenAPI config.\n//\n//\t@title\t\t\tWoodpecker CI API\n//\t@description\tWoodpecker is a simple, yet powerful CI/CD engine with great extensibility.\n//\t@description\tTo get a personal access token (PAT) for authentication, please log in your Woodpecker server,\n//\t@description\tand go to you personal profile page, by clicking the user icon at the top right.\n//\t@BasePath\t\t/api\n//\t@contact.name\tWoodpecker CI\n//\t@contact.url\thttps://woodpecker-ci.org/\nfunc setupOpenAPIStaticConfig() {\n\topenapi.SwaggerInfo.Version = version.String()\n}\n"
  },
  {
    "path": "cmd/server/openapi_json_gen.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// *********************************************************\n// This is a generator tool, to update the openapi.json file\n// *********************************************************\n\n//go:build generate\n\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path\"\n\n\t\"github.com/getkin/kin-openapi/openapi2\"\n\t\"github.com/getkin/kin-openapi/openapi2conv\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n)\n\nfunc main() {\n\t// set openapi infos\n\tsetupOpenAPIStaticConfig()\n\n\tbasePath := path.Join(\"..\", \"..\")\n\tfilePath := path.Join(basePath, \"docs\", \"openapi.json\")\n\n\t// generate openapi file\n\tf, err := os.Create(filePath)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer f.Close()\n\tdoc := openapi.SwaggerInfo.ReadDoc()\n\tdoc, err = removeHost(doc)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\t_, err = f.WriteString(doc)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tfmt.Println(\"generated openapi.json\")\n\n\t// convert to OpenApi3\n\tif err := toOpenApi3(filePath, filePath); err != nil {\n\t\tfmt.Printf(\"converting '%s' from openapi v2 to v3 failed\\n\", filePath)\n\t\tpanic(err)\n\t}\n}\n\nfunc removeHost(jsonIn string) (string, error) {\n\tm := make(map[string]interface{})\n\tif err := json.Unmarshal([]byte(jsonIn), &m); err != nil {\n\t\treturn \"\", err\n\t}\n\tdelete(m, \"host\")\n\traw, err := json.Marshal(m)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(raw), nil\n}\n\nfunc toOpenApi3(input, output string) error {\n\tdata2, err := os.ReadFile(input)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read input: %w\", err)\n\t}\n\n\tvar doc2 openapi2.T\n\terr = json.Unmarshal(data2, &doc2)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unmarshal input: %w\", err)\n\t}\n\n\tdoc3, err := openapi2conv.ToV3(&doc2)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"convert openapi v2 to v3: %w\", err)\n\t}\n\terr = doc3.Validate(context.Background())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdata, err := json.Marshal(doc3)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Marshal converted: %w\", err)\n\t}\n\n\tif err = os.WriteFile(output, data, 0o644); err != nil {\n\t\treturn fmt.Errorf(\"write output: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/server/openapi_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n)\n\nfunc TestSetupOpenApiStaticConfig(t *testing.T) {\n\tsetupOpenAPIStaticConfig()\n\tassert.Equal(t, \"/api\", openapi.SwaggerInfo.BasePath)\n}\n"
  },
  {
    "path": "cmd/server/server.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"golang.org/x/sync/errgroup\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tcron_scheduler \"go.woodpecker-ci.org/woodpecker/v3/server/cron\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/web\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nconst (\n\tshutdownTimeout = time.Second * 5\n)\n\nvar (\n\tstopServerFunc     context.CancelCauseFunc = func(error) {}\n\tshutdownCancelFunc context.CancelFunc      = func() {}\n\tshutdownCtx                                = context.Background()\n)\n\nfunc run(ctx context.Context, c *cli.Command) error {\n\tif err := logger.SetupGlobalLogger(ctx, c, true); err != nil {\n\t\treturn err\n\t}\n\n\tctx, ctxCancel := context.WithCancelCause(ctx)\n\tstopServerFunc = func(err error) {\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"shutdown of whole server\")\n\t\t}\n\t\tstopServerFunc = func(error) {}\n\t\tshutdownCtx, shutdownCancelFunc = context.WithTimeout(shutdownCtx, shutdownTimeout)\n\t\tctxCancel(err)\n\t}\n\tdefer stopServerFunc(nil)\n\tdefer shutdownCancelFunc()\n\n\t// set gin mode based on log level\n\tif zerolog.GlobalLevel() > zerolog.DebugLevel {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\tif c.String(\"server-host\") == \"\" {\n\t\treturn fmt.Errorf(\"WOODPECKER_HOST is not properly configured\")\n\t}\n\n\tif !strings.Contains(c.String(\"server-host\"), \"://\") {\n\t\treturn fmt.Errorf(\"WOODPECKER_HOST must be <scheme>://<hostname> format\")\n\t}\n\n\tif _, err := url.Parse(c.String(\"server-host\")); err != nil {\n\t\treturn fmt.Errorf(\"could not parse WOODPECKER_HOST: %w\", err)\n\t}\n\n\tif strings.Contains(c.String(\"server-host\"), \"://localhost\") {\n\t\tlog.Warn().Msg(\n\t\t\t\"WOODPECKER_HOST should probably be publicly accessible (not localhost)\",\n\t\t)\n\t}\n\n\t_store, err := backoff.Retry(ctx,\n\t\tfunc() (store.Store, error) {\n\t\t\treturn setupStore(ctx, c)\n\t\t},\n\t\tbackoff.WithBackOff(backoff.NewExponentialBackOff()),\n\t\tbackoff.WithMaxTries(c.Uint(\"db-max-retries\")),\n\t\tbackoff.WithNotify(func(err error, delay time.Duration) {\n\t\t\tlog.Error().Msgf(\"failed to setup store: %v: retry in %v\", err, delay)\n\t\t}))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() {\n\t\tif err := _store.Close(); err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"could not close store\")\n\t\t}\n\t}()\n\n\terr = setupEvilGlobals(ctx, c, _store)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't setup globals: %w\", err)\n\t}\n\n\t// wait for all services until one do stops with an error\n\tserviceWaitingGroup := errgroup.Group{}\n\n\tlog.Info().Msgf(\"starting Woodpecker server with version '%s'\", version.String())\n\n\tstartMetricsCollector(ctx, _store)\n\n\tserviceWaitingGroup.Go(func() error {\n\t\tlog.Info().Msg(\"starting cron service ...\")\n\t\tif err := cron_scheduler.Run(ctx, _store); err != nil {\n\t\t\tgo stopServerFunc(err)\n\t\t\treturn err\n\t\t}\n\t\tlog.Info().Msg(\"cron service stopped\")\n\t\treturn nil\n\t})\n\n\t// start the grpc server\n\tserviceWaitingGroup.Go(func() error {\n\t\tlog.Info().Msg(\"starting grpc server ...\")\n\t\tif err := runGrpcServer(ctx, c, _store); err != nil {\n\t\t\t// stop whole server as grpc is essential\n\t\t\tgo stopServerFunc(err)\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\n\tproxyWebUI := c.String(\"www-proxy\")\n\tvar webUIServe func(w http.ResponseWriter, r *http.Request)\n\n\tif proxyWebUI == \"\" {\n\t\twebEngine, err := web.New()\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"failed to create web engine\")\n\t\t\treturn err\n\t\t}\n\t\twebUIServe = webEngine.ServeHTTP\n\t} else {\n\t\torigin, _ := url.Parse(proxyWebUI)\n\n\t\tdirector := func(req *http.Request) {\n\t\t\treq.Header.Add(\"X-Forwarded-Host\", req.Host)\n\t\t\treq.Header.Add(\"X-Origin-Host\", origin.Host)\n\t\t\treq.URL.Scheme = origin.Scheme\n\t\t\treq.URL.Host = origin.Host\n\t\t}\n\n\t\tproxy := &httputil.ReverseProxy{Director: director}\n\t\twebUIServe = proxy.ServeHTTP\n\t}\n\n\t// setup the server and start the listener\n\thandler := router.Load(\n\t\twebUIServe,\n\t\tmiddleware.Logger(time.RFC3339, true),\n\t\tmiddleware.Version,\n\t\tmiddleware.Store(_store),\n\t)\n\n\tif c.String(\"server-cert\") != \"\" {\n\t\t// start the server with tls enabled\n\t\tserviceWaitingGroup.Go(func() error {\n\t\t\ttlsServer := &http.Server{\n\t\t\t\tAddr:    server.Config.Server.PortTLS,\n\t\t\t\tHandler: handler,\n\t\t\t\tTLSConfig: &tls.Config{\n\t\t\t\t\tNextProtos: []string{\"h2\", \"http/1.1\"},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tgo func() {\n\t\t\t\t<-ctx.Done()\n\t\t\t\tlog.Info().Msg(\"shutdown tls server ...\")\n\t\t\t\tif err := tlsServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck\n\t\t\t\t\tlog.Error().Err(err).Msg(\"shutdown tls server failed\")\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info().Msg(\"tls server stopped\")\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tlog.Info().Msg(\"starting tls server ...\")\n\t\t\terr := tlsServer.ListenAndServeTLS(\n\t\t\t\tc.String(\"server-cert\"),\n\t\t\t\tc.String(\"server-key\"),\n\t\t\t)\n\t\t\tif err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\tlog.Error().Err(err).Msg(\"TLS server failed\")\n\t\t\t\tstopServerFunc(fmt.Errorf(\"TLS server failed: %w\", err))\n\t\t\t}\n\t\t\treturn err\n\t\t})\n\n\t\t// http to https redirect\n\t\tredirect := func(w http.ResponseWriter, req *http.Request) {\n\t\t\tserverURL, _ := url.Parse(server.Config.Server.Host)\n\t\t\treq.URL.Scheme = \"https\"\n\t\t\treq.URL.Host = serverURL.Host\n\n\t\t\tw.Header().Set(\"Strict-Transport-Security\", \"max-age=31536000\")\n\n\t\t\thttp.Redirect(w, req, req.URL.String(), http.StatusMovedPermanently)\n\t\t}\n\n\t\tserviceWaitingGroup.Go(func() error {\n\t\t\tredirectServer := &http.Server{\n\t\t\t\tAddr:    server.Config.Server.Port,\n\t\t\t\tHandler: http.HandlerFunc(redirect),\n\t\t\t}\n\t\t\tgo func() {\n\t\t\t\t<-ctx.Done()\n\t\t\t\tlog.Info().Msg(\"shutdown redirect server ...\")\n\t\t\t\tif err := redirectServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck\n\t\t\t\t\tlog.Error().Err(err).Msg(\"shutdown redirect server failed\")\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info().Msg(\"redirect server stopped\")\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tlog.Info().Msg(\"starting redirect server ...\")\n\t\t\tif err := redirectServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\tlog.Error().Err(err).Msg(\"redirect server failed\")\n\t\t\t\tstopServerFunc(fmt.Errorf(\"redirect server failed: %w\", err))\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t} else {\n\t\t// start the server without tls\n\t\tserviceWaitingGroup.Go(func() error {\n\t\t\thttpServer := &http.Server{\n\t\t\t\tAddr:    c.String(\"server-addr\"),\n\t\t\t\tHandler: handler,\n\t\t\t}\n\n\t\t\tgo func() {\n\t\t\t\t<-ctx.Done()\n\t\t\t\tlog.Info().Msg(\"shutdown http server ...\")\n\t\t\t\tif err := httpServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck\n\t\t\t\t\tlog.Error().Err(err).Msg(\"shutdown http server failed\")\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info().Msg(\"http server stopped\")\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tlog.Info().Msg(\"starting http server ...\")\n\t\t\tif err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\tlog.Error().Err(err).Msg(\"http server failed\")\n\t\t\t\tstopServerFunc(fmt.Errorf(\"http server failed: %w\", err))\n\t\t\t}\n\t\t\treturn err\n\t\t})\n\t}\n\n\tif metricsServerAddr := c.String(\"metrics-server-addr\"); metricsServerAddr != \"\" {\n\t\tserviceWaitingGroup.Go(func() error {\n\t\t\tmetricsRouter := gin.New()\n\t\t\tmetricsRouter.GET(\"/metrics\", gin.WrapH(promhttp.Handler()))\n\n\t\t\tmetricsServer := &http.Server{\n\t\t\t\tAddr:    metricsServerAddr,\n\t\t\t\tHandler: metricsRouter,\n\t\t\t}\n\n\t\t\tgo func() {\n\t\t\t\t<-ctx.Done()\n\t\t\t\tlog.Info().Msg(\"shutdown metrics server ...\")\n\t\t\t\tif err := metricsServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck\n\t\t\t\t\tlog.Error().Err(err).Msg(\"shutdown metrics server failed\")\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info().Msg(\"metrics server stopped\")\n\t\t\t\t}\n\t\t\t}()\n\n\t\t\tlog.Info().Msg(\"starting metrics server ...\")\n\t\t\tif err := metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\t\tlog.Error().Err(err).Msg(\"metrics server failed\")\n\t\t\t\tstopServerFunc(fmt.Errorf(\"metrics server failed: %w\", err))\n\t\t\t}\n\t\t\treturn err\n\t\t})\n\t}\n\n\treturn serviceWaitingGroup.Wait()\n}\n"
  },
  {
    "path": "cmd/server/setup.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/base32\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/cache\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/setup\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services\"\n\tservice_log \"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/log/addon\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/log/file\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/datastore\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nconst (\n\tqueueInfoRefreshInterval = 500 * time.Millisecond\n\tstoreInfoRefreshInterval = 10 * time.Second\n)\n\nfunc setupStore(ctx context.Context, c *cli.Command) (store.Store, error) {\n\tdatasource := c.String(\"db-datasource\")\n\tdriver := c.String(\"db-driver\")\n\txorm := store.XORM{\n\t\tLog:             c.Bool(\"db-log\"),\n\t\tShowSQL:         c.Bool(\"db-log-sql\"),\n\t\tMaxOpenConns:    c.Int(\"db-max-open-connections\"),\n\t\tMaxIdleConns:    c.Int(\"db-max-idle-connections\"),\n\t\tConnMaxLifetime: c.Duration(\"db-max-connection-timeout\"),\n\t}\n\n\tif driver == \"sqlite3\" {\n\t\tif datastore.SupportedDriver(\"sqlite3\") {\n\t\t\tlog.Debug().Msg(\"server has sqlite3 support\")\n\t\t} else {\n\t\t\tlog.Debug().Msg(\"server was built without sqlite3 support!\")\n\t\t}\n\t}\n\n\tif !datastore.SupportedDriver(driver) {\n\t\treturn nil, fmt.Errorf(\"database driver '%s' not supported\", driver)\n\t}\n\n\tif driver == \"sqlite3\" {\n\t\tif err := checkSqliteFileExist(datasource); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"check sqlite file: %w\", err)\n\t\t}\n\t}\n\n\topts := &store.Opts{\n\t\tDriver: driver,\n\t\tConfig: datasource,\n\t\tXORM:   xorm,\n\t}\n\tlog.Debug().Str(\"driver\", driver).Any(\"xorm\", xorm).Msg(\"setting up datastore\")\n\tstore, err := datastore.NewEngine(opts)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not open datastore: %w\", err)\n\t}\n\n\tif err = store.Ping(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := store.Migrate(ctx, c.Bool(\"migrations-allow-long\")); err != nil {\n\t\treturn nil, fmt.Errorf(\"could not migrate datastore: %w\", err)\n\t}\n\n\treturn store, nil\n}\n\nfunc checkSqliteFileExist(path string) error {\n\t_, err := os.Stat(path)\n\tif err != nil && os.IsNotExist(err) {\n\t\tlog.Warn().Msgf(\"no sqlite3 file found, will create one at '%s'\", path)\n\t\treturn nil\n\t}\n\treturn err\n}\n\nfunc setupQueue(ctx context.Context, s store.Store) (queue.Queue, error) {\n\treturn queue.New(ctx, queue.Config{\n\t\tBackend: queue.TypeMemory,\n\t\tStore:   s,\n\t})\n}\n\nfunc setupMembershipService(_ context.Context, _store store.Store) cache.MembershipService {\n\treturn cache.NewMembershipService(_store)\n}\n\nfunc setupLogStore(c *cli.Command, s store.Store) (service_log.Service, error) {\n\tswitch c.String(\"log-store\") {\n\tcase \"file\":\n\t\treturn file.NewLogStore(c.String(\"log-store-file-path\"))\n\tcase \"addon\":\n\t\treturn addon.Load(c.String(\"log-store-file-path\"))\n\tdefault:\n\t\treturn s, nil\n\t}\n}\n\nconst jwtSecretID = \"jwt-secret\"\n\nfunc setupJWTSecret(_store store.Store) (string, error) {\n\tjwtSecret, err := _store.ServerConfigGet(jwtSecretID)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\tjwtSecret := base32.StdEncoding.EncodeToString(\n\t\t\trandom.GetRandomBytes(32),\n\t\t)\n\t\terr = _store.ServerConfigSet(jwtSecretID, jwtSecret)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tlog.Debug().Msg(\"created jwt secret\")\n\t\treturn jwtSecret, nil\n\t}\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn jwtSecret, nil\n}\n\nfunc setupEvilGlobals(ctx context.Context, c *cli.Command, s store.Store) (err error) {\n\t// services\n\tserver.Config.Services.Logs = logging.New()\n\tserver.Config.Services.Membership = setupMembershipService(ctx, s)\n\tpubsub := memory.New()\n\tqueue, err := setupQueue(ctx, s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not setup queue: %w\", err)\n\t}\n\tserver.Config.Services.Scheduler = scheduler.NewScheduler(queue, pubsub)\n\tserver.Config.Services.Manager, err = services.NewManager(c, s, setup.Forge)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not setup service manager: %w\", err)\n\t}\n\tserver.Config.Services.LogStore, err = setupLogStore(c, s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not setup log store: %w\", err)\n\t}\n\n\t// agents\n\tserver.Config.Agent.DisableUserRegisteredAgentRegistration = c.Bool(\"disable-user-agent-registration\")\n\n\t// authentication\n\tserver.Config.Pipeline.AuthenticatePublicRepos = c.Bool(\"authenticate-public-repos\")\n\n\t// Pull requests\n\tserver.Config.Pipeline.DefaultAllowPullRequests = c.Bool(\"default-allow-pull-requests\")\n\n\t// Approval mode\n\tapprovalMode := model.ApprovalMode(c.String(\"default-approval-mode\"))\n\tif !approvalMode.Valid() {\n\t\treturn fmt.Errorf(\"approval mode %s is not valid\", approvalMode)\n\t}\n\tserver.Config.Pipeline.DefaultApprovalMode = approvalMode\n\n\t// Cloning\n\tserver.Config.Pipeline.DefaultClonePlugin = c.String(\"default-clone-plugin\")\n\tserver.Config.Pipeline.TrustedClonePlugins = c.StringSlice(\"plugins-trusted-clone\")\n\tserver.Config.Pipeline.TrustedClonePlugins = append(server.Config.Pipeline.TrustedClonePlugins, server.Config.Pipeline.DefaultClonePlugin)\n\n\t// Execution\n\t_events := c.StringSlice(\"default-cancel-previous-pipeline-events\")\n\tevents := make([]model.WebhookEvent, 0, len(_events))\n\tfor _, v := range _events {\n\t\te := model.WebhookEvent(v)\n\t\tif err := e.Validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tevents = append(events, e)\n\t}\n\tserver.Config.Pipeline.DefaultCancelPreviousPipelineEvents = events\n\tserver.Config.Pipeline.DefaultTimeout = c.Int64(\"default-pipeline-timeout\")\n\tserver.Config.Pipeline.MaxTimeout = c.Int64(\"max-pipeline-timeout\")\n\n\t_labels := c.StringSlice(\"default-workflow-labels\")\n\tlabels := make(map[string]string, len(_labels))\n\tfor _, v := range _labels {\n\t\tname, value, ok := strings.Cut(v, \"=\")\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"invalid label filter: %s\", v)\n\t\t}\n\t\tlabels[name] = value\n\t}\n\tserver.Config.Pipeline.DefaultWorkflowLabels = labels\n\n\t// backend options for pipeline compiler\n\tserver.Config.Pipeline.Proxy.No = c.String(\"backend-no-proxy\")\n\tserver.Config.Pipeline.Proxy.HTTP = c.String(\"backend-http-proxy\")\n\tserver.Config.Pipeline.Proxy.HTTPS = c.String(\"backend-https-proxy\")\n\n\t// server configuration\n\tserver.Config.Server.JWTSecret, err = setupJWTSecret(s)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not setup jwt secret: %w\", err)\n\t}\n\tserver.Config.Server.Cert = c.String(\"server-cert\")\n\tserver.Config.Server.Key = c.String(\"server-key\")\n\tserver.Config.Server.AgentToken = c.String(\"agent-secret\")\n\tserverHost := strings.TrimSuffix(c.String(\"server-host\"), \"/\")\n\tserver.Config.Server.Host = serverHost\n\tif c.IsSet(\"server-webhook-host\") {\n\t\tserver.Config.Server.WebhookHost = c.String(\"server-webhook-host\")\n\t} else {\n\t\tserver.Config.Server.WebhookHost = serverHost\n\t}\n\tserver.Config.Server.OAuthHost = serverHost\n\tserver.Config.Server.Port = c.String(\"server-addr\")\n\tserver.Config.Server.PortTLS = c.String(\"server-addr-tls\")\n\tserver.Config.Server.StatusContext = c.String(\"status-context\")\n\tserver.Config.Server.StatusContextFormat = c.String(\"status-context-format\")\n\tserver.Config.Server.SessionExpires = c.Duration(\"session-expires\")\n\tu, _ := url.Parse(server.Config.Server.Host)\n\trootPath := strings.TrimSuffix(u.Path, \"/\")\n\tif rootPath != \"\" && !strings.HasPrefix(rootPath, \"/\") {\n\t\trootPath = \"/\" + rootPath\n\t}\n\tserver.Config.Server.RootPath = rootPath\n\tserver.Config.Server.CustomCSSFile = strings.TrimSpace(c.String(\"custom-css-file\"))\n\tserver.Config.Server.CustomJsFile = strings.TrimSpace(c.String(\"custom-js-file\"))\n\tserver.Config.Pipeline.Networks = c.StringSlice(\"network\")\n\tserver.Config.Pipeline.Volumes = c.StringSlice(\"volume\")\n\tserver.Config.WebUI.EnableSwagger = c.Bool(\"enable-swagger\")\n\tserver.Config.WebUI.SkipVersionCheck = c.Bool(\"skip-version-check\")\n\tserver.Config.WebUI.MaxPipelineLogLineCount = c.Uint(\"max-pipeline-log-line-count\")\n\tserver.Config.Pipeline.PrivilegedPlugins = c.StringSlice(\"plugins-privileged\")\n\n\t// TODO: remove with version 4.x\n\tserver.Config.Pipeline.ForceIgnoreServiceFailure = c.Bool(\"force-ignore-service-failure\")\n\tif server.Config.Pipeline.ForceIgnoreServiceFailure {\n\t\tlog.Info().Msg(\"WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE is true by default. To prepare for v4.0.0, set it to false and update your pipeline definitions if needed.\")\n\t}\n\n\t// prometheus\n\tserver.Config.Prometheus.AuthToken = c.String(\"prometheus-auth-token\")\n\n\t// permissions\n\tserver.Config.Permissions.Open = c.Bool(\"open\")\n\tserver.Config.Permissions.Admins = permissions.NewAdmins(c.StringSlice(\"admin\"))\n\tserver.Config.Permissions.Orgs = permissions.NewOrgs(c.StringSlice(\"orgs\"))\n\tserver.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist(c.StringSlice(\"repo-owners\"))\n\treturn nil\n}\n"
  },
  {
    "path": "codecov.yaml",
    "content": "ignore:\n  - '**/mocks/mock_*.go'\n  - '**/fixtures/*.go'\n  - 'e2e/**/*.go'\n"
  },
  {
    "path": "contrib/woodpecker-test-repo/.woodpecker/demo.yaml",
    "content": "steps:\n  demo:\n    image: 'alpine'\n    commands:\n      - echo 'Demo'\n"
  },
  {
    "path": "contrib/woodpecker-test-repo/.woodpecker/test.yaml",
    "content": "steps:\n  test_1:\n    image: 'alpine'\n    commands:\n      - echo 'Test 1'\n\n  test_2:\n    image: 'alpine'\n    commands:\n      - echo 'Test 2'\n\n  test_3:\n    image: 'alpine'\n    commands:\n      - echo 'Test 3'\n"
  },
  {
    "path": "docker/Dockerfile.agent.alpine.multiarch",
    "content": "FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build\n\nWORKDIR /src\nCOPY . .\nARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH\nRUN --mount=type=cache,target=/root/.cache/go-build \\\n    --mount=type=cache,target=/go/pkg \\\n    make build-agent\n\nFROM docker.io/alpine:3.23\n\nRUN apk add -U --no-cache ca-certificates && \\\n  adduser -u 1000 -g 1000 woodpecker -D && \\\n  mkdir -p /etc/woodpecker && \\\n  chown -R woodpecker:woodpecker /etc/woodpecker\n\nENV GODEBUG=netdns=go\n# Internal setting do NOT change! Signals that woodpecker is running inside a container\nENV WOODPECKER_IN_CONTAINER=true\nEXPOSE 3000\n\nCOPY --from=build /src/dist/woodpecker-agent /bin/\n\nHEALTHCHECK CMD [\"/bin/woodpecker-agent\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-agent\"]\n"
  },
  {
    "path": "docker/Dockerfile.agent.multiarch",
    "content": "FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build\n\nRUN groupadd -g 1000 woodpecker && \\\n  useradd -u 1000 -g 1000 woodpecker && \\\n  mkdir -p /etc/woodpecker\n\nWORKDIR /src\nCOPY . .\nARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH\nRUN --mount=type=cache,target=/root/.cache/go-build \\\n    --mount=type=cache,target=/go/pkg \\\n    make build-agent\n\nFROM scratch\nENV GODEBUG=netdns=go\n# Internal setting do NOT change! Signals that woodpecker is running inside a container\nENV WOODPECKER_IN_CONTAINER=true\nEXPOSE 3000\n\n# copy certs from build image\nCOPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n# copy agent binary\nCOPY --from=build /src/dist/woodpecker-agent /bin/\nCOPY --from=build --chown=woodpecker:woodpecker /etc/woodpecker /etc\nCOPY --from=build /etc/passwd /etc/passwd\nCOPY --from=build /etc/group /etc/group\n\nHEALTHCHECK CMD [\"/bin/woodpecker-agent\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-agent\"]\n"
  },
  {
    "path": "docker/Dockerfile.cli.alpine.multiarch.rootless",
    "content": "FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build\n\nWORKDIR /src\nCOPY . .\nARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH\nRUN --mount=type=cache,target=/root/.cache/go-build \\\n    --mount=type=cache,target=/go/pkg \\\n    make build-cli\n\nFROM docker.io/alpine:3.23\n\nWORKDIR /woodpecker\n\nRUN apk add -U --no-cache ca-certificates && \\\n  adduser -u 1000 -g 1000 -D woodpecker\n\nENV GODEBUG=netdns=go\nENV WOODPECKER_DISABLE_UPDATE_CHECK=true\n\nCOPY --from=build /src/dist/woodpecker-cli /bin/\n\nUSER woodpecker\n\nHEALTHCHECK CMD [\"/bin/woodpecker-cli\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-cli\"]\n"
  },
  {
    "path": "docker/Dockerfile.cli.multiarch.rootless",
    "content": "FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build\n\nRUN groupadd -g 1000 woodpecker && \\\n  useradd -u 1000 -g 1000 woodpecker\n\nWORKDIR /src\nCOPY . .\nARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH\nRUN --mount=type=cache,target=/root/.cache/go-build \\\n    --mount=type=cache,target=/go/pkg \\\n    make build-cli\n\nFROM scratch\nWORKDIR /woodpecker\n\nENV GODEBUG=netdns=go\nENV WOODPECKER_DISABLE_UPDATE_CHECK=true\n\n# copy certs from build image\nCOPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n# copy cli binary\nCOPY --from=build /src/dist/woodpecker-cli /bin/\nCOPY --from=build /etc/passwd /etc/passwd\nCOPY --from=build /etc/group /etc/group\n\nUSER woodpecker\n\nHEALTHCHECK CMD [\"/bin/woodpecker-cli\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-cli\"]\n"
  },
  {
    "path": "docker/Dockerfile.make",
    "content": "# docker build --rm -f docker/Dockerfile.make -t woodpecker/make:local .\nFROM docker.io/golang:1.26-alpine AS golang_image\nFROM docker.io/node:24-alpine\n\nRUN apk add --no-cache --update make gcc binutils-gold musl-dev && \\\n    apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main protoc && \\\n  corepack enable\n\n# Build packages.\nCOPY --from=golang_image /usr/local/go /usr/local/go\nCOPY Makefile /\nENV PATH=$PATH:/usr/local/go/bin\nENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0\nENV COREPACK_ENABLE_AUTO_PIN=0\n\n# Cache tools\nRUN GOBIN=/usr/local/go/bin make install-tools && \\\n    rm -rf /Makefile\n\nENV GOPATH=/tmp/go\nENV HOME=/tmp/home\nENV PATH=$PATH:/usr/local/go/bin:/tmp/go/bin\n\nWORKDIR /build\nRUN chmod -R 777 /root\n\nCMD [ \"/bin/sh\" ]\n"
  },
  {
    "path": "docker/Dockerfile.server.alpine.multiarch.rootless",
    "content": "FROM docker.io/alpine:3.23\n\nARG TARGETOS TARGETARCH\nRUN apk add -U --no-cache ca-certificates && \\\n  adduser -u 1000 -g 1000 woodpecker -D && \\\n  mkdir -p /var/lib/woodpecker && \\\n  chown -R woodpecker:woodpecker /var/lib/woodpecker\n\nENV GODEBUG=netdns=go\n# Internal setting do NOT change! Signals that woodpecker is running inside a container\nENV WOODPECKER_IN_CONTAINER=true\nENV XDG_CACHE_HOME=/var/lib/woodpecker\nENV XDG_DATA_HOME=/var/lib/woodpecker\nEXPOSE 8000 9000 80 443\n\nCOPY dist/server/${TARGETOS}_${TARGETARCH}/woodpecker-server /bin/\n\nUSER woodpecker\n\nHEALTHCHECK CMD [\"/bin/woodpecker-server\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-server\"]\n"
  },
  {
    "path": "docker/Dockerfile.server.multiarch.rootless",
    "content": "FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build\n\nRUN groupadd -g 1000 woodpecker && \\\n  useradd -u 1000 -g 1000 woodpecker && \\\n  mkdir -p /var/lib/woodpecker\n\nFROM scratch\nARG TARGETOS TARGETARCH\nENV GODEBUG=netdns=go\n# Internal setting do NOT change! Signals that woodpecker is running inside a container\nENV WOODPECKER_IN_CONTAINER=true\nENV XDG_CACHE_HOME=/var/lib/woodpecker\nENV XDG_DATA_HOME=/var/lib/woodpecker\nEXPOSE 8000 9000 80 443\n\n# copy certs from certs image\nCOPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n# copy server binary\nCOPY dist/server/${TARGETOS}_${TARGETARCH}/woodpecker-server /bin/\nCOPY --from=build /etc/passwd /etc/passwd\nCOPY --from=build /etc/group /etc/group\nCOPY --from=build --chown=woodpecker:woodpecker /var/lib/woodpecker /var/lib/woodpecker\n\nUSER woodpecker\n\nHEALTHCHECK CMD [\"/bin/woodpecker-server\", \"ping\"]\nENTRYPOINT [\"/bin/woodpecker-server\"]\n"
  },
  {
    "path": "docker-compose.example.yaml",
    "content": "version: '3'\n\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:v3\n    ports:\n      - 8000:8000\n    networks:\n      - woodpecker\n    volumes:\n      - /var/lib/woodpecker:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=laszlocph\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n  woodpecker-agent:\n    depends_on:\n      woodpecker-server:\n        condition: service_healthy\n    image: woodpeckerci/woodpecker-agent:v3\n    networks:\n      - woodpecker\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n      - WOODPECKER_MAX_WORKFLOWS=2\n\nnetworks:\n  woodpecker:\n"
  },
  {
    "path": "docker-compose.gitpod.yaml",
    "content": "# cSpell:ignore pgdata pgsql localtime\nversion: '3'\n\nservices:\n  gitea-database:\n    image: postgres:18.3-alpine\n    environment:\n      POSTGRES_USER: gitea\n      POSTGRES_PASSWORD: 123456\n      POSTGRES_DB: gitea\n      PGDATA: /var/lib/postgresql/data/pgdata\n    volumes:\n      - pgsql:/var/lib/postgresql/data/pgdata\n\n  gitea:\n    image: gitea/gitea:1.26\n    ports:\n      - 3000:3000\n    volumes:\n      - gitea:/data\n      - /etc/timezone:/etc/timezone:ro\n      - /etc/localtime:/etc/localtime:ro\n    depends_on:\n      - gitea-database\n    environment:\n      USER_UID: 1000\n      USER_GID: 1000\n      # GITEA__server__DOMAIN: gitea.local.self\n      GITEA__server__ROOT_URL: https://3000-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST}\n      GITEA__database__DB_TYPE: postgres\n      GITEA__database__HOST: gitea-database:5432\n      GITEA__database__NAME: gitea\n      GITEA__database__USER: gitea\n      GITEA__database__PASSWD: 123456\n      GITEA__webhook__ALLOWED_HOST_LIST: '*'\n      GITEA__security__INSTALL_LOCK: 'true'\n      GITEA__security__INTERNAL_TOKEN: '123456'\n    extra_hosts:\n      - 'host.docker.internal:host-gateway'\n\nvolumes:\n  gitea:\n  pgsql:\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "docs/.prettierignore",
    "content": "pnpm-lock.yaml\ndist\nLICENSE\nopenapi.json\n40-cli.md\nbuild/\n"
  },
  {
    "path": "docs/.prettierrc.js",
    "content": "const config = require('../.prettierrc.json');\n\nmodule.exports = {\n  ...config,\n  plugins: ['@ianvs/prettier-plugin-sort-imports'],\n  importOrder: [\n    '<THIRD_PARTY_MODULES>', // Imports not matched by other special words or groups.\n    '', // Empty string will match any import not matched by other special words or groups.\n    '^(#|@|~|\\\\$)(/.*)$',\n    '',\n    '^[./]',\n  ],\n};\n"
  },
  {
    "path": "docs/LICENSE",
    "content": "Files in this folder are licensed under Creative Commons Attribution-ShareAlike 4.0 International Public License.\nIt is a derivative work of the https://github.com/drone/docs git repository.\n\nAttribution-ShareAlike 4.0 International\n\n=======================================================================\n\nCreative Commons Corporation (\"Creative Commons\") is not a law firm and\ndoes not provide legal services or legal advice. Distribution of\nCreative Commons public licenses does not create a lawyer-client or\nother relationship. Creative Commons makes its licenses and related\ninformation available on an \"as-is\" basis. Creative Commons gives no\nwarranties regarding its licenses, any material licensed under their\nterms and conditions, or any related information. Creative Commons\ndisclaims all liability for damages resulting from their use to the\nfullest extent possible.\n\nUsing Creative Commons Public Licenses\n\nCreative Commons public licenses provide a standard set of terms and\nconditions that creators and other rights holders may use to share\noriginal works of authorship and other material subject to copyright\nand certain other rights specified in the public license below. The\nfollowing considerations are for informational purposes only, are not\nexhaustive, and do not form part of our licenses.\n\n     Considerations for licensors: Our public licenses are\n     intended for use by those authorized to give the public\n     permission to use material in ways otherwise restricted by\n     copyright and certain other rights. Our licenses are\n     irrevocable. Licensors should read and understand the terms\n     and conditions of the license they choose before applying it.\n     Licensors should also secure all rights necessary before\n     applying our licenses so that the public can reuse the\n     material as expected. Licensors should clearly mark any\n     material not subject to the license. This includes other CC-\n     licensed material, or material used under an exception or\n     limitation to copyright. More considerations for licensors:\n    wiki.creativecommons.org/Considerations_for_licensors\n\n     Considerations for the public: By using one of our public\n     licenses, a licensor grants the public permission to use the\n     licensed material under specified terms and conditions. If\n     the licensor's permission is not necessary for any reason--for\n     example, because of any applicable exception or limitation to\n     copyright--then that use is not regulated by the license. Our\n     licenses grant only permissions under copyright and certain\n     other rights that a licensor has authority to grant. Use of\n     the licensed material may still be restricted for other\n     reasons, including because others have copyright or other\n     rights in the material. A licensor may make special requests,\n     such as asking that all changes be marked or described.\n     Although not required by our licenses, you are encouraged to\n     respect those requests where reasonable. More considerations\n     for the public:\n    wiki.creativecommons.org/Considerations_for_licensees\n\n=======================================================================\n\nCreative Commons Attribution-ShareAlike 4.0 International Public\nLicense\n\nBy exercising the Licensed Rights (defined below), You accept and agree\nto be bound by the terms and conditions of this Creative Commons\nAttribution-ShareAlike 4.0 International Public License (\"Public\nLicense\"). To the extent this Public License may be interpreted as a\ncontract, You are granted the Licensed Rights in consideration of Your\nacceptance of these terms and conditions, and the Licensor grants You\nsuch rights in consideration of benefits the Licensor receives from\nmaking the Licensed Material available under these terms and\nconditions.\n\n\nSection 1 -- Definitions.\n\n  a. Adapted Material means material subject to Copyright and Similar\n     Rights that is derived from or based upon the Licensed Material\n     and in which the Licensed Material is translated, altered,\n     arranged, transformed, or otherwise modified in a manner requiring\n     permission under the Copyright and Similar Rights held by the\n     Licensor. For purposes of this Public License, where the Licensed\n     Material is a musical work, performance, or sound recording,\n     Adapted Material is always produced where the Licensed Material is\n     synched in timed relation with a moving image.\n\n  b. Adapter's License means the license You apply to Your Copyright\n     and Similar Rights in Your contributions to Adapted Material in\n     accordance with the terms and conditions of this Public License.\n\n  c. BY-SA Compatible License means a license listed at\n     creativecommons.org/compatiblelicenses, approved by Creative\n     Commons as essentially the equivalent of this Public License.\n\n  d. Copyright and Similar Rights means copyright and/or similar rights\n     closely related to copyright including, without limitation,\n     performance, broadcast, sound recording, and Sui Generis Database\n     Rights, without regard to how the rights are labeled or\n     categorized. For purposes of this Public License, the rights\n     specified in Section 2(b)(1)-(2) are not Copyright and Similar\n     Rights.\n\n  e. Effective Technological Measures means those measures that, in the\n     absence of proper authority, may not be circumvented under laws\n     fulfilling obligations under Article 11 of the WIPO Copyright\n     Treaty adopted on December 20, 1996, and/or similar international\n     agreements.\n\n  f. Exceptions and Limitations means fair use, fair dealing, and/or\n     any other exception or limitation to Copyright and Similar Rights\n     that applies to Your use of the Licensed Material.\n\n  g. License Elements means the license attributes listed in the name\n     of a Creative Commons Public License. The License Elements of this\n     Public License are Attribution and ShareAlike.\n\n  h. Licensed Material means the artistic or literary work, database,\n     or other material to which the Licensor applied this Public\n     License.\n\n  i. Licensed Rights means the rights granted to You subject to the\n     terms and conditions of this Public License, which are limited to\n     all Copyright and Similar Rights that apply to Your use of the\n     Licensed Material and that the Licensor has authority to license.\n\n  j. Licensor means the individual(s) or entity(ies) granting rights\n     under this Public License.\n\n  k. Share means to provide material to the public by any means or\n     process that requires permission under the Licensed Rights, such\n     as reproduction, public display, public performance, distribution,\n     dissemination, communication, or importation, and to make material\n     available to the public including in ways that members of the\n     public may access the material from a place and at a time\n     individually chosen by them.\n\n  l. Sui Generis Database Rights means rights other than copyright\n     resulting from Directive 96/9/EC of the European Parliament and of\n     the Council of 11 March 1996 on the legal protection of databases,\n     as amended and/or succeeded, as well as other essentially\n     equivalent rights anywhere in the world.\n\n  m. You means the individual or entity exercising the Licensed Rights\n     under this Public License. Your has a corresponding meaning.\n\n\nSection 2 -- Scope.\n\n  a. License grant.\n\n       1. Subject to the terms and conditions of this Public License,\n          the Licensor hereby grants You a worldwide, royalty-free,\n          non-sublicensable, non-exclusive, irrevocable license to\n          exercise the Licensed Rights in the Licensed Material to:\n\n            a. reproduce and Share the Licensed Material, in whole or\n               in part; and\n\n            b. produce, reproduce, and Share Adapted Material.\n\n       2. Exceptions and Limitations. For the avoidance of doubt, where\n          Exceptions and Limitations apply to Your use, this Public\n          License does not apply, and You do not need to comply with\n          its terms and conditions.\n\n       3. Term. The term of this Public License is specified in Section\n          6(a).\n\n       4. Media and formats; technical modifications allowed. The\n          Licensor authorizes You to exercise the Licensed Rights in\n          all media and formats whether now known or hereafter created,\n          and to make technical modifications necessary to do so. The\n          Licensor waives and/or agrees not to assert any right or\n          authority to forbid You from making technical modifications\n          necessary to exercise the Licensed Rights, including\n          technical modifications necessary to circumvent Effective\n          Technological Measures. For purposes of this Public License,\n          simply making modifications authorized by this Section 2(a)\n          (4) never produces Adapted Material.\n\n       5. Downstream recipients.\n\n            a. Offer from the Licensor -- Licensed Material. Every\n               recipient of the Licensed Material automatically\n               receives an offer from the Licensor to exercise the\n               Licensed Rights under the terms and conditions of this\n               Public License.\n\n            b. Additional offer from the Licensor -- Adapted Material.\n               Every recipient of Adapted Material from You\n               automatically receives an offer from the Licensor to\n               exercise the Licensed Rights in the Adapted Material\n               under the conditions of the Adapter's License You apply.\n\n            c. No downstream restrictions. You may not offer or impose\n               any additional or different terms or conditions on, or\n               apply any Effective Technological Measures to, the\n               Licensed Material if doing so restricts exercise of the\n               Licensed Rights by any recipient of the Licensed\n               Material.\n\n       6. No endorsement. Nothing in this Public License constitutes or\n          may be construed as permission to assert or imply that You\n          are, or that Your use of the Licensed Material is, connected\n          with, or sponsored, endorsed, or granted official status by,\n          the Licensor or others designated to receive attribution as\n          provided in Section 3(a)(1)(A)(i).\n\n  b. Other rights.\n\n       1. Moral rights, such as the right of integrity, are not\n          licensed under this Public License, nor are publicity,\n          privacy, and/or other similar personality rights; however, to\n          the extent possible, the Licensor waives and/or agrees not to\n          assert any such rights held by the Licensor to the limited\n          extent necessary to allow You to exercise the Licensed\n          Rights, but not otherwise.\n\n       2. Patent and trademark rights are not licensed under this\n          Public License.\n\n       3. To the extent possible, the Licensor waives any right to\n          collect royalties from You for the exercise of the Licensed\n          Rights, whether directly or through a collecting society\n          under any voluntary or waivable statutory or compulsory\n          licensing scheme. In all other cases the Licensor expressly\n          reserves any right to collect such royalties.\n\n\nSection 3 -- License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the\nfollowing conditions.\n\n  a. Attribution.\n\n       1. If You Share the Licensed Material (including in modified\n          form), You must:\n\n            a. retain the following if it is supplied by the Licensor\n               with the Licensed Material:\n\n                 i. identification of the creator(s) of the Licensed\n                    Material and any others designated to receive\n                    attribution, in any reasonable manner requested by\n                    the Licensor (including by pseudonym if\n                    designated);\n\n                ii. a copyright notice;\n\n               iii. a notice that refers to this Public License;\n\n                iv. a notice that refers to the disclaimer of\n                    warranties;\n\n                 v. a URI or hyperlink to the Licensed Material to the\n                    extent reasonably practicable;\n\n            b. indicate if You modified the Licensed Material and\n               retain an indication of any previous modifications; and\n\n            c. indicate the Licensed Material is licensed under this\n               Public License, and include the text of, or the URI or\n               hyperlink to, this Public License.\n\n       2. You may satisfy the conditions in Section 3(a)(1) in any\n          reasonable manner based on the medium, means, and context in\n          which You Share the Licensed Material. For example, it may be\n          reasonable to satisfy the conditions by providing a URI or\n          hyperlink to a resource that includes the required\n          information.\n\n       3. If requested by the Licensor, You must remove any of the\n          information required by Section 3(a)(1)(A) to the extent\n          reasonably practicable.\n\n  b. ShareAlike.\n\n     In addition to the conditions in Section 3(a), if You Share\n     Adapted Material You produce, the following conditions also apply.\n\n       1. The Adapter's License You apply must be a Creative Commons\n          license with the same License Elements, this version or\n          later, or a BY-SA Compatible License.\n\n       2. You must include the text of, or the URI or hyperlink to, the\n          Adapter's License You apply. You may satisfy this condition\n          in any reasonable manner based on the medium, means, and\n          context in which You Share Adapted Material.\n\n       3. You may not offer or impose any additional or different terms\n          or conditions on, or apply any Effective Technological\n          Measures to, Adapted Material that restrict exercise of the\n          rights granted under the Adapter's License You apply.\n\n\nSection 4 -- Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that\napply to Your use of the Licensed Material:\n\n  a. for the avoidance of doubt, Section 2(a)(1) grants You the right\n     to extract, reuse, reproduce, and Share all or a substantial\n     portion of the contents of the database;\n\n  b. if You include all or a substantial portion of the database\n     contents in a database in which You have Sui Generis Database\n     Rights, then the database in which You have Sui Generis Database\n     Rights (but not its individual contents) is Adapted Material,\n\n     including for purposes of Section 3(b); and\n  c. You must comply with the conditions in Section 3(a) if You Share\n     all or a substantial portion of the contents of the database.\n\nFor the avoidance of doubt, this Section 4 supplements and does not\nreplace Your obligations under this Public License where the Licensed\nRights include other Copyright and Similar Rights.\n\n\nSection 5 -- Disclaimer of Warranties and Limitation of Liability.\n\n  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE\n     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS\n     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF\n     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,\n     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,\n     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR\n     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,\n     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT\n     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT\n     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.\n\n  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE\n     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,\n     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,\n     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,\n     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR\n     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN\n     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR\n     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR\n     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.\n\n  c. The disclaimer of warranties and limitation of liability provided\n     above shall be interpreted in a manner that, to the extent\n     possible, most closely approximates an absolute disclaimer and\n     waiver of all liability.\n\n\nSection 6 -- Term and Termination.\n\n  a. This Public License applies for the term of the Copyright and\n     Similar Rights licensed here. However, if You fail to comply with\n     this Public License, then Your rights under this Public License\n     terminate automatically.\n\n  b. Where Your right to use the Licensed Material has terminated under\n     Section 6(a), it reinstates:\n\n       1. automatically as of the date the violation is cured, provided\n          it is cured within 30 days of Your discovery of the\n          violation; or\n\n       2. upon express reinstatement by the Licensor.\n\n     For the avoidance of doubt, this Section 6(b) does not affect any\n     right the Licensor may have to seek remedies for Your violations\n     of this Public License.\n\n  c. For the avoidance of doubt, the Licensor may also offer the\n     Licensed Material under separate terms or conditions or stop\n     distributing the Licensed Material at any time; however, doing so\n     will not terminate this Public License.\n\n  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public\n     License.\n\n\nSection 7 -- Other Terms and Conditions.\n\n  a. The Licensor shall not be bound by any additional or different\n     terms or conditions communicated by You unless expressly agreed.\n\n  b. Any arrangements, understandings, or agreements regarding the\n     Licensed Material not stated herein are separate from and\n     independent of the terms and conditions of this Public License.\n\n\nSection 8 -- Interpretation.\n\n  a. For the avoidance of doubt, this Public License does not, and\n     shall not be interpreted to, reduce, limit, restrict, or impose\n     conditions on any use of the Licensed Material that could lawfully\n     be made without permission under this Public License.\n\n  b. To the extent possible, if any provision of this Public License is\n     deemed unenforceable, it shall be automatically reformed to the\n     minimum extent necessary to make it enforceable. If the provision\n     cannot be reformed, it shall be severed from this Public License\n     without affecting the enforceability of the remaining terms and\n     conditions.\n\n  c. No term or condition of this Public License will be waived and no\n     failure to comply consented to unless expressly agreed to by the\n     Licensor.\n\n  d. Nothing in this Public License constitutes or may be interpreted\n     as a limitation upon, or waiver of, any privileges and immunities\n     that apply to the Licensor or You, including from the legal\n     processes of any jurisdiction or authority.\n\n\n=======================================================================\n\nCreative Commons is not a party to its public\nlicenses. Notwithstanding, Creative Commons may elect to apply one of\nits public licenses to material it publishes and in those instances\nwill be considered the “Licensor.” The text of the Creative Commons\npublic licenses is dedicated to the public domain under the CC0 Public\nDomain Dedication. Except for the limited purpose of indicating that\nmaterial is shared under a Creative Commons public license or as\notherwise permitted by the Creative Commons policies published at\ncreativecommons.org/policies, Creative Commons does not authorize the\nuse of the trademark \"Creative Commons\" or any other trademark or logo\nof Creative Commons without its prior written consent including,\nwithout limitation, in connection with any unauthorized modifications\nto any of its public licenses or any other arrangements,\nunderstandings, or agreements concerning use of licensed material. For\nthe avoidance of doubt, this paragraph does not form part of the\npublic licenses.\n\nCreative Commons may be contacted at creativecommons.org.\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Website\n\nThis website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator.\n\n## Installation\n\n```bash\npnpm install\n```\n\n## Local Development\n\n```bash\npnpm start\n```\n\nThis command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.\n\n## Build\n\n```bash\npnpm build\n```\n\nThis command generates static content into the `build` directory and can be served using any static contents hosting service.\n\n## Deployment\n\nDeployment happen via [CI](https://github.com/woodpecker-ci/woodpecker/blob/d59fdb4602bfdd0d00078716ba61b05c02cbd1af/.woodpecker/docs.yml#L8-L30) to [woodpecker-ci.org](https://woodpecker-ci.org).\n\nTo manually build the website and push it exec:\n\n```sh\nGIT_USER=woodpecker-bot USE_SSH=true DEPLOYMENT_BRANCH=main pnpm deploy\n```\n"
  },
  {
    "path": "docs/blog/2023-06-11-hello-blog/index.md",
    "content": "---\ntitle: Welcome Woodpecker's blog\ndescription: This our first post on Woodpecker\nslug: hello-blog\nauthors:\n  - name: Anbraten\n    title: Maintainer of Woodpecker\n    url: https://github.com/anbraten\n    image_url: https://github.com/anbraten.png\ntags: [hello, woodpecker]\nhide_table_of_contents: false\n---\n\nWelcome to this blog. This is our first post on this blog ...\n\n<!--truncate-->\n\nIn the future we will post about our releases and other things like tutorials.\n\nWe are currently working on the `1.0.0` release of Woodpecker. This release will include a lot of new features and improvements which most of you probably already tested using the `next` tag.\n\nIf you have any suggestions or ideas for posts, feel free to open an issue in the [GitHub repository](https://github.com/woodpecker-ci/woodpecker).\n"
  },
  {
    "path": "docs/blog/2023-07-28-release-v1.0.0/index.md",
    "content": "---\ntitle: Presenting Woodpecker 1.0.0\ndescription: Introducing Woodpecker 1.0.0 and its new features.\nslug: release-v100\nauthors:\n  - name: 6543\n    title: Maintainer of Woodpecker\n    url: https://github.com/6543\n    image_url: https://github.com/6543.png\ntags: [release, major]\nhide_table_of_contents: false\n---\n\nWe are proud to present you Woodpecker v1.0.0.\nIt took us quite some time, but now we are sure it's ready, and you should really have a look at it.\n\n<!--truncate-->\n\nWe've refactored a lot of code, so contributing to the codebase should be much easier.\nFurthermore, a ton of bugs where addressed and various enhancements introduced, along with some highly anticipated features.\nWith Woodpecker v1.0.0, you can now substantially improve and streamline your code pipelines,\nempowering you to automate and optimize workflows like never before.\n\n## Some picked highlights\n\n### Add Support for Cron Jobs\n\nAutomate recurring tasks with ease using Woodpecker's new cron jobs feature.\nSchedule pipelines to run at specified intervals or times, optimizing repetitive workflows.\n\n### YAML Map Merge, Overrides, and Sequence Merge Support\n\nWith enhanced YAML support, managing complex configurations becomes a breeze.\nMerge maps, apply overrides, and sequence merging—all within your YAML files.\nThis is providing more flexibility and control over your pipelines.\n\n### Web-UI for Admins\n\nSimplify administration tasks with Woodpecker's new Admin UI.\nEffortlessly manage user accounts, agents, and tasks, including adding new agents or pausing the task queue for maintenance.\n\n![Image of admin queue view](./admin_queue_ui.png)\n\n### Localize Web-UI\n\nEmbrace internationalization by changing your locale in the user settings.\nInteract with Woodpecker in the language of your choice, tailored to your preferences.\nIf your language is not available or only partially translated, consider contributing to our [Weblate](https://translate.woodpecker-ci.org/engage/woodpecker-ci/).\n\n### Add `evaluate` to `when` Filter\n\nEnhance pipeline flexibility with the new \"when evaluate\" filter, enabling or disabling steps based on custom conditions.\nCustomize your workflows to dynamically respond to specific triggers and events.\n\n### Global- and Organization-Secrets\n\nSave time and effort by declaring secrets for your entire instance or organization.\nSimplify your workflow and securely manage sensitive information across projects.\n\n![Image of settings view of org secrets](./org_secrets.png)\n\n## Changelog\n\nThe full changelog can be viewed in our project source folder at [CHANGELOG.md](https://github.com/woodpecker-ci/woodpecker/blob/v1.0.0/CHANGELOG.md)\n"
  },
  {
    "path": "docs/blog/2023-11-09-release-v2.0.0/index.md",
    "content": "---\ntitle: It's time for some changes - Woodpecker 2.0.0\ndescription: Introducing Woodpecker 2.0.0 with more than 350 changes\nslug: release-v200\nauthors:\n  - name: Anbraten\n    title: Maintainer of Woodpecker\n    url: https://github.com/anbraten\n    image_url: https://github.com/anbraten.png\n  - name: qwerty287\n    title: Maintainer of Woodpecker\n    url: https://github.com/qwerty287\n    image_url: https://github.com/qwerty287.png\ntags: [release, major]\nhide_table_of_contents: false\n---\n\nWe are proud to present you Woodpecker v2.0.0 with more than 350 changes from our fabulous community. This release includes a lot of new features, improvements and some breaking changes which most of you probably already tested using the `next` tag or the RC version.\n\n<!--truncate-->\n\n## How we plan to handle releases in the future\n\nIn the future, there won't be backports anymore as they require quite an amount of maintenance. Instead, we'll release our current state of the `main` branch with the correct version (according to semver) every few weeks. Of course, critical bug and security fixes are released as soon as possible. To not release new major version too often, we'll try to hold back breaking changes pull-request for a longer time and release them all together in a new major version.\n\n## Breaking changes\n\n### Renamed some api routes\n\nWe renamed some API routes to be more consistent. So we suggest admins to update all repository webhooks by clicking on the newly added `Repair all repositories` button in the admin settings.\n\n### Dropped deprecated environment variables and CLI commands\n\nFor v1.0.0, we deprecated a bunch of old environment variables like `CI_BUILD_*`. These variables were removed in this version, you therefore have to use the new ones.\nAlso, the deprecated `build` command of the CLI was removed. Use `pipeline` instead.\n\n### Removed SSH backend\n\nDue to various issues with the SSH backend we decided to remove it.\nAs an alternative, you can install an agent running the local backend directly on the remote machine or you can simply execute `ssh` commands connecting to the remote server in your pipeline.\n\n### Deprecated `platform` filter\n\nThe `platform` filter has been removed. Use the more advanced labels instead ([read more](../docs/usage/workflow-syntax#filter-by-platform)).\n\n### Update Docker to v24\n\nWe updated Docker to v24 as of some security patches. If you use an older version of Docker, you might need to upgrade it.\n\n### Removed plugin-only option from secrets\n\nSecurity is pretty important to us and we want to make sure that no one can steal your secrets. Therefore, we decided to remove the plugin-only option from secrets and instead, if you define an image filter, it will be automatically only available to plugins using the defined image names.\n\n## Migration notes\n\nThere have been a few more breaking changes. [Read more about what you need to do when upgrading!](/migrations#200)\n\n## New features\n\nBut that's enough about breaking changes. Let's talk about the new features!\n\n### Config warnings and errors in the UI\n\nYou ever wondered why a secret was not working and after hours of debugging you found out that you misspelled the secret name? Or you used a wrong key in your YAML config? Woodpecker now shows errors and linter warnings directly in it's UI, notifying you about missing secrets, incorrect configuration or deprecated settings!\n\n![Image of warnings and errors in the UI](./linter_warnings_errors.png)\n\n### Repository and organization overview for admins\n\nAdmins now get an overview over all repositories and organizations registered on the server, allowing them to perform common actions like deleting directly from the admin dashboard.\n\n![Image of repos overview](./admin_repos.png)\n\n### Support for user secrets\n\nIt is now possible to add secrets for all repos owned by yourself, similar to organization and global secrets.\n\n### Bitbucket cloud support for multi-workflows\n\nWe enhanced support for Bitbucket, allowing you to use multiple workflows just as you probably know from all other forges already.\n\n### Full support for Kubernetes backend\n\nMany of you already used it extensively in the past, but now we can finally call the Kubernetes backend ready for production use. Supporting all major features and even quite some Kubernetes specific options.\n\n### Auto theme\n\nThe UI now supports automatically adapting the theme to your browser config, so no more light mode in the middle of the night!\n\n### Update notification\n\nUpdates are awesome as they bring new features and bug fixes most of the time, but sometimes there are also important security fixes which should be installed as soon as possible. To not miss any of them, we added a notification to the UI for admins if there's a new update available.\n\n## Changelog\n\nThe full changelog can be viewed in our project source folder at [CHANGELOG.md](https://github.com/woodpecker-ci/woodpecker/blob/v2.0.0/CHANGELOG.md)\n"
  },
  {
    "path": "docs/blog/2023-12-12-podman-image-builds/index.md",
    "content": "---\ntitle: '[Community] Podman-in-Podman image builds'\ndescription: Build images in Podman with buildah\nslug: podman-image-builds\nauthors:\n  - name: handlebargh\n    url: https://github.com/handlebargh\n    image_url: https://github.com/handlebargh.png\nhide_table_of_contents: true\ntags: [community, image, podman]\n---\n\n<!-- cspell:ignore buildah Containerfile roundcube -->\n\nI run Woodpecker CI with podman backend instead of docker and just figured out how to build images with buildah. Since I couldn't find this anywhere documented, I thought I might as well just share it here.\n\n<!-- truncate -->\n\nIt's actually pretty straight forward. Here's what my repository structure looks like:\n\n```bash\n.\n├── roundcube\n│   ├── Containerfile\n│   ├── docker-entrypoint.sh\n│   └── php.ini\n└── .woodpecker\n    └── .build_roundcube.yml\n```\n\nAs you can see I'm building a roundcube mail image.\n\nThis is the `.woodpecker/.build_roundcube.yaml`\n\n```yaml\nwhen:\n  event: [cron, manual]\n  cron: build_roundcube\n\nsteps:\n  build-image:\n    image: quay.io/buildah/stable:latest\n    pull: true\n    privileged: true\n    commands:\n      - echo $REGISTRY_LOGIN_TOKEN | buildah login -u <username> --password-stdin registry.gitlab.com\n      - cd roundcube\n      - buildah build --tag registry.gitlab.com/<namespace>/<repository_name>/roundcube:latest .\n      - buildah push registry.gitlab.com/<namespace>/<repository_name>/roundcube:latest\n\n    secrets: [registry_login_token]\n```\n\nAs you can see, I'm using this workflow over at gitlab.com. It should work with GitHub as well, with adjusting the registry login.\n\nYou may have to adjust the `when:` to your needs. Furthermore, you must check the `trusted` checkbox in project settings. Therefore, be sure to run trusted code only in this setup.\n\nThis seems to work fine so far. I wonder if anybody else made this work a different way.\n\nEDIT: Removed the additional step that would run buildah in a podman container. I didn't know it could be that easy to be honest.\n"
  },
  {
    "path": "docs/blog/2023-12-13-debug-pipeline-steps/index.md",
    "content": "---\ntitle: '[Community] Debug pipeline steps'\ndescription: Debug pipeline steps using sshx\nslug: debug-pipeline-steps\nauthors:\n  - name: anbraten\n    url: https://github.com/anbraten\n    image_url: https://github.com/anbraten.png\nhide_table_of_contents: true\ntags: [community, debug]\n---\n\n<!-- cspell:ignore sshx -->\n\nSometimes you want to debug a pipeline.\nTherefore I recently discovered: <https://github.com/ekzhang/sshx>\n\n<!-- truncate -->\n\nA simple step like should allow you to debug:\n\n```yaml\nsteps:\n  - name: debug\n    image: alpine\n    commands:\n      - curl -sSf https://sshx.io/get | sh && sshx\n      #      ^\n      #      └ This will open a remote terminal session and print the URL. It\n      #        should take under a second.\n```\n"
  },
  {
    "path": "docs/blog/2023-12-15-podman-sigstore/index.md",
    "content": "---\ntitle: '[Community] Podman image build with sigstore'\ndescription: Build images in Podman with sigstore signature checking and signing\nslug: podman-image-build-sigstore\nauthors:\n  - name: handlebargh\n    url: https://github.com/handlebargh\n    image_url: https://github.com/handlebargh.png\nhide_table_of_contents: false\ntags: [community, image, podman, sigstore, signature]\n---\n\n<!-- cspell:ignore BQVUJ Containerfile cosing distroless fulcio keypair nonroot QVRFLS rekor skopeo -->\n\nThis example shows how to build a container image with podman while verifying the base image and signing the resulting image.\n\n<!-- truncate -->\n\nThe image being pulled uses a keyless signature, while the image being built will be signed by a pre-generated private key.\n\n## Prerequisites\n\n### Generate signing keypair\n\nYou can use cosing or skopeo to generate the keypair.\n\nUsing skopeo:\n\n```bash\nskopeo generate-sigstore-key --output-prefix myKey\n```\n\nThis command will generate a `myKey.private` and a `myKey.pub` keyfile.\n\nStore the `myKey.private` as secret in Woodpecker. In the example below, the secret is called `sigstore_private_key`\n\n### Configure hosts pulling the resulting image\n\nSee [here](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/building_running_and_managing_containers/assembly_signing-container-images_building-running-and-managing-containers#proc_verifying-sigstore-image-signatures-using-a-public-key_assembly_signing-container-images) on how to configure the hosts pulling the built and signed image.\n\n## Repository structure\n\nConsider the `Makefile` having a `build` target that will be used in the following workflow.\nThis target yields a Go binary with the filename `app` that will be placed in the root directory.\n\n```bash\n.\n├── Containerfile\n├── main.go\n├── go.mod\n├── go.sum\n├── .woodpecker.yml\n└── Makefile\n```\n\n### Containerfile\n\nThe Containerfile refers to the base image that will be verified when pulled.\n\n```dockerfile\nFROM gcr.io/distroless/static-debian12:nonroot\nCOPY app /app\nCMD [\"/app\"]\n```\n\n### Woodpecker workflow\n\n```yaml\nsteps:\n  build:\n    image: docker.io/library/golang:1.21\n    pull: true\n    commands:\n      - make build\n\n  publish:\n    image: quay.io/podman/stable:latest\n    # Caution: This image is built daily. It might fill up your image store quickly.\n    pull: true\n    # Fill in the trusted checkbox in Woodpecker's settings as well\n    privileged: true\n    commands:\n      # Configure podman to use sigstore attachments for both, the registry you pull from and the registry you push to.\n      - |\n        printf \"docker:\n          registry.gitlab.com:\n            use-sigstore-attachments: true\n          gcr.io:\n            use-sigstore-attachments: true\" >> /etc/containers/registries.d/default.yaml\n\n      # At pull, check the keyless sigstore signature of the distroless image.\n      # This is a very strict container policy. It allows pulling from gcr.io/distroless only. Every other registry will be rejected.\n      # See https://github.com/containers/image/blob/main/docs/containers-policy.json.5.md for more information.\n\n      # fulcio CA crt obtained from https://github.com/sigstore/sigstore/blob/main/pkg/tuf/repository/targets/fulcio_v1.crt.pem\n      # rekor public key obtained from https://github.com/sigstore/sigstore/blob/main/pkg/tuf/repository/targets/rekor.pub\n      # crt/key data is base64 encoded. --> echo \"$CERT\" | base64\n      - |\n        printf '{\n            \"default\": [\n              {\n                \"type\": \"reject\"\n              }\n            ],\n            \"transports\": {\n              \"docker\": {\n                \"gcr.io/distroless\": [\n                  {\n                    \"type\": \"sigstoreSigned\",\n                    \"fulcio\": {\n                      \"caData\": \"LS0tLS1CRUdJTiBDR...QVRFLS0tLS0K\",\n                      \"oidcIssuer\": \"https://accounts.google.com\",\n                      \"subjectEmail\": \"keyless@distroless.iam.gserviceaccount.com\"\n                    },\n                    \"rekorPublicKeyData\": \"LS0tLS1CRUdJTiBQVUJ...lDIEtFWS0tLS0tCg==\",\n                    \"signedIdentity\": { \"type\": \"matchRepository\" }\n                  }\n                ]\n              },\n              \"docker-daemon\": {\n                \"\": [\n                  {\n                    \"type\": \"reject\"\n                  }\n                ]\n              }\n            }\n          }' > /etc/containers/policy.json\n\n      # Use this key to sign the built image at push.\n      - echo \"$SIGSTORE_PRIVATE_KEY\" > key.private\n      # Login at the registry\n      - echo $REGISTRY_LOGIN_TOKEN | podman login -u <username> --password-stdin registry.gitlab.com\n      # Build the container image\n      - podman build --tag registry.gitlab.com/<namespace>/<repository_name>/<image_name>:latest .\n      # Sign and push the image\n      - podman push --sign-by-sigstore-private-key ./key.private registry.gitlab.com/<namespace>/<repository_name>/<image_name>:latest\n\n    secrets: [sigstore_private_key, registry_login_token]\n```\n"
  },
  {
    "path": "docs/blog/2024-01-01-continuous-deployment/index.md",
    "content": "---\ntitle: '[Community] Continuous Deployment'\ndescription: Deploy your artifacts to an app server\nslug: continuous-deployment\nauthors:\n  - name: lonix1\n    url: https://github.com/lonix1\n    image_url: https://github.com/lonix1.png\nhide_table_of_contents: false\ntags: [community, cd, deployment]\n---\n\nA typical CI pipeline contains steps such as: _clone_, _build_, _test_, _package_ and _push_. The final build product may be artifacts pushed to a git repository or a docker container pushed to a container registry.\n\nWhen these should be deployed on an app server, the pipeline should include a _deploy_ step, which represents the \"CD\" in CI/CD - the automatic deployment of a pipeline's final product.\n\nThere are various ways to accomplish CD with Woodpecker, depending on your project's specific needs.\n\n<!--truncate-->\n\n## Invoking deploy script via SSH\n\nThe final step in your pipeline could SSH into the app server and run a deployment script.\n\nOne of the benefits would be that the deployment script's output could be included in the pipeline's log. However in general, this is a complicated option as it tightly couples the CI and app servers.\n\nAn SSH step could be written by using a plugin, like [ssh](https://plugins.drone.io/plugins/ssh) or [git push](https://woodpecker-ci.org/plugins/git-push).\n\n## Polling for asset changes\n\nThis option completely decouples the CI and app servers, and there is no explicit deploy step in the pipeline.\n\nOn the app server, one should create a script or cron job that polls for asset changes (every minute, say). When a new version is detected, the script redeploys the app.\n\nThis option is easy to maintain, but the downside is a short delay (one minute) before new assets are detected.\n\n## Using a configuration management tool\n\nIf you are using a configuration management tool (e.g. Ansible, Chef, Puppet), then you could setup the last pipeline step to call that tool to perform the redeployment.\n\nA plugin for [Ansible](https://woodpecker-ci.org/plugins/ansible) exists and could be adapted accordingly.\n\nThis option is complex and only suitable in an environment in which you're already using configuration management.\n\n## Using webhooks (recommended)\n\nIf your forge (GitHub, GitLab, Gitea, etc.) supports webhooks, then you could create a separate listening app that receives a webhook when new assets are available and redeploys your app.\n\nThe listening \"app\" can be something as simple as a PHP script.\n\nAlternatively, there are a number of popular webhook servers that simplify this process, so you only need to write your actual deployment script. For example, [webhook](https://github.com/adnanh/webhook) and [webhookd](https://github.com/ncarlier/webhookd).\n\nThis is arguably the simplest and most maintainable solution.\n"
  },
  {
    "path": "docs/blog/2024-05-27-release-v2.5.0/index.md",
    "content": "---\ntitle: Here is Woodpecker 2.5.0\ndescription: Introducing Woodpecker 2.5.0\nslug: release-v250\nauthors:\n  - name: Anbraten\n    title: Maintainer of Woodpecker\n    url: https://github.com/anbraten\n    image_url: https://github.com/anbraten.png\ntags: [release, minor]\nhide_table_of_contents: false\n---\n\nHere is the next minor release 2.5.0 of Woodpecker 🪶 ☀️.\n\n<!--truncate-->\n\nAs always thanks to all contributors who helped to make this release possible. It includes quite a few enhancements\nmost users will benefit from while they are probably not that visible at first sight for most. The release also includes some preparations for new features to come in the next versions. Anyway, let's dive into some of the highlights of this release:\n\n## Improve the way entrypoints work\n\nThe implementation wasn't perfect yet so we improved the way entrypoints work:\n\nIf you define [`commands`](/docs/usage/workflow-syntax#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\n\nIf you define your own entrypoint, you can completely overwrite the default entrypoint. If you define `entrypoint: [\"/bin/my-script\", \"\"]` for example you can run your own binary / script. In this case the commands section will ignored, however you can still access it in your own script by using the base64 encoded string of the `CI_SCRIPT` environment variable.\n\n[#3269](https://github.com/woodpecker-ci/woodpecker/pull/3269)\n\n## Cli output formats\n\nThe cli output has been improved. The first command (mainly pipeline info, ls, create) support a `--output` flag now which allows you to change the output format. There is a new `table` format (the new default) which will look like the following and can be further customized:\n\n```bash\n# use default table output\n❯ woodpecker-cli pipeline ls --limit 2 2\nNUMBER  STATUS   EVENT   BRANCH  COMMIT                                    AUTHOR\n43      error    manual  main    473761d8b26b20f7c206408563d54cf998410329  woodpecker\n42      success  push    main    473761d8b26b20f7c206408563d54cf998410329  woodpecker\n\n# customize table output and disable header\n❯ woodpecker-cli pipeline ls --limit 2 --output table=number,status,event --no-header 2\n43  error    manual\n42  success  push\n```\n\nIn addition especially useful for programmatic usage there is a `go-template` output format which will output the data using the provided go template like this:\n\n```bash\n########\n# go crazy and use a template layout\n❯ woodpecker-cli pipeline ls --limit 2 --output go-template='{{range .}}{{printf \"\\x1b[33mPipeline #%d\\x1b[0m\\nStatus: %s\\nEvent:%s\\nCommit:%s\\n\\n\" .Number .Status .Event .Commit}}{{end}}' 2\nPipeline #43\nStatus: error\nEvent:manual\nCommit:473761d8b26b20f7c206408563d54cf998410329\n\nPipeline #42\nStatus: success\nEvent:push\nCommit:473761d8b26b20f7c206408563d54cf998410329\n```\n\n[#3660](https://github.com/woodpecker-ci/woodpecker/pull/3660)\n\n## Deleting logs or complete pipelines\n\nIf you accidentally exposed some secret to the public in your logs or you simply want to cleanup some logs you can now delete logs or complete pipelines using the api and the cli.\n\n[#3451](https://github.com/woodpecker-ci/woodpecker/pull/3451)\n[#3506](https://github.com/woodpecker-ci/woodpecker/pull/3506)\n[#3458](https://github.com/woodpecker-ci/woodpecker/pull/3458)\n\n## Support for Github deploy tasks\n\nWoodpecker now supports Github deploy tasks. This allows you to pass the deploy task set in Github to your Woodpecker pipeline.\n\n[#3512](https://github.com/woodpecker-ci/woodpecker/pull/3512)\n\n## Deprecations\n\nTo keep things clean and simple we deprecated some pipeline options, server settings and features which will\nbe removed in the next major release:\n\n- Deprecated `environment` filter, use `when.evaluate`\n- Use `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` instead of `WOODPECKER_DEV_GITEA_OAUTH_URL` or `WOODPECKER_DEV_OAUTH_HOST`\n- Deprecated `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST`\n\nFor a full list of deprecations that will be dropped in the `next` major release `3.0.0` (no eta yet), please check the [migrations](/migrations#next) section.\n"
  },
  {
    "path": "docs/blog/2024-12-28-release-v3.0.0/index.md",
    "content": "---\ntitle: Woodpecker 3.0.0\ndescription: Introducing Woodpecker 2.5.0\nslug: release-v300\nauthors:\n  - name: pat-s\n    title: Maintainer of Woodpecker\n    url: https://github.com/pat-s\n    image_url: https://github.com/pat-s.png\ntags: [release, major]\nhide_table_of_contents: false\n---\n\nWe are excited to announce the release of Woodpecker 3.0.0! Along with various cleanup improvements, you can now register your own agents as a user and replay pipelines directly from the server using cli exec.\n\n<!--truncate-->\n\n## Breaking Changes\n\nTo enhance the usability of Woodpecker and comply with evolving security standards, we periodically implement migrations. While we strive to minimize changes, some adjustments are essential for an improved user experience.\nWe acknowledge that this release includes a significant number of changes, many of which require users to update their pipeline definitions. We understand that this can be a tedious task, especially when managing multiple repositories and pipelines.\nRest assured that each modification was carefully considered and thoroughly discussed, with specific reasoning behind every decision.\n\nA substantial portion of these updates aims to transition away from outdated and suboptimal Drone definitions. Your patience and understanding as we implement these necessary changes are greatly appreciated. If you encounter any major issues during your migration to a new version, please don't hesitate to reach out. The Woodpecker maintainers are always eager to reassess and improve our updates based on your feedback.\n\nSecurity has been a primary focus in this major release. In addition to patching known vulnerabilities (which have also been backported to v2 releases), we have enhanced the secrets handling mechanism to prevent accidental leaks and simplify the process of keeping sensitive information fully encrypted.\n\nFor a complete list of migration steps, please refer to the [migration guide](/migrations).\n\n### `from_secret:` as the powerful replacement for the `secrets:` keyword\n\nSpecifically, the `secrets:` keyword has been deprecated in favor of a more flexible (and secure) way to specify secrets: `from_secret:`.\nThis new approach provides more flexibility (by using different names for the source and destination secrets) and ensures a safe internal secret parsing through a unified engine.\nBecause secrets defined via `secrets:` were simple env vars in the end, this change also removes potential confusion about the differences between values specified in `environment:` and `secrets`.\nNow, both are defined in `environment:` using an expressive syntax:\n\n```yaml\nsteps:\n  name:\n    image: alpine\n    commands:\n      - echo \"The secret is $TOKEN_ENV\"\n    environment:\n      TOKEN_ENV:\n        from_secret: SECRET_TOKEN\n```\n\n## Register Your Own Agents for Users or Organizations [#3539](https://github.com/woodpecker-ci/woodpecker/pull/3539)\n\nWoodpeckerCI now lets you register custom agents scoped to individual users or organizations. This means you can bring your own agents, configured to meet the unique needs of your projects, and assign them to specific users or organizational workflows.\n\nThis update provides flexibility for teams with diverse requirements, allowing them to integrate agents tailored to specific tasks or environments seamlessly into their pipelines.\n\n## Replay Pipelines Locally Using `cli exec` [#4103](https://github.com/woodpecker-ci/woodpecker/pull/4103)\n\nDebugging pipelines no longer requires endless small adjustments and repeated pushes. With the new `woodpecker-cli exec` feature, you can download pipeline metadata directly from the server and replay it locally. This allows you to test and fix issues in a similar environment to the server, all from your machine.\n\n![debug-pipelines-option](debug-pipelines.png)\n\nBy locally debugging, this feature accelerates the development process and provides deeper insights into pipeline behavior without relying on server-side execution for every small change.\n\n:::info\nIn order to use this feature, all required pipeline elements must be passed, e.g. secrets.\nHowever, secrets are not included in the pipeline metadata and must be passed manually to the local execution call.\n:::\n\n## Rootless images\n\nWoodpecker now supports running rootless images by adjusting the entrypoints and directory permissions in the containers in a way that allows non-privileged users to execute tasks.\n\nIn addition, all images published by Woodpecker (Server, Agent, CLI) now use a non-privileged user (`woodpecker` with UID and GID `1000`) by default. If you have volumes attached to the containers, you may need to change the ownership of these directories from `root` to `woodpecker` by executing `chown -R 1000:1000 <mount dir>`.\n\n:::info\nThe agent image must remain rootful by default to be able to mount the Docker socket when Woodpecker is used with the `docker` backend.\nThe helm chart will start to use a non-privileged user by utilizing `securityContext`.\nRunning a completely rootless agent with the `docker` backend may be possible by using a rootless docker daemon.\nHowever, this requires more work and is currently not supported.\n:::\n\n## Fine grained control over approvals options\n\nWoodpecker 3.0.0 introduces enhanced approval options. Beyond requiring approval for all pipeline events, you can now configure it specifically for all pull requests or only for pull requests originating from forks.\n\nBy default, public repositories will now mandate approval for pull requests from forks. This helps prevent potentially malicious PRs from exposing secrets or performing unauthorized actions without the repository owner's awareness.\n\n![screenshot of new approval-requirements options](approval-requirements.png)\n\n## UI\n\nWe have fixed many UI-related bugs in this version.\nMany were small misalignment related to padding, margins or other edge cases related to small screen sizes.\nWe also aimed to harmonize the icons across the UI, specifically across logical subgroups, such as status-icons or admin panel icons.\n\nUI elements are now sized in a relative way, meaning they will all scale relative when you change the font-size or zoom in/out.\n\n## Deleting old pipeline logs\n\nDeleting a pipeline now successfully also deletes its related logs.\nBeforehand, there was an issue where the logs were not deleted and were kept in the DB forever.\n\nYou might want to check [#4572](https://github.com/woodpecker-ci/woodpecker/pull/4572) for more details including a snippet how to delete orphaned entries of a Postgres DB.\n\n:::info\nThere is no option yet to auto-delete old pipeline logs after a specific time or event.\nPlease follow [#1068](https://github.com/woodpecker-ci/woodpecker/issues/1068) for future updates.\n:::\n\n## Migration to standard Linux CRON syntax\n\nCRON definitions now follow standard Linux syntax without seconds. An automatic migration will attempt to update your settings - ensure the update completes successfully.\n\n## Known Issues\n\nThe generic `pipeline definition not found` is still present and not yet understood.\nThis error message can be triggered by various elements (which the most likely one being a (temporary) connection issue with the forge) and the error return/output must be improved first in order to take appropriate action.\n"
  },
  {
    "path": "docs/docs/10-intro/index.md",
    "content": "# Welcome to Woodpecker\n\nWoodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics.\n\n## Have you ever heard of CI/CD or pipelines?\n\nDon't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of\nchecks, tests and routines along the way. A typical pipeline might include the following steps:\n\n1. Running tests\n2. Building your application\n3. Deploying your application\n\n[Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd)\n\n## Do you know containers?\n\nIf you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/).\n\n## Already have access to a Woodpecker instance?\n\nThen you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md).\n\n## Want to start from scratch and deploy your own Woodpecker instance?\n\nWoodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance.\n"
  },
  {
    "path": "docs/docs/20-usage/10-intro.md",
    "content": "# Your first pipeline\n\nLet's get started and create your first pipeline.\n\n## 1. Repository Activation\n\nTo activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click.\n\n![new repository list](repo-new.png)\n\nTo enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something\nthat is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.).\n\n## 2. Define first workflow\n\nAfter enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository:\n\n```yaml title=\".woodpecker/my-first-workflow.yaml\"\nwhen:\n  - event: push\n    branch: main\n\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"This is the build step\"\n      - echo \"binary-data-123\" > executable\n  - name: a-test-step\n    image: golang:1.16\n    commands:\n      - echo \"Testing ...\"\n      - ./executable\n```\n\n**So what did we do here?**\n\n1. We defined your first workflow file `my-first-workflow.yaml`.\n2. This workflow will be executed when a push event happens on the `main` branch,\n   because we added a filter using the `when` section:\n\n   ```diff\n   + when:\n   +   - event: push\n   +     branch: main\n\n   ...\n   ```\n\n3. We defined two steps: `build` and `a-test-step`\n\nThe steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`.\n\nIn the `build` step we use the `debian` image and build a \"binary file\" called `executable`.\n\nIn the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it.\n\nYou can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to:\n\n```diff\n steps:\n   - name: build\n-    image: debian\n+    image: my-company/image-with-aws_cli\n     commands:\n       - aws help\n```\n\n## 3. Push the file and trigger first pipeline\n\nIf you push this file to your repository now, Woodpecker will already execute your first pipeline.\n\nYou can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository.\n\n![pipeline view](./pipeline.png)\n\nAs you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps.\n\nThis for example allows the first step to build your application using your source code and as the second step will receive\nthe same workspace it can use the previously built binary and test it.\n\n## 4. Use a plugin for reusable tasks\n\nSometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md).\n\nIf you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline:\n\n```yaml\nsteps:\n  # ...\n  - name: upload\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      access_key: a50d28f4dd477bc184fbd10b376de753\n      secret_key:\n        from_secret: aws_secret_key\n      source: public/**/*\n      target: /target/location\n```\n\nTo configure a plugin you can use the `settings` section.\n\nSometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md).\n\nSimilar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed.\n\nLearn more about [plugins](./51-plugins/51-overview.md).\n\nAs you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md).\n"
  },
  {
    "path": "docs/docs/20-usage/100-troubleshooting.md",
    "content": "# Troubleshooting\n\n## How to debug clone issues\n\n(And what to do with an error message like `fatal: could not read Username for 'https://<url>': No such device or address`)\n\nThis error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`:\n\n```ini\nWOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true\n```\n\nIf that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container \"hang\":\n\n```yaml\nskip_clone: true\n\nsteps:\n  build:\n    image: debian:stable-backports\n    commands:\n      - apt update\n      - apt install -y inetutils-ping wget\n      - ping -c 4 git.example.com\n      - wget git.example.com\n      - sleep 9999999\n```\n\nGet the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf  bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline:\n\n```bash\ngit init\ngit remote add origin https://git.example.com/username/repo.git\ngit fetch --no-tags origin +refs/heads/branch:\n```\n\n(replace the url AND the branch with the correct values, use your username and password as log in values)\n\n## SELinux Issues\n\nWhen running Woodpecker on systems with SELinux enabled (such as RHEL, CentOS, Fedora, or other Enterprise Linux distributions), SELinux may prevent the agent from accessing the Docker socket.\n\n### Symptoms\n\nIf SELinux is blocking access, you may see errors like:\n\n```text\npermission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock\n```\n\n### Solutions\n\nThere are several ways to resolve this:\n\n#### Option 1: Set SELinux to Permissive Mode (For Testing Only)\n\nSet SELinux to permissive mode temporarily to verify it's the issue:\n\n```bash\nsetenforce 0\n```\n\nTo permanently set SELinux to permissive mode:\n\n```bash\n# Edit /etc/selinux/config\nSELINUX=permissive\n```\n\n#### Option 2: Configure SELinux Policy (Recommended)\n\nCreate a custom SELinux policy to allow Woodpecker agent to access Docker:\n\n```bash\n# Generate the policy module\nausearch -c 'docker' -avc | audit2allow -R -o woodpecker-docker.te\n# Build the policy module\ncheckmodule -M -m -o woodpecker-docker.mod woodpecker-docker.te\nsemodule_package -o woodpecker-docker.pp -m woodpecker-docker.mod\n# Load the policy module\nsemodule -i woodpecker-docker.pp\n```\n\n#### Option 3: Use Docker Volume with SELinux Options\n\nWhen using Docker Compose or Docker, add the `:z` or `:Z` option to volume mounts:\n\n```yaml\nvolumes:\n  - /var/run/docker.sock:/var/run/docker.sock:z\n```\n\nThe `:z` option tells Docker to automatically relabel the volume content for SELinux. Use `:Z` with caution as it relabels the volume exclusively for this container.\n\n#### Option 4: Use Podman (Alternative)\n\nIf you prefer to avoid SELinux configuration issues, consider using Podman instead of Docker, as it has better SELinux integration.\n"
  },
  {
    "path": "docs/docs/20-usage/15-terminology/architecture.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 226,\n      \"versionNonce\": 1002880859,\n      \"isDeleted\": false,\n      \"id\": \"UczUX5VuNnCB1rVvUJVfm\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.098092529257,\n      \"y\": 320.8758615860986,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 472.8823858375721,\n      \"height\": 183.19688715994928,\n      \"seed\": 917720693,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 161,\n      \"versionNonce\": 286006267,\n      \"isDeleted\": false,\n      \"id\": \"sKPZmBSWUdAYfBs4ByItH\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 539.5451038202509,\n      \"y\": 345.2419383247636,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 82.46875,\n      \"height\": 32.199999999999996,\n      \"seed\": 1485551573,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Server\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Server\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 333,\n      \"versionNonce\": 448586907,\n      \"isDeleted\": false,\n      \"id\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 649.8080506852966,\n      \"y\": 427.60908869342575,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 136,\n      \"height\": 60,\n      \"seed\": 1783625013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"r90dckf8trHemYzEwCgCW\"\n        },\n        {\n          \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 298,\n      \"versionNonce\": 1244067771,\n      \"isDeleted\": false,\n      \"id\": \"r90dckf8trHemYzEwCgCW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 703.8080506852966,\n      \"y\": 441.5090886934257,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 28,\n      \"height\": 32.199999999999996,\n      \"seed\": 660965013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113383,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"UI\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"originalText\": \"UI\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 105,\n      \"versionNonce\": 265992667,\n      \"isDeleted\": false,\n      \"id\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 800.9240766836483,\n      \"y\": 421.4987043996123,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135.3671503686619,\n      \"height\": 62.2689029398432,\n      \"seed\": 1115810805,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"svsVhxCbatcLj7lQLch0P\"\n        },\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 83,\n      \"versionNonce\": 1706870395,\n      \"isDeleted\": false,\n      \"id\": \"svsVhxCbatcLj7lQLch0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 828.1594096804793,\n      \"y\": 436.53315586953386,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.896484375,\n      \"height\": 32.199999999999996,\n      \"seed\": 2074781013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"GRPC\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"originalText\": \"GRPC\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 270,\n      \"versionNonce\": 418660123,\n      \"isDeleted\": false,\n      \"id\": \"hSrrwwnm9y7R-_CnJtaK1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.567103519039,\n      \"y\": 556.4146894573112,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 1983197877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 154,\n      \"versionNonce\": 871605179,\n      \"isDeleted\": false,\n      \"id\": \"8tsYgVssKnBd_Zw1QuqNz\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1298.4367898442752,\n      \"y\": 566.567242947784,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 96.5234375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1321669653,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent 1\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent 1\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 1323136091,\n      \"isDeleted\": false,\n      \"id\": \"eeugZg73_yD_6uLBBgmcX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 404.5001910129067,\n      \"y\": 707.1233710221009,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 210.068359375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1901447541,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"User => Browser\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"User => Browser\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"ellipse\",\n      \"version\": 106,\n      \"versionNonce\": 1501835515,\n      \"isDeleted\": false,\n      \"id\": \"mlDhl4OOc-H1tNgh77AAW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 482.5857164810477,\n      \"y\": 602.4394551739279,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 46.024748503793035,\n      \"height\": 44.21988070606176,\n      \"seed\": 791073493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"line\",\n      \"version\": 166,\n      \"versionNonce\": 627726747,\n      \"isDeleted\": false,\n      \"id\": \"ADEXzdYAhvj-_wVRftTIg\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 459.12202200277807,\n      \"y\": 697.1964604319912,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.31792517362464,\n      \"height\": 31.585599568061298,\n      \"seed\": 349155381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          42.415150610916044,\n          -28.87829787146393\n        ],\n        [\n          80.31792517362464,\n          2.7073016965973693\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 231,\n      \"versionNonce\": 801271355,\n      \"isDeleted\": false,\n      \"id\": \"xmz4J-rxLIjfUQ4q19PjD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 516.8788931508789,\n      \"y\": 870.4664542146543,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#fff4e6\",\n      \"width\": 385.34512717560705,\n      \"height\": 60.464035142111264,\n      \"seed\": 3531157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 93,\n      \"versionNonce\": 728690395,\n      \"isDeleted\": false,\n      \"id\": \"gSbpry_947XArfI7b6AAL\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 636.1468430141358,\n      \"y\": 878.5884970070326,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 132.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1989076725,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Autoscaler\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Autoscaler\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 118,\n      \"versionNonce\": 1258445691,\n      \"isDeleted\": false,\n      \"id\": \"WVy0mdTGbUx08RuxdQUH8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 523.3741602213286,\n      \"y\": 907.372811672524,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 369.1484375,\n      \"height\": 18.4,\n      \"seed\": 979386453,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Starts agents based on amount of pending pipelines\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Starts agents based on amount of pending pipelines\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 373,\n      \"versionNonce\": 1254044699,\n      \"isDeleted\": false,\n      \"id\": \"0Y1RcqzVFBFqh-wy-APMI\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1232.1955835481922,\n      \"y\": 605.8737363119278,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 292.6171875,\n      \"height\": 18.4,\n      \"seed\": 561999285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Executes pending workflows of a pipeline\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Executes pending workflows of a pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 630,\n      \"versionNonce\": 983038139,\n      \"isDeleted\": false,\n      \"id\": \"lGumbhMs3xx1vU2632hli\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 505.62283787078286,\n      \"y\": 383.42044095379515,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.015625,\n      \"height\": 36.8,\n      \"seed\": 722595605,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Central unit of a \\nWoodpecker instance \",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Central unit of a \\nWoodpecker instance \",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 131,\n      \"versionNonce\": 137308507,\n      \"isDeleted\": false,\n      \"id\": \"PbSQXehWVLYcQGXYFpd-B\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 971.7123256059622,\n      \"y\": 171.06951064323448,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 274.3443117379593,\n      \"height\": 74.90311522655017,\n      \"seed\": 1435321461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 96,\n      \"versionNonce\": 1222067707,\n      \"isDeleted\": false,\n      \"id\": \"2P2tz29C_2sUzVNSpaG17\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.5206131439782,\n      \"y\": 183.12082907329545,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 73.14453125,\n      \"height\": 32.199999999999996,\n      \"seed\": 884403669,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Forge\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Forge\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 141,\n      \"versionNonce\": 1133694619,\n      \"isDeleted\": false,\n      \"id\": \"0eYhFYPuRanZ7wkR2OlHO\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 986.864582863368,\n      \"y\": 225.1223531590797,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 247.234375,\n      \"height\": 18.4,\n      \"seed\": 1201957685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 55,\n      \"versionNonce\": 991183675,\n      \"isDeleted\": false,\n      \"id\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 820.419424341303,\n      \"y\": 340.29123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 247151765,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"bcUL-u4zkLA9CLG2YdaeN\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 38,\n      \"versionNonce\": 2008949723,\n      \"isDeleted\": false,\n      \"id\": \"bcUL-u4zkLA9CLG2YdaeN\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 831.853994653803,\n      \"y\": 358.79123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 94.130859375,\n      \"height\": 23,\n      \"seed\": 1638982133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Webhooks\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"originalText\": \"Webhooks\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 93,\n      \"versionNonce\": 295891067,\n      \"isDeleted\": false,\n      \"id\": \"Bphhue86mMXHN4klGamM3\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 697.3018309300141,\n      \"y\": 339.607928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 92986197,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"0YxY2hEPyDWFqR8_-f6bn\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 87,\n      \"versionNonce\": 2055547163,\n      \"isDeleted\": false,\n      \"id\": \"0YxY2hEPyDWFqR8_-f6bn\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 727.4522215550141,\n      \"y\": 358.107928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 56.69921875,\n      \"height\": 23,\n      \"seed\": 43952309,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"OAuth\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Bphhue86mMXHN4klGamM3\",\n      \"originalText\": \"OAuth\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 284,\n      \"versionNonce\": 1205292475,\n      \"isDeleted\": false,\n      \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1205.6453201409104,\n      \"y\": 250.4849674923464,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 272.1094712799886,\n      \"height\": 94.31865813977868,\n      \"seed\": 982632981,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"0eYhFYPuRanZ7wkR2OlHO\",\n        \"focus\": -0.8418551162334328,\n        \"gap\": 6.962614333266799\n      },\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          -69.68740859223726,\n          65.87860410965993\n        ],\n        [\n          -272.1094712799886,\n          94.31865813977868\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 53,\n      \"versionNonce\": 1803962459,\n      \"isDeleted\": false,\n      \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1050.575099048673,\n      \"y\": 297.96357160200637,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 170.765625,\n      \"height\": 36.8,\n      \"seed\": 1046069109,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"sends events like push, \\ntag, ...\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"originalText\": \"sends events like push, tag, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 487,\n      \"versionNonce\": 335895291,\n      \"isDeleted\": false,\n      \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 792.0835609101814,\n      \"y\": 316.38601649373913,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 176.92139414789008,\n      \"height\": 122.73778943055902,\n      \"seed\": 1681656021,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yvJTQ64RU50N6-hxEQlkl\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"UczUX5VuNnCB1rVvUJVfm\",\n        \"focus\": -0.03867359238356983,\n        \"gap\": 4.489845092359474\n      },\n      \"endBinding\": {\n        \"elementId\": \"PbSQXehWVLYcQGXYFpd-B\",\n        \"focus\": 0.7798878042817562,\n        \"gap\": 2.707370547890605\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          60.422360349016344,\n          -71.97786730696657\n        ],\n        [\n          176.92139414789008,\n          -122.73778943055902\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 62,\n      \"versionNonce\": 301106427,\n      \"isDeleted\": false,\n      \"id\": \"yvJTQ64RU50N6-hxEQlkl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 773.7910775091977,\n      \"y\": 226.00814918677256,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 157.4296875,\n      \"height\": 36.8,\n      \"seed\": 500049461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"allows users to login \\nusing existing account\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"originalText\": \"allows users to login using existing account\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 393,\n      \"versionNonce\": 598459861,\n      \"isDeleted\": false,\n      \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 936.9267543177084,\n      \"y\": 458.95033086418084,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 215.17788326846676,\n      \"height\": 93.99151368376693,\n      \"seed\": 234198933,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"rFf6NIofw6UBOyAFwg0Kn\"\n        }\n      ],\n      \"updated\": 1697530127259,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.30339107267010673,\n        \"gap\": 1\n      },\n      \"endBinding\": {\n        \"elementId\": \"hSrrwwnm9y7R-_CnJtaK1\",\n        \"focus\": -0.14057158065513534,\n        \"gap\": 3.4728449093634026\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          130.0760301643047,\n          42.90930518030268\n        ],\n        [\n          215.17788326846676,\n          93.99151368376693\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 8,\n      \"versionNonce\": 1693330843,\n      \"isDeleted\": false,\n      \"id\": \"rFf6NIofw6UBOyAFwg0Kn\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 997.4942845557462,\n      \"y\": 473.9409015069133,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 1592253685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 270,\n      \"versionNonce\": 1855882619,\n      \"isDeleted\": false,\n      \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 886.0581619083632,\n      \"y\": 485.67004123832135,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 174.09447592006472,\n      \"height\": 326.4905563076211,\n      \"seed\": 1479177813,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"apyMCAv2GIN_yzHXwX4tY\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.1341191028023529,\n        \"gap\": 1.9024338988657519\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"focus\": -0.7088661407505865,\n        \"gap\": 4.060573862784622\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          44.14165353942735,\n          196.18483635907205\n        ],\n        [\n          174.09447592006472,\n          326.4905563076211\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 66,\n      \"versionNonce\": 2007745083,\n      \"isDeleted\": false,\n      \"id\": \"apyMCAv2GIN_yzHXwX4tY\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 849.4927841977906,\n      \"y\": 663.4548775973934,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 882041781,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 347,\n      \"versionNonce\": 1353818811,\n      \"isDeleted\": false,\n      \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 534.9278465333664,\n      \"y\": 595.2199151317081,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 113.88020415193023,\n      \"height\": 119.81968366814112,\n      \"seed\": 944153877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"_A8uznhnpXuQBYzjP-iVx\",\n        \"focus\": 0.5397285671082249,\n        \"gap\": 1\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          113.88020415193023,\n          -119.81968366814112\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 61,\n      \"versionNonce\": 1099141979,\n      \"isDeleted\": false,\n      \"id\": \"j56ZKRwmXk72nHrZzLz_1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1081.8110514012087,\n      \"y\": 652.5253283508498,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 566.7373014532342,\n      \"height\": 68.58600908319681,\n      \"seed\": 112933493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 82,\n      \"versionNonce\": 1879994363,\n      \"isDeleted\": false,\n      \"id\": \"cAVYXfBRnfuGAv7QTQVow\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1300.6584159706863,\n      \"y\": 658.8425033454967,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 77.83203125,\n      \"height\": 23,\n      \"seed\": 951460821,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Backend\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Backend\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 376,- add some images explaining the architecture & terminology with\npipeline -> workflow -> step\n- combine advanced config usage\n- rename pipeline syntax to workflow syntax (and most references to\npipeline steps etc as well)\n- update agent registration part\n- add bug note to secrets encryption setting\n- remove usage from readme to point to up-to-date docs page\n- typos\n- closes #1408\n\n---------\n      \"angle\": 0,\n      \"x\": 1094.1972977313717,\n      \"y\": 681.8988272758752,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530.9453125,\n      \"height\": 55.199999999999996,\n      \"seed\": 843899189,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 50\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 384,\n      \"versionNonce\": 1778969915,\n      \"isDeleted\": false,\n      \"id\": \"pxF49EKDNO6IZq_34i7bY\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1064.2132116912126,\n      \"y\": 754.5018564383092,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 954528405,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 154,\n      \"versionNonce\": 1988988379,\n      \"isDeleted\": false,\n      \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 904.0288881242177,\n      \"y\": 882.4966027880746,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.83070714434325,\n      \"height\": 32.735025983189644,\n      \"seed\": 1228134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yNxAOEPZu_Jl7mnI01OXs\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"xmz4J-rxLIjfUQ4q19PjD\",\n        \"gap\": 1.8048677977312764,\n        \"focus\": 0.31250963573550006\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"gap\": 1.353616422651612,\n        \"focus\": 0.36496042109885213\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          158.83070714434325,\n          -32.735025983189644\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 25,\n      \"versionNonce\": 1393410779,\n      \"isDeleted\": false,\n      \"id\": \"yNxAOEPZu_Jl7mnI01OXs\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 963.8856479463893,\n      \"y\": 856.9290897964797,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 39.1171875,\n      \"height\": 18.4,\n      \"seed\": 759107925,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113387,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"starts\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"originalText\": \"starts\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 187,\n      \"versionNonce\": 671224603,\n      \"isDeleted\": false,\n      \"id\": \"sSj4Pda-fo-BBYM_dzml6\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1296.0854928322988,\n      \"y\": 776.6118140041631,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 104.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1381768885,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/docs/20-usage/15-terminology/index.md",
    "content": "# Terminology\n\n## Glossary\n\n- **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC.\n- **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines.\n- **Code**: Refers to the files tracked by the version control system used by the [forge][Forge].\n- **Commit**: A defined state of the code, usually associated with a version control system like Git.\n- **Container**: A lightweight and isolated environment where commands are executed.\n- **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel.\n- **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI.\n- **[Extension][Extension]**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through extensions.\n- **[Forge][Forge]**: The hosting platform or service where the repositories are hosted.\n- **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix.\n- **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events.\n- **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings.\n- **Repos**: Short for repositories, these are storage locations where code is stored.\n- **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration.\n- **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow].\n- **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge].\n- **Steps**: Individual commands, actions or tasks within a [workflow][Workflow].\n- **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue.\n- **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code.\n- **Woodpecker CI**: The project name around Woodpecker.\n- **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker).\n- **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps.\n- **YAML File**: A file format used to define and configure [workflows][Workflow].\n\n## Woodpecker architecture\n\n![Woodpecker architecture](architecture.svg)\n\n## Pipeline, workflow & step\n\n![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg)\n\n## Conventions\n\nSometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker:\n\n- Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()`\n- Use the term **pipelines** instead of the previous **builds**\n- Use the term **steps** instead of the previous **jobs**\n- Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users\n\n<!-- References -->\n\n[Event]: ../20-workflow-syntax.md#event\n[Pipeline]: ../20-workflow-syntax.md\n[Workflow]: ../25-workflows.md\n[Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md\n[Plugin]: ../51-plugins/51-overview.md\n[Workspace]: ../20-workflow-syntax.md#workspace\n[Matrix]: ../30-matrix-workflows.md\n[Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md\n[Local]: ../../30-administration/10-configuration/11-backends/30-local.md\n[Extension]: ../72-extensions/index.md\n"
  },
  {
    "path": "docs/docs/20-usage/15-terminology/pipeline-workflow-step.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 97,\n      \"versionNonce\": 257762037,\n      \"isDeleted\": false,\n      \"id\": \"Y3hYdpX9r1qWfyHWs7AXT\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 393.622323134362,\n      \"y\": 336.02197155458475,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 366.3936710429598,\n      \"height\": 499.95605689083004,\n      \"seed\": 875444373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 67,\n      \"versionNonce\": 369556565,\n      \"isDeleted\": false,\n      \"id\": \"g1Eb010Kx_KFryVqNYWBQ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 520.0116988873679,\n      \"y\": 363.32095846456355,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 99.626953125,\n      \"height\": 32.199999999999996,\n      \"seed\": 1466195445,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Pipeline\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 314,\n      \"versionNonce\": 1983028731,\n      \"isDeleted\": false,\n      \"id\": \"9o-DNP0YdlIGVz1kEm_hW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 407.1590381712276,\n      \"y\": 410.9252244837219,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1869535061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 156,\n      \"versionNonce\": 1495247317,\n      \"isDeleted\": false,\n      \"id\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 490.3194993196821,\n      \"y\": 473.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 111355061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"ya0JzDo-4oscHIq87TZ_D\"\n        },\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 156,\n      \"versionNonce\": 1469425461,\n      \"isDeleted\": false,\n      \"id\": \"ya0JzDo-4oscHIq87TZ_D\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 566.0118821321821,\n      \"y\": 478.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1084671509,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 236,\n      \"versionNonce\": 1535319541,\n      \"isDeleted\": false,\n      \"id\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 491.2218643672577,\n      \"y\": 519.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 812596085,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"FRby8A9aUiKvHpM5mCdDN\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 231,\n      \"versionNonce\": 28677973,\n      \"isDeleted\": false,\n      \"id\": \"FRby8A9aUiKvHpM5mCdDN\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 583.0324112422577,\n      \"y\": 524.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1849820373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 291,\n      \"versionNonce\": 571598005,\n      \"isDeleted\": false,\n      \"id\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 489.6426911083554,\n      \"y\": 567.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1840554549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"UOwxmKIS0W62CFt_ffEy4\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 4032021,\n      \"isDeleted\": false,\n      \"id\": \"UOwxmKIS0W62CFt_ffEy4\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 581.4532379833554,\n      \"y\": 572.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 330077077,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 296,\n      \"versionNonce\": 1539516059,\n      \"isDeleted\": false,\n      \"id\": \"9laL3864YWOna6NQlVDqq\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 630.0635849044402,\n      \"y\": 383.14314287821776,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 294.3024370154917,\n      \"height\": 36.656016722015465,\n      \"seed\": 207575285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -1.000156025347643,\n        \"gap\": 27.782081605504118\n      },\n      \"endBinding\": {\n        \"elementId\": \"vS2PNUbmeBe3EPxl-dID8\",\n        \"focus\": 0.7761987167055517,\n        \"gap\": 8.978940924346716\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          294.3024370154917,\n          -36.656016722015465\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 249,\n      \"versionNonce\": 2076402229,\n      \"isDeleted\": false,\n      \"id\": \"vS2PNUbmeBe3EPxl-dID8\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 933.3449628442786,\n      \"y\": 336.02200598023114,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 301.298828125,\n      \"height\": 46,\n      \"seed\": 1632793173,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 751,\n      \"versionNonce\": 1371044827,\n      \"isDeleted\": false,\n      \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 751.1619011845514,\n      \"y\": 440.8355079324799,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 160.46519124360202,\n      \"height\": 2.2452348338335923,\n      \"seed\": 1331388341,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -0.6591700594229558,\n        \"gap\": 3.8807513696519322\n      },\n      \"endBinding\": {\n        \"elementId\": \"wfFvnFZuh0npL9hh0ez7o\",\n        \"focus\": 0.7652411053273549,\n        \"gap\": 20.75618622779257\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          160.46519124360202,\n          -2.2452348338335923\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 819540565,\n      \"isDeleted\": false,\n      \"id\": \"TbejdIYo_qNDw15yLP2IB\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 406.0812257713851,\n      \"y\": 626.8305540252475,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1553965333,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 466,\n      \"versionNonce\": 663477,\n      \"isDeleted\": false,\n      \"id\": \"wfFvnFZuh0npL9hh0ez7o\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 932.383278655946,\n      \"y\": 424.0107569968011,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 481.2890625,\n      \"height\": 115,\n      \"seed\": 781497973,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 110\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 464,\n      \"versionNonce\": 734626075,\n      \"isDeleted\": false,\n      \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 741.0645380446722,\n      \"y\": 492.31283255558515,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 178.4459423531871,\n      \"height\": 83.08707392565111,\n      \"seed\": 536879061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"q4TKpiq2KAwPaz19GdhtK\",\n        \"focus\": -0.7697471991854113,\n        \"gap\": 3.7450387249900814\n      },\n      \"endBinding\": {\n        \"elementId\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n        \"focus\": -0.7822252364700005,\n        \"gap\": 8.360835317635974\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          178.4459423531871,\n          83.08707392565111\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 327,\n      \"versionNonce\": 371646421,\n      \"isDeleted\": false,\n      \"id\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 927.8713157154953,\n      \"y\": 563.2132686484658,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 491.357421875,\n      \"height\": 46,\n      \"seed\": 385310005,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 91,\n      \"versionNonce\": 1180085909,\n      \"isDeleted\": false,\n      \"id\": \"0tGx2VdJLNf7W6HD76dtO\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 427.6895298601876,\n      \"y\": 432.3583566254258,\n      \"strokeColor\": \"#9c36b5\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 143.876953125,\n      \"height\": 23,\n      \"seed\": 450883221,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"build\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"build\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 338,\n      \"versionNonce\": 957223925,\n      \"isDeleted\": false,\n      \"id\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.7251825950889,\n      \"y\": 685.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 711939061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"8EqaPnZX2CgLaF08UNZZg\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 340,\n      \"versionNonce\": 510774613,\n      \"isDeleted\": false,\n      \"id\": \"8EqaPnZX2CgLaF08UNZZg\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 563.4175654075889,\n      \"y\": 690.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1370164565,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 421,\n      \"versionNonce\": 97999541,\n      \"isDeleted\": false,\n      \"id\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 488.62754764266447,\n      \"y\": 731.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 2145950389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"DX10t075MMDu7BLtuUaij\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 417,\n      \"versionNonce\": 2011446293,\n      \"isDeleted\": false,\n      \"id\": \"DX10t075MMDu7BLtuUaij\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 580.4380945176645,\n      \"y\": 736.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 500005909,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 475,\n      \"versionNonce\": 1284370805,\n      \"isDeleted\": false,\n      \"id\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.04837438376217,\n      \"y\": 779.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1666134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"-xogFSFcP-Vv5cuOSFm8T\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 476,\n      \"versionNonce\": 1092221653,\n      \"isDeleted\": false,\n      \"id\": \"-xogFSFcP-Vv5cuOSFm8T\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 578.8589212587622,\n      \"y\": 784.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1840462549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 125,\n      \"versionNonce\": 1310578741,\n      \"isDeleted\": false,\n      \"id\": \"N1a9yL7Pts16hUKY9-vhw\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 424.78852030984035,\n      \"y\": 646.2446482189896,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 133.857421875,\n      \"height\": 23,\n      \"seed\": 361699381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"test\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"test\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 184,\n      \"versionNonce\": 2127603131,\n      \"isDeleted\": false,\n      \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 737.454940151797,\n      \"y\": 535.9141784615474,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 190.41665096887027,\n      \"height\": 112.96427727851824,\n      \"seed\": 80234901,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.8392895251910331,\n        \"gap\": 2.0300115262207328\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          190.41665096887027,\n          112.96427727851824\n        ]\n      ]\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 327,\n      \"versionNonce\": 780710651,\n      \"isDeleted\": false,\n      \"id\": \"379hO6Dc5rygB38JgDbVo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 738.8084877231549,\n      \"y\": 591.3526691276127,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 186.8066399682357,\n      \"height\": 57.68023784868956,\n      \"seed\": 211046133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"2WwuMWX7YawqK0i1rDPJo\",\n        \"focus\": -0.5776522830934517,\n        \"gap\": 2.1657966147995467\n      },\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.7269489945238884,\n        \"gap\": 4.286474955497397\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          186.8066399682357,\n          57.68023784868956\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 285,\n      \"versionNonce\": 1165977685,\n      \"isDeleted\": false,\n      \"id\": \"0TjxOfERekC91N3yciQIq\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 929.901602646888,\n      \"y\": 632.4760859429873,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 518.076171875,\n      \"height\": 46,\n      \"seed\": 997763157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/docs/20-usage/20-workflow-syntax.md",
    "content": "# Workflow syntax\n\nThe Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status.\n\n:::note\nAn exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run.\n:::\n\n:::note\nWe support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility.\nRead more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3)\n:::\n\nExample steps:\n\n```yaml\nsteps:\n  - name: backend\n    image: golang\n    commands:\n      - go build\n      - go test\n  - name: frontend\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\nIn the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary.\n\nThe name is optional, if not added the steps will be numerated.\n\nAnother way to name a step is by using dictionaries:\n\n```yaml\nsteps:\n  backend:\n    image: golang\n    commands:\n      - go build\n      - go test\n  frontend:\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\n## Skip Commits\n\nWoodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive.\n\n```bash\ngit commit -m \"updated README [CI SKIP]\"\n```\n\n## Steps\n\nEvery step of your workflow executes commands inside a specified container.<br>\nThe defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).<br>\nThe associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\n### File changes are incremental\n\n- Woodpecker clones the source code in the beginning of the workflow\n- Changes to files are persisted through steps as the same volume is mounted to all steps\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"test content\" > myfile\n  - name: a-test-step\n    image: debian\n    commands:\n      - cat myfile\n```\n\n### `image`\n\nWoodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers.\n\nWhen using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands.\n\n```diff\n steps:\n   - name: build\n+    image: golang:1.6\n     commands:\n       - go build\n       - go test\n\n   - name: prettier\n+    image: woodpeckerci/plugin-prettier\n\n services:\n   - name: database\n+    image: mysql\n```\n\nWoodpecker supports any valid Docker image from any Docker registry:\n\n```yaml\nimage: golang\nimage: golang:1.7\nimage: library/golang:1.7\nimage: index.docker.io/library/golang\nimage: index.docker.io/library/golang:1.7\n```\n\nLearn more how you can use images from [different registries](./41-registries.md).\n\n### `pull`\n\nBy default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present.\n\nTo always pull the latest image when updates are available, use the `pull` option:\n\n```diff\n steps:\n   - name: build\n     image: golang:latest\n+    pull: true\n```\n\n### `commands`\n\nCommands of every step are executed serially as if you would enter them into your local shell.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\nThere is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script:\n\n```bash\n#!/bin/sh\nset -e\n\ngo build\ngo test\n```\n\nThe above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed:\n\n```bash\ndocker run --entrypoint=build.sh golang\n```\n\n:::note\nOnly build steps can define commands. You cannot use commands with plugins or services.\n:::\n\n### `entrypoint`\n\nAllows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `[\"/bin/sh\", \"-c\"]`).\n\nIf you define [`commands`](#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\nYou can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`.\n\n### `environment`\n\nWoodpecker provides the ability to pass environment variables to individual steps.\n\nFor more details, check the [environment docs](./50-environment.md).\n\n### `failure`\n\nSome of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n       - go build\n       - go test\n+    failure: ignore\n```\n\nIf you would like to cancel the full pipeline once the step fails, you can set `failure: cancel`.\n\n### `when` - Conditional Execution\n\nWoodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true.\nA condition can be a check like:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - event: pull_request\n+        repo: test/test\n+      - event: push\n+        branch: main\n```\n\nThe `prettier` step is executed if one of these conditions is met:\n\n1. The pipeline is executed from a pull request in the repo `test/test`\n2. The pipeline is executed from a push to `main`\n\n#### `repo`\n\nExample conditional execution by repository:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - repo: test/test\n```\n\n#### `branch`\n\n:::note\nBranch conditions are not applied to tags.\n:::\n\nExample conditional execution by branch:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - branch: main\n```\n\n> The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only.\n\nExecute a step if the branch is `main` or `develop`:\n\n```yaml\nwhen:\n  - branch: [main, develop]\n```\n\nExecute a step if the branch starts with `prefix/*`:\n\n```yaml\nwhen:\n  - branch: prefix/*\n```\n\nThe branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples:\n\n- `*\\\\/*` to match patterns with exactly 1 `/`\n- `*\\\\/**` to match patters with at least 1 `/`\n- `*` to match patterns without `/`\n- `**` to match everything\n\nExecute a step using custom include and exclude logic:\n\n```yaml\nwhen:\n  - branch:\n      include: [main, release/*]\n      exclude: [release/1.0.0, release/1.1.*]\n```\n\n#### `event`\n\nThe available events are:\n\n- `push`: triggered when a commit is pushed to a branch.\n- `pull_request`: triggered when a pull request is opened or a new commit is pushed to it.\n- `pull_request_closed`: triggered when a pull request is closed or merged.\n- `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...).\n- `tag`: triggered when a tag is pushed.\n- `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).)\n- `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.)\n- `cron`: triggered when a cron job is executed.\n- `manual`: triggered when a user manually triggers a pipeline.\n\nExecute a step if the build event is a `tag`:\n\n```yaml\nwhen:\n  - event: tag\n```\n\nExecute a step if the pipeline event is a `push` to a specified branch:\n\n```diff\nwhen:\n  - event: push\n+   branch: main\n```\n\nExecute a step for multiple events:\n\n```yaml\nwhen:\n  - event: [push, tag, deployment]\n```\n\n#### `cron`\n\nThis filter **only** applies to cron events and filters based on the name of a cron job.\n\nMake sure to have a `event: cron` condition in the `when`-filters as well.\n\n```yaml\nwhen:\n  - event: cron\n    cron: sync_* # name of your cron job\n```\n\n[Read more about cron](./45-cron.md)\n\n#### `ref`\n\nThe `ref` filter compares the git reference against which the workflow is executed.\nThis allows you to filter, for example, tags that must start with **v**:\n\n```yaml\nwhen:\n  - event: tag\n    ref: refs/tags/v*\n```\n\n#### `status`\n\nBy default, steps only run when the workflow has succeeded up to that point,<br>\nwhich is equivalent to `status: [ success ]`.\n\nThe `status` filter lets you override this behavior.\nThe only accepted values are `success` and `failure`.\n\nA common use case is executing a step on failure, such as sending notifications for a failed workflow/pipeline.\nTo run a step regardless of outcome, list both values:\n\n```diff\n steps:\n   - name: notify\n     image: alpine\n+    when:\n+      - status: [ success, failure ]\n```\n\nThe filter is aware of the other filters. If you want to run on failures if the event is `tag`, but if it's a `pull_request`, run it on both success and failure:\n\n```diff\n when:\n+  - event: tag\n+    status: [ failure ]\n+  - event: pull_request\n+    status: [ success, failure ]\n```\n\nIf there's no matching filter at all or all matching filters don't have set `status`, it will use the default, which means it runs on success only. In the example above this will happen if the event is neither `tag` nor `pull_request`.\n\n#### `platform`\n\n:::note\nThis condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch.\n:::\n\nExecute a step for a specific platform:\n\n```yaml\nwhen:\n  - platform: linux/amd64\n```\n\nExecute a step for a specific platform using wildcards:\n\n```yaml\nwhen:\n  - platform: [linux/*, windows/amd64]\n```\n\n#### `matrix`\n\nExecute a step for a single matrix permutation:\n\n```yaml\nwhen:\n  - matrix:\n      GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n```\n\n#### `instance`\n\nExecute a step only on a certain Woodpecker instance matching the specified hostname:\n\n```yaml\nwhen:\n  - instance: stage.woodpecker.company.com\n```\n\n#### `path`\n\n:::info\nPath conditions are applied only to **push** and **pull_request** events.\n:::\n\nExecute a step only on a pipeline with certain files being changed:\n\n```yaml\nwhen:\n  - path: 'src/*'\n```\n\nYou can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`.\n\nFor pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases.\n\n```yaml\nwhen:\n  - path:\n      include: ['.woodpecker/*.yaml', '*.ini']\n      exclude: ['*.md', 'docs/**']\n      ignore_message: '[ALL]'\n      on_empty: true\n```\n\n:::info\nPassing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting.\n:::\n\n#### `evaluate`\n\nExecute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression.\n\nThe expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library.\n\nRun on pushes to the default branch for the repository `owner/repo`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'\n```\n\nRun on commits created by user `woodpecker-ci`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n```\n\nSkip all commits containing `please ignore me` in the commit message:\n\n```yaml\nwhen:\n  - evaluate: 'not (CI_COMMIT_MESSAGE contains \"please ignore me\")'\n```\n\nRun on pull requests with the label `deploy`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"deploy\"'\n```\n\nSkip step only if `SKIP=true`, run otherwise or if undefined:\n\n```yaml\nwhen:\n  - evaluate: 'SKIP != \"true\"'\n```\n\n### `depends_on`\n\nNormally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`:\n\n```diff\n steps:\n   - name: build # build will be executed immediately\n     image: golang\n     commands:\n       - go build\n\n   - name: deploy\n     image: woodpeckerci/plugin-s3\n     settings:\n       bucket: my-bucket-name\n       source: some-file-name\n       target: /target/some-file\n+    depends_on: [build, test] # deploy will be executed after build and test finished\n\n   - name: test # test will be executed immediately as no dependencies are set\n     image: golang\n     commands:\n       - go test\n```\n\n:::note\nYou can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified.\n\n```yaml\nsteps:\n  - name: check code format\n    image: mstruebing/editorconfig-checker\n    depends_on: [] # enable parallel steps\n  ...\n```\n\n:::\n\n### `volumes`\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\nFor more details check the [volumes docs](./70-volumes.md).\n\n### `detach`\n\nWoodpecker gives the ability to detach steps to run them in background until the workflow finishes.\n\nFor more details check the [service docs](./60-services.md#detachment).\n\n### `directory`\n\nUsing `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run.\n\n### `backend_options`\n\nWith `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes.\n\nFurther details can be found in the documentation of the used backend:\n\n- [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration)\n- [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration)\n\n## `services`\n\nWoodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow.\n\nFor more details check the [services docs](./60-services.md).\n\n## `workspace`\n\nThe workspace defines the shared volume and working directory shared by all workflow steps.\nThe default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`).\nSo an example would be `/woodpecker/src/github.com/octocat/hello-world`.\n\nThe workspace can be customized using the workspace block in the YAML file:\n\n```diff\n+workspace:\n+  base: /go\n+  path: src/github.com/octocat/hello-world\n\n steps:\n   - name: build\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n```\n\n:::note\nPlugins will always have the workspace base at `/woodpecker`\n:::\n\nThe base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps.\n\n```diff\n workspace:\n+  base: /go\n   path: src/github.com/octocat/hello-world\n\n steps:\n   - name: deps\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n   - name: build\n     image: node:latest\n     commands:\n       - go build\n```\n\nThis would be equivalent to the following docker commands:\n\n```bash\ndocker volume create my-named-volume\n\ndocker run --volume=my-named-volume:/go golang:latest\ndocker run --volume=my-named-volume:/go node:latest\n```\n\nThe path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path.\n\n```diff\n workspace:\n   base: /go\n+  path: src/github.com/octocat/hello-world\n```\n\n```bash\ngit clone https://github.com/octocat/hello-world \\\n  /go/src/github.com/octocat/hello-world\n```\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `matrix`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations.\n\nFor more details check the [matrix build docs](./30-matrix-workflows.md).\n\n## `labels`\n\nYou can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent.\n\nTo specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo.\n\nWorkflow labels with an empty value are ignored.\nBy default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`.\n\n:::warning\nLabels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition.\n:::\n\nYou can add additional labels as a key value map:\n\n```diff\n+labels:\n+  location: europe # only agents with `location=europe` or `location=*` will be used\n+  weather: sun\n+  hostname: \"\" # this label will be ignored as it is empty\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\n### Filter by platform\n\nTo configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key.\nHave a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`.\n\nExample:\n\nAssuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`.\n\n```diff\n+labels:\n+  platform: linux/arm64\n\n steps:\n   [...]\n```\n\n## `variables`\n\nWoodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration.\n\nFor more details and examples check the [Advanced usage docs](./90-advanced-usage.md)\n\n## `clone`\n\nWoodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step.\n\nYou can manually configure the clone step in your workflow to customize it:\n\n```diff\n+clone:\n+  git:\n+    image: woodpeckerci/plugin-git\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\nExample configuration to override the depth:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n+    settings:\n+      partial: false\n+      depth: 50\n```\n\nExample configuration to use a custom clone plugin:\n\n```diff\n clone:\n   - name: git\n+    image: octocat/custom-git-plugin\n```\n\n### Git Submodules\n\nTo use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`:\n\n```diff\n [submodule \"my-module\"]\n path = my-module\n-url = git@github.com:octocat/my-module.git\n+url = https://github.com/octocat/my-module.git\n```\n\nTo use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n     settings:\n       recursive: true\n+      submodule_override:\n+        my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  ...\n```\n\n## `skip_clone`\n\n:::warning\nThe default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`.\n:::\n\nBy default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using:\n\n```yaml\nskip_clone: true\n```\n\n## `when` - Global workflow conditions\n\nWoodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue.\n\nFor more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution).\n\nExample conditional execution by branch:\n\n```diff\n+when:\n+  branch: main\n+\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n```\n\nThe workflow now triggers on `main`, but also if the target branch of a pull request is `main`.\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `depends_on`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword.\n\n## Advanced network options for steps\n\n:::warning\nOnly allowed if 'Trusted Network' option is enabled in repo settings by an admin.\n:::\n\n### `dns`\n\nIf the backend engine understands to change the DNS server and lookup domain,\nthis options will be used to alter the default DNS config to a custom one for a specific step.\n\n```yaml\nsteps:\n  - name: build\n    image: plugin/abc\n    dns: 1.2.3.4\n    dns_search: 'internal.company'\n```\n\n## Privileged mode\n\nWoodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities.\n\n:::info\nPrivileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     environment:\n       - DOCKER_HOST=tcp://docker:2375\n     commands:\n       - docker --tls=false ps\n\n services:\n   - name: docker\n     image: docker:dind\n     commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false\n+    privileged: true\n```\n"
  },
  {
    "path": "docs/docs/20-usage/25-workflows.md",
    "content": "# Workflows\n\nA pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps.\n\nIn case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow.\n\nBy placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored.\n\nYou can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md).\n\n## Benefits of using workflows\n\n- faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote\n- better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying\n- utilizing more agents to speed up the execution of the whole pipeline\n\n## Example workflow definition\n\n:::warning\nPlease note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow.\nIf you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket).\n:::\n\n```bash\n.woodpecker/\n├── build.yaml\n├── deploy.yaml\n├── lint.yaml\n└── test.yaml\n```\n\n```yaml title=\".woodpecker/build.yaml\"\nsteps:\n  - name: build\n    image: debian:stable-slim\n    commands:\n      - echo building\n      - sleep 5\n```\n\n```yaml title=\".woodpecker/deploy.yaml\"\nsteps:\n  - name: deploy\n    image: debian:stable-slim\n    commands:\n      - echo deploying\n\ndepends_on:\n  - lint\n  - build\n  - test\n```\n\n```yaml title=\".woodpecker/test.yaml\"\nsteps:\n  - name: test\n    image: debian:stable-slim\n    commands:\n      - echo testing\n      - sleep 5\n\ndepends_on:\n  - build\n```\n\n```yaml title=\".woodpecker/lint.yaml\"\nsteps:\n  - name: lint\n    image: debian:stable-slim\n    commands:\n      - echo linting\n      - sleep 5\n```\n\n## Status lines\n\nEach workflow will report its own status back to your forge.\n\n## Flow control\n\nThe workflows run in parallel on separate agents and share nothing.\n\nDependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully.\n\nThe name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`.\n\n```diff\n steps:\n   - name: deploy\n     image: debian:stable-slim\n     commands:\n       - echo deploying\n\n+depends_on:\n+  - lint\n+  - build\n+  - test\n```\n\nWorkflows that need to run even on failures should set the `status` filter.\n\n```diff\n steps:\n   - name: notify\n     image: debian:stable-slim\n     commands:\n       - echo notifying\n\n depends_on:\n   - deploy\n\n+when:\n+  - status: [ success, failure ]\n```\n\nThis works just like the [`status` filter for steps](./20-workflow-syntax.md#status).\n\n:::info\nSome workflows don't need the source code, like creating a notification on failure.\nRead more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone)\n:::\n"
  },
  {
    "path": "docs/docs/20-usage/30-matrix-workflows.md",
    "content": "# Matrix workflows\n\nWoodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations.\n\n:::warning\nWoodpecker currently supports a maximum of **27 matrix axes** per workflow.\nIf your matrix exceeds this number, any additional axes will be silently ignored.\n:::\n\nExample matrix definition:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  REDIS_VERSION:\n    - 2.6\n    - 2.8\n    - 3.0\n```\n\nExample matrix definition containing only specific combinations:\n\n```yaml\nmatrix:\n  include:\n    - GO_VERSION: 1.4\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.6\n      REDIS_VERSION: 3.0\n```\n\n## Interpolation\n\nMatrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:8\n    - mysql:5\n    - mariadb:10.1\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go get\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: ${DATABASE}\n```\n\nExample YAML file after injecting the matrix parameters:\n\n```diff\n steps:\n   - name: build\n-    image: golang:${GO_VERSION}\n+    image: golang:1.4\n     commands:\n       - go get\n       - go build\n       - go test\n+    environment:\n+      - GO_VERSION=1.4\n+      - DATABASE=mysql:8\n\n services:\n   - name: database\n-    image: ${DATABASE}\n+    image: mysql:8\n```\n\n## Examples\n\n### Example matrix pipeline based on Docker image tag\n\n```yaml\nmatrix:\n  TAG:\n    - 1.7\n    - 1.8\n    - latest\n\nsteps:\n  - name: build\n    image: golang:${TAG}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline based on container image\n\n```yaml\nmatrix:\n  IMAGE:\n    - golang:1.7\n    - golang:1.8\n    - golang:latest\n\nsteps:\n  - name: build\n    image: ${IMAGE}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline using multiple platforms\n\n```yaml\nmatrix:\n  platform:\n    - linux/amd64\n    - linux/arm64\n\nlabels:\n  platform: ${platform}\n\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n\n  - name: test-arm-only\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n      - echo \"Arm is cool!\"\n    when:\n      platform: linux/arm*\n```\n\n:::note\nIf you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector).\n:::\n"
  },
  {
    "path": "docs/docs/20-usage/40-secrets.md",
    "content": "# Secrets\n\nWoodpecker provides the ability to store named variables in a central secret store.\nThese secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`.\n\nThere are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins):\n\n1. **Repository secrets**: Available for all pipelines of a repository.\n1. **Organization secrets**: Available for all pipelines of an organization.\n1. **Global secrets**: Can only be set by instance administrators.\n   Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution.\n\nIn addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources.\n\n:::warning\nWoodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs.\n:::\n\n## Usage\n\nYou can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax.\n\nThe following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`:\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n     commands:\n+      - echo \"The secret is $TOKEN_ENV\"\n+    environment:\n+      TOKEN_ENV:\n+        from_secret: secret_token\n```\n\nThe same syntax can be used to pass secrets to (plugin) settings.\nA secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details).\n`PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution.\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n+    settings:\n+      TOKEN:\n+        from_secret: secret_token\n```\n\n### Escape secrets\n\nPlease note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts.\nIf secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing.\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n-      - echo ${TOKEN_ENV}\n+      - echo $${TOKEN_ENV}\n     environment:\n       TOKEN_ENV:\n         from_secret: secret_token\n```\n\n### Events filter\n\nBy default, secrets are not exposed to pull requests.\nHowever, you can change this behavior by creating the secret and enabling the `pull_request` event type.\nThis can be configured either via the UI or via the CLI.\n\n:::warning\nBe careful when exposing secrets for pull requests.\nIf your repository is public and accepts pull requests from everyone, your secrets may be at risk.\nMalicious actors could take advantage of this to expose your secrets or transfer them to an external location.\n:::\n\n### Plugins filter\n\nTo prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins.\nIf enabled, they are not available to any other plugins.\nPlugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets.\n\n:::tip\nIf you specify a tag, the filter will take it into account.\nHowever, if the same image appears several times in the list, the least privileged entry will take precedence.\nFor example, an image without a tag will allow all tags, even if it contains another entry with a tag attached.\n:::\n\n![plugins filter](./secrets-plugins-filter.png)\n\n## CLI\n\nIn addition to the UI, secrets can also be managed using the CLI.\n\nCreate the secret with the default settings.\nThe secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events).\n\n```bash\nwoodpecker-cli repo secret add \\\n  --repository octocat/hello-world \\\n  --name aws_access_key_id \\\n  --value <value>\n```\n\nCreate the secret and limit it to a single image:\n\n```diff\n woodpecker-cli secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secrets and limit it to a set of images:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n+  --image woodpeckerci/plugin-docker-buildx \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secret and enable it for multiple hook events:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n   --image woodpeckerci/plugin-s3 \\\n+  --event pull_request \\\n+  --event push \\\n+  --event tag \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nSecrets can be loaded from a file using the syntax `@`.\nThis method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example):\n\n```diff\n woodpecker-cli repo secret add \\\n   -repository octocat/hello-world \\\n   -name ssh_key \\\n+  -value @/root/ssh/id_rsa\n```\n"
  },
  {
    "path": "docs/docs/20-usage/41-registries.md",
    "content": "# Registries\n\nWoodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries.\n\n## Images from private registries\n\nYou must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file.\n\nThese credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin.\n\nExample configuration using a private image:\n\n```diff\n steps:\n   - name: build\n+    image: gcr.io/custom/golang\n     commands:\n       - go build\n       - go test\n```\n\nWoodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers.\n\nExample registry hostnames:\n\n- Image `gcr.io/foo/bar` has hostname `gcr.io`\n- Image `foo/bar` has hostname `docker.io`\n- Image `qux.com:8000/foo/bar` has hostname `qux.com:8000`\n\nExample registry hostname matching logic:\n\n- Hostname `gcr.io` matches image `gcr.io/foo/bar`\n- Hostname `docker.io` matches `golang`\n- Hostname `docker.io` matches `library/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang:latest`\n\n## Global registry support\n\nTo make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config).\n\n## GCR registry support\n\nFor specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file).\n\n## Local Images\n\n:::warning\nFor this, privileged rights are needed only available to admins. In addition, this only works when using a single agent.\n:::\n\nIt's possible to build a local image by mounting the docker socket as a volume.\n\nWith a `Dockerfile` at the root of the project:\n\n```yaml\nsteps:\n  - name: build-image\n    image: docker\n    commands:\n      - docker build --rm -t local/project-image .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  - name: build-project\n    image: local/project-image\n    commands:\n      - ./build.sh\n```\n"
  },
  {
    "path": "docs/docs/20-usage/45-cron.md",
    "content": "# Cron\n\nTo configure cron jobs you need at least push access to the repository.\n\n## Add a new cron job\n\n1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job:\n\n   ```diff\n    steps:\n      - name: sync_locales\n        image: weblate_sync\n        settings:\n          url: example.com\n          token:\n            from_secret: weblate_token\n   +    when:\n   +      event: cron\n   +      cron: \"name of the cron job\" # if you only want to execute this step by a specific cron job\n   ```\n\n2. Create a new cron job in the repository settings:\n\n   ![cron settings](./cron-settings.png)\n\n   The supported schedule syntax can be found at <https://pkg.go.dev/github.com/gdgvda/cron#hdr-CRON_Expression_Format>. If you need general understanding of the cron syntax <https://it-tools.tech/crontab-generator> is a good place to start and experiment.\n\n   Examples: `@every 5m`, `@daily`, `30 * * * *` ...\n"
  },
  {
    "path": "docs/docs/20-usage/50-environment.md",
    "content": "# Environment variables\n\nWoodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables:\n\n```diff\n steps:\n   - name: build\n     image: golang\n+    environment:\n+      CGO: 0\n+      GOOS: linux\n+      GOARCH: amd64\n     commands:\n       - go build\n       - go test\n```\n\nPlease note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section.\n\n```diff\n steps:\n   - name: build\n     image: golang\n-    environment:\n-      - PATH=$PATH:/go\n     commands:\n+      - export PATH=$PATH:/go\n       - go build\n       - go test\n```\n\n:::warning\n`${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped:\n:::\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n-      - export PATH=${PATH}:/go\n+      - export PATH=$${PATH}:/go\n       - go build\n       - go test\n```\n\n## Built-in environment variables\n\nThis is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime.\n\n| NAME                               | Description                                                                                                        | Example                                                                                                    |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |\n| `CI`                               | CI environment name                                                                                                | `woodpecker`                                                                                               |\n|                                    | **Repository**                                                                                                     |                                                                                                            |\n| `CI_REPO`                          | repository full name `<owner>/<name>`                                                                              | `john-doe/my-repo`                                                                                         |\n| `CI_REPO_OWNER`                    | repository owner                                                                                                   | `john-doe`                                                                                                 |\n| `CI_REPO_NAME`                     | repository name                                                                                                    | `my-repo`                                                                                                  |\n| `CI_REPO_REMOTE_ID`                | repository remote ID, is the UID it has in the forge                                                               | `82`                                                                                                       |\n| `CI_REPO_URL`                      | repository web URL                                                                                                 | `https://git.example.com/john-doe/my-repo`                                                                 |\n| `CI_REPO_CLONE_URL`                | repository clone URL                                                                                               | `https://git.example.com/john-doe/my-repo.git`                                                             |\n| `CI_REPO_CLONE_SSH_URL`            | repository SSH clone URL                                                                                           | `git@git.example.com:john-doe/my-repo.git`                                                                 |\n| `CI_REPO_DEFAULT_BRANCH`           | repository default branch                                                                                          | `main`                                                                                                     |\n| `CI_REPO_PRIVATE`                  | repository is private                                                                                              | `true`                                                                                                     |\n| `CI_REPO_TRUSTED_NETWORK`          | repository has trusted network access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_VOLUMES`          | repository has trusted volumes access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_SECURITY`         | repository has trusted security access                                                                             | `false`                                                                                                    |\n|                                    | **Current Commit**                                                                                                 |                                                                                                            |\n| `CI_COMMIT_SHA`                    | commit SHA                                                                                                         | `eba09b46064473a1d345da7abf28b477468e8dbd`                                                                 |\n| `CI_COMMIT_REF`                    | commit ref                                                                                                         | `refs/heads/main`                                                                                          |\n| `CI_COMMIT_REFSPEC`                | commit ref spec                                                                                                    | `issue-branch:main`                                                                                        |\n| `CI_COMMIT_BRANCH`                 | commit branch (equals target branch for pull requests)                                                             | `main`                                                                                                     |\n| `CI_COMMIT_SOURCE_BRANCH`          | commit source branch (set only for pull request events)                                                            | `issue-branch`                                                                                             |\n| `CI_COMMIT_TARGET_BRANCH`          | commit target branch (set only for pull request events)                                                            | `main`                                                                                                     |\n| `CI_COMMIT_TAG`                    | commit tag name (empty if event is not `tag`)                                                                      | `v1.10.3`                                                                                                  |\n| `CI_COMMIT_PULL_REQUEST`           | commit pull request number (set only for pull request events)                                                      | `1`                                                                                                        |\n| `CI_COMMIT_PULL_REQUEST_LABELS`    | labels assigned to pull request (set only for pull request events)                                                 | `server`                                                                                                   |\n| `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events)                  | `summer-sprint`                                                                                            |\n| `CI_COMMIT_MESSAGE`                | commit message                                                                                                     | `Initial commit`                                                                                           |\n| `CI_COMMIT_AUTHOR`                 | commit author username                                                                                             | `john-doe`                                                                                                 |\n| `CI_COMMIT_AUTHOR_EMAIL`           | commit author email address                                                                                        | `john-doe@example.com`                                                                                     |\n| `CI_COMMIT_PRERELEASE`             | release is a pre-release (empty if event is not `release`)                                                         | `false`                                                                                                    |\n|                                    | **Current pipeline**                                                                                               |                                                                                                            |\n| `CI_PIPELINE_NUMBER`               | pipeline number                                                                                                    | `8`                                                                                                        |\n| `CI_PIPELINE_PARENT`               | number of parent pipeline                                                                                          | `0`                                                                                                        |\n| `CI_PIPELINE_STATUS`               | state of the workflow right before the step was started                                                            | `success`, `failure`                                                                                       |\n| `CI_PIPELINE_EVENT`                | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                            | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PIPELINE_EVENT_REASON`         | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change              | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PIPELINE_URL`                  | link to the web UI for the pipeline                                                                                | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n| `CI_PIPELINE_FORGE_URL`            | link to the forge's web UI for the commit(s) or tag that triggered the pipeline                                    | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd`                 |\n| `CI_PIPELINE_DEPLOY_TARGET`        | pipeline deploy target for `deployment` events                                                                     | `production`                                                                                               |\n| `CI_PIPELINE_DEPLOY_TASK`          | pipeline deploy task for `deployment` events                                                                       | `migration`                                                                                                |\n| `CI_PIPELINE_CREATED`              | pipeline created UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_STARTED`              | pipeline started UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_FILES`                | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[\".woodpecker.yml\",\"README.md\"]`                                                                    |\n| `CI_PIPELINE_AUTHOR`               | pipeline author username                                                                                           | `octocat`                                                                                                  |\n| `CI_PIPELINE_AVATAR`               | pipeline author avatar                                                                                             | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | **Current workflow**                                                                                               |                                                                                                            |\n| `CI_WORKFLOW_NAME`                 | workflow name                                                                                                      | `release`                                                                                                  |\n|                                    | **Current step**                                                                                                   |                                                                                                            |\n| `CI_STEP_NAME`                     | step name                                                                                                          | `build package`                                                                                            |\n| `CI_STEP_TYPE`                     | step type (`commands`, `plugin`, `service`, `clone` or `cache`)                                                    | `commands`                                                                                                 |\n| `CI_STEP_NUMBER`                   | step number                                                                                                        | `0`                                                                                                        |\n| `CI_STEP_STARTED`                  | step started UNIX timestamp                                                                                        | `1722617519`                                                                                               |\n| `CI_STEP_URL`                      | URL to step in UI                                                                                                  | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n|                                    | **Previous commit**                                                                                                |                                                                                                            |\n| `CI_PREV_COMMIT_SHA`               | previous commit SHA                                                                                                | `15784117e4e103f36cba75a9e29da48046eb82c4`                                                                 |\n| `CI_PREV_COMMIT_REF`               | previous commit ref                                                                                                | `refs/heads/main`                                                                                          |\n| `CI_PREV_COMMIT_REFSPEC`           | previous commit ref spec                                                                                           | `issue-branch:main`                                                                                        |\n| `CI_PREV_COMMIT_BRANCH`            | previous commit branch                                                                                             | `main`                                                                                                     |\n| `CI_PREV_COMMIT_SOURCE_BRANCH`     | previous commit source branch (set only for pull request events)                                                   | `issue-branch`                                                                                             |\n| `CI_PREV_COMMIT_TARGET_BRANCH`     | previous commit target branch (set only for pull request events)                                                   | `main`                                                                                                     |\n| `CI_PREV_COMMIT_URL`               | previous commit link in forge                                                                                      | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_COMMIT_MESSAGE`           | previous commit message                                                                                            | `test`                                                                                                     |\n| `CI_PREV_COMMIT_AUTHOR`            | previous commit author username                                                                                    | `john-doe`                                                                                                 |\n| `CI_PREV_COMMIT_AUTHOR_EMAIL`      | previous commit author email address                                                                               | `john-doe@example.com`                                                                                     |\n|                                    | **Previous pipeline**                                                                                              |                                                                                                            |\n| `CI_PREV_PIPELINE_NUMBER`          | previous pipeline number                                                                                           | `7`                                                                                                        |\n| `CI_PREV_PIPELINE_PARENT`          | previous pipeline number of parent pipeline                                                                        | `0`                                                                                                        |\n| `CI_PREV_PIPELINE_EVENT`           | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                   | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PREV_PIPELINE_EVENT_REASON`    | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change         | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PREV_PIPELINE_URL`             | previous pipeline link in CI                                                                                       | `https://ci.example.com/repos/7/pipeline/7`                                                                |\n| `CI_PREV_PIPELINE_FORGE_URL`       | previous pipeline link to event in forge                                                                           | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_PIPELINE_DEPLOY_TARGET`   | previous pipeline deploy target for `deployment` events                                                            | `production`                                                                                               |\n| `CI_PREV_PIPELINE_DEPLOY_TASK`     | previous pipeline deploy task for `deployment` events                                                              | `migration`                                                                                                |\n| `CI_PREV_PIPELINE_STATUS`          | previous pipeline status                                                                                           | `success`, `failure`                                                                                       |\n| `CI_PREV_PIPELINE_CREATED`         | previous pipeline created UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_STARTED`         | previous pipeline started UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_FINISHED`        | previous pipeline finished UNIX timestamp                                                                          | `1722610383`                                                                                               |\n| `CI_PREV_PIPELINE_AUTHOR`          | previous pipeline author username                                                                                  | `octocat`                                                                                                  |\n| `CI_PREV_PIPELINE_AVATAR`          | previous pipeline author avatar                                                                                    | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | &emsp;                                                                                                             |                                                                                                            |\n| `CI_WORKSPACE`                     | Path of the workspace where source code gets cloned to                                                             | `/woodpecker/src/git.example.com/john-doe/my-repo`                                                         |\n|                                    | **System**                                                                                                         |                                                                                                            |\n| `CI_SYSTEM_NAME`                   | name of the CI system                                                                                              | `woodpecker`                                                                                               |\n| `CI_SYSTEM_URL`                    | link to CI system                                                                                                  | `https://ci.example.com`                                                                                   |\n| `CI_SYSTEM_HOST`                   | hostname of CI server                                                                                              | `ci.example.com`                                                                                           |\n| `CI_SYSTEM_VERSION`                | version of the server                                                                                              | `2.7.0`                                                                                                    |\n|                                    | **Forge**                                                                                                          |                                                                                                            |\n| `CI_FORGE_TYPE`                    | name of forge                                                                                                      | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab`                                   |\n| `CI_FORGE_URL`                     | root URL of configured forge                                                                                       | `https://git.example.com`                                                                                  |\n|                                    | **Internal** - Please don't use!                                                                                   |                                                                                                            |\n| `CI_SCRIPT`                        | Internal script path. Used to call pipeline step commands.                                                         |                                                                                                            |\n| `CI_NETRC_USERNAME`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_PASSWORD`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_MACHINE`                 | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n\n## Global environment variables\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nThese can be used, for example, to manage the image tag used by multiple projects.\n\n```ini\nWOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18\n```\n\n```diff\n steps:\n   - name: build\n-    image: golang:1.18\n+    image: golang:${GOLANG_VERSION}\n     commands:\n       - [...]\n```\n\n## String Substitution\n\nWoodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration.\n\nExample commit substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA}\n```\n\nExample tag substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG}\n```\n\n## String Operations\n\nWoodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values.\n\n| OPERATION          | DESCRIPTION                                      |\n| ------------------ | ------------------------------------------------ |\n| `${param}`         | parameter substitution                           |\n| `${param,}`        | parameter substitution with lowercase first char |\n| `${param,,}`       | parameter substitution with lowercase            |\n| `${param^}`        | parameter substitution with uppercase first char |\n| `${param^^}`       | parameter substitution with uppercase            |\n| `${param:pos}`     | parameter substitution with substring            |\n| `${param:pos:len}` | parameter substitution with substring and length |\n| `${param=default}` | parameter substitution with default              |\n| `${param##prefix}` | parameter substitution with prefix removal       |\n| `${param%%suffix}` | parameter substitution with suffix removal       |\n| `${param/old/new}` | parameter substitution with find and replace     |\n\nExample variable substitution with substring:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA:0:8}\n```\n\nExample variable substitution strips `v` prefix from `v.1.0.0`:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG##v}\n```\n\n## `pull_request_metadata` specific event reason values\n\nFor the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`.\n\n**GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list.\n\n:::note\nEvent reason values are forge-specific and may change between versions.\n:::\n\n| Event                | GitHub             | Gitea              | Forgejo            | GitLab             | Bitbucket | Bitbucket Datacenter | Description                                                                    |\n| -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ |\n| `assigned`           | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was assigned to a user                                            |\n| `converted_to_draft` | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Pull request was converted to a draft                                          |\n| `demilestoned`       | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was removed from a milestone                                      |\n| `description_edited` | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Description edited                                                             |\n| `edited`             | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:                | :x:       | :x:                  | The title or body of a pull request was edited, or the base branch was changed |\n| `label_added`        | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Pull had no labels and now got label(s) added                                  |\n| `label_cleared`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | All labels removed                                                             |\n| `label_updated`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | New label(s) added / label(s) changed                                          |\n| `locked`             | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was locked                                      |\n| `milestoned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was added to a milestone                                          |\n| `ready_for_review`   | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Draft pull request was marked as ready for review                              |\n| `review_requested`   | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | New review was requested                                                       |\n| `title_edited`       | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Title edited                                                                   |\n| `unassigned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | User was unassigned from a pull request                                        |\n| `unlabeled`          | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Label was removed from a pull request                                          |\n| `unlocked`           | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was unlocked                                    |\n\n**Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214).\n"
  },
  {
    "path": "docs/docs/20-usage/51-plugins/20-creating-plugins.md",
    "content": "# Creating plugins\n\nCreating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT.\n\n## Settings\n\nTo allow users to configure the behavior of your plugin, you should use `settings:`.\n\nThese are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix.\nUsing a setting like `url` results in an env var named `PLUGIN_URL`.\n\nCharacters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`.\nCamelCase is not respected, `anInt` get `PLUGIN_ANINT`. <!-- cspell:ignore ANINT -->\n\n### Basic settings\n\nUsing any basic YAML type (scalar) will be converted into a string:\n\n| Setting              | Environment value            |\n| -------------------- | ---------------------------- |\n| `some-bool: false`   | `PLUGIN_SOME_BOOL=\"false\"`   |\n| `some_String: hello` | `PLUGIN_SOME_STRING=\"hello\"` |\n| `anInt: 3`           | `PLUGIN_ANINT=\"3\"`           |\n\n### Complex settings\n\nIt's also possible to use complex settings like this:\n\n```yaml\nsteps:\n  - name: plugin\n    image: foo/plugin\n    settings:\n      complex:\n        abc: 2\n        list:\n          - 2\n          - 3\n```\n\nValues like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{\"abc\": \"2\", \"list\": [ \"2\", \"3\" ]}`.\n\n### Secrets\n\nSecrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage).\n\n## Plugin library\n\nFor Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See <https://codeberg.org/woodpecker-plugins/go-plugin>.\n\n## Metadata\n\nIn your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins).\n\nSupported metadata:\n\n- `name`: The plugin's full name\n- `icon`: URL to your plugin's icon\n- `description`: A short description of what it's doing\n- `author`: Your name\n- `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin)\n- `containerImage`: name of the container image\n- `containerImageUrl`: link to the container image\n- `url`: homepage or repository of your plugin\n\nIf you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required.\n\n## Example plugin\n\nThis provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline.\n\n### What end users will see\n\nThe below example demonstrates how we might configure a webhook plugin in the YAML file:\n\n```yaml\nsteps:\n  - name: webhook\n    image: foo/webhook\n    settings:\n      url: https://example.com\n      method: post\n      body: |\n        hello world\n```\n\n### Write the logic\n\nCreate a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`.\n\n```bash\n#!/bin/sh\n\ncurl \\\n  -X ${PLUGIN_METHOD} \\\n  -d ${PLUGIN_BODY} \\\n  ${PLUGIN_URL}\n```\n\n### Package it\n\nCreate a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint.\n\n```dockerfile\n# please pin the version, e.g. alpine:3.19\nFROM alpine\nADD script.sh /bin/\nRUN chmod +x /bin/script.sh\nRUN apk -Uuv add curl ca-certificates\nENTRYPOINT /bin/script.sh\n```\n\nBuild and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community.\n\n```shell\ndocker build -t foo/webhook .\ndocker push foo/webhook\n```\n\nExecute your plugin locally from the command line to verify it is working:\n\n```shell\ndocker run --rm \\\n  -e PLUGIN_METHOD=post \\\n  -e PLUGIN_URL=https://example.com \\\n  -e PLUGIN_BODY=\"hello world\" \\\n  foo/webhook\n```\n\n## Best practices\n\n- Build your plugin for different architectures to allow many users to use them.\n  At least, you should support `amd64` and `arm64`.\n- Provide binaries for users using the `local` backend.\n  These should also be built for different OS/architectures.\n- Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible.\n- Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names.\n- Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)).\n- Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)).\n"
  },
  {
    "path": "docs/docs/20-usage/51-plugins/51-overview.md",
    "content": "# Plugins\n\nPlugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline.\nPlugins can be used to deploy code, publish artifacts, send notification, and more.\n\nThey are automatically pulled from the default container registry the agent's have configured.\n\n```dockerfile title=\"Dockerfile\"\nFROM cloud/kubectl\nCOPY deploy /usr/local/deploy\nENTRYPOINT [\"/usr/local/deploy\"]\n```\n\n```bash title=\"deploy\"\nkubectl apply -f $PLUGIN_TEMPLATE\n```\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: deploy-to-k8s\n    image: cloud/my-k8s-plugin\n    settings:\n      template: config/k8s/service.yaml\n```\n\nExample pipeline using the Prettier and S3 plugins:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  - name: prettier\n    image: woodpeckerci/plugin-prettier\n\n  - name: publish\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      source: some-file-name\n      target: /target/some-file\n```\n\n## Plugin Isolation\n\nPlugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree.\nWhile normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author.\n\nThat's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically\nadjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands`\nor `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin\nanymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition.\n\n## Finding Plugins\n\nFor official plugins, you can use the Woodpecker plugin index:\n\n- [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins)\n\n:::tip\nThere are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking.\n\n- [Drone Plugins](http://plugins.drone.io)\n- [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/)\n- [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community)\n\n:::\n"
  },
  {
    "path": "docs/docs/20-usage/51-plugins/_category_.yaml",
    "content": "label: 'Plugins'\n# position: 2\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/docs/20-usage/60-services.md",
    "content": "# Services\n\nWoodpecker provides a services section in the YAML file used for defining service containers.\nThe below configuration composes database and cache containers.\n\nServices are accessed using custom hostnames.\nIn the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`.\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n\n  - name: cache\n    image: redis\n```\n\nYou can define a port and a protocol explicitly:\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    ports:\n      - 3306\n\n  - name: wireguard\n    image: wg\n    ports:\n      - 51820/udp\n```\n\n## Stopping\n\nServices that are no longer needed receive a **SIGTERM** signal. If they do not respond, they are forcibly terminated with **SIGKILL**.\nIf there are services that do not shut down properly and this doesn't matter, you can simply ignore the error:\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    failure: ignore # we don't care how mysql exits\n     ports:\n       - 3306\n```\n\n## Configuration\n\nService containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more.\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    environment:\n+      MYSQL_DATABASE: test\n+      MYSQL_ALLOW_EMPTY_PASSWORD: yes\n\n   - name: cache\n     image: redis\n```\n\n## Detachment\n\nService and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required.\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n\n   - name: database\n     image: redis\n+    detach: true\n\n   - name: test\n     image: golang\n     commands:\n       - go test\n```\n\nContainers from detached steps will terminate when the pipeline ends.\n\n## Initialization\n\nService containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff.\n\n```diff\n steps:\n   - name: test\n     image: golang\n     commands:\n+      - sleep 15\n       - go get\n       - go test\n\n services:\n   - name: database\n     image: mysql\n```\n\n## Complete Pipeline Example\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ROOT_PASSWORD: example\nsteps:\n  - name: get-version\n    image: ubuntu\n    commands:\n      - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null\n      - sleep 30s # need to wait for mysql-server init\n      - echo 'SHOW VARIABLES LIKE \"version\"' | mysql -u root -h database test -p example\n```\n"
  },
  {
    "path": "docs/docs/20-usage/70-volumes.md",
    "content": "# Volumes\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\n:::note\nVolumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     commands:\n       - docker build --rm -t octocat/hello-world .\n       - docker run --rm octocat/hello-world --test\n       - docker push octocat/hello-world\n       - docker rmi octocat/hello-world\n     volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nIf you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`.\n\nPlease note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error.\n\n```diff\n-volumes: [ ./certs:/etc/ssl/certs ]\n+volumes: [ /etc/ssl/certs:/etc/ssl/certs ]\n```\n"
  },
  {
    "path": "docs/docs/20-usage/72-extensions/40-configuration-extension.md",
    "content": "# Configuration extension\n\nThe configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n<!-- cSpell:words templating,Starlark,Jsonnet -->\n\n- Preprocess the original configuration file with something like Go templating\n- Convert custom attributes to Woodpecker attributes\n- Add defaults to the configuration like default steps\n- Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ...\n- Centralize configuration for multiple repositories in one place\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your configuration extension.\n\nThe global configuration will be called before the repository specific configuration extension if both are configured and the repository has not enabled the exclusive setting.\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used.\n\nYou can enable the exclusive setting (both globally and on a per-repo level). Then Woodpecker will only call your extension, but nothing else. This allows you to completely skip the forge. Requests sent to the extension will not have the configuration files added.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_CONFIG_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n  configuration?: {\n    // list of configurations. Not send if there was none.\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"configuration\": [\n    {\n      \"name\": \".woodpecker.yaml\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from Repo (.woodpecker.yaml)\\\"\\n\"\n    }\n  ],\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  configs: {\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/docs/20-usage/72-extensions/50-registry-extension.md",
    "content": "# Registry extension\n\nWoodpecker uses the registry extension to get registry credentials. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n- Centralize registry credential management\n- Use an external storage for credentials\n- Dynamically manage which credentials Woodpecker should use\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your registry extension.\n\nIf both the global and the repo-level extension return credentials for a registry, it will use the credentials from the repo extension.\n\n```ini title=\"Server\"\nWOODPECKER_REGISTRY_EXTENSION_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered, Woodpecker will fetch the credentials from your service. As fallback, it uses the credentials configured directly in Woodpecker.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_REGISTRY_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n// Please check the latest structure in the models mentioned above.\n// This example is likely outdated.\n\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  registries: {\n    address: string; // the docker registry address\n    username: string; // registry username\n    password: string; // registry password\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"registries\": [\n    {\n      \"address\": \"docker.io\",\n      \"username\": \"woodpecker-bot\",\n      \"password\": \"your-pass-word-123\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/docs/20-usage/72-extensions/55-secret-extension.md",
    "content": "# Secret extension\n\nWoodpecker uses the secret extension to get secrets from an external service. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n- Centralize secret management (e.g. HashiCorp Vault, AWS Secrets Manager)\n- Dynamically generate secrets per pipeline\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the security section.\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your secret extension.\n\nIf both the global and the repo-level extension return a secret with the same name, it will use the secret from the repo extension.\n\n```ini title=\"Server\"\nWOODPECKER_SECRET_EXTENSION_ENDPOINT=https://example.com/secrets\nWOODPECKER_SECRET_EXTENSION_NETRC=false\n```\n\n## How it works\n\nWhen a pipeline is triggered, Woodpecker will fetch secrets from your service. The extension secrets are merged with the secrets configured directly in Woodpecker, with extension secrets taking priority by name. If the extension is unavailable, Woodpecker falls back to the locally configured secrets.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_SECRET_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n// Please check the latest structure in the models mentioned above.\n// This example is likely outdated.\n\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n// Note: the \"netrc\" field is omitted when netrc sending is not enabled.\n```\n\n### Response\n\nThe extension should respond with a JSON object containing a `secrets` array.\nIf the extension wants to keep the existing secrets without adding any, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  secrets: {\n    name: string; // the secret name, matched by from_secret in pipeline config\n    value: string; // the secret value\n    images?: string[]; // optional: restrict to specific plugins\n    events?: string[]; // optional: restrict to specific pipeline events\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"secrets\": [\n    {\n      \"name\": \"docker_password\",\n      \"value\": \"your-secret-password-123\"\n    },\n    {\n      \"name\": \"deploy_token\",\n      \"value\": \"super-secret-token\",\n      \"events\": [\"push\", \"tag\"]\n    }\n  ]\n}\n```\n\n## 3rd Party Extensions\n\n:::danger\nThese extensions are neither developed nor verified by Woodpecker CI. Make sure you trust them before using.\n:::\n\n- [OpenBao extension](https://github.com/vcheesbrough/woodpecker-openbao-broker)\n"
  },
  {
    "path": "docs/docs/20-usage/72-extensions/_category_.yaml",
    "content": "label: 'Extensions'\n# position: 3\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'index'\n"
  },
  {
    "path": "docs/docs/20-usage/72-extensions/index.md",
    "content": "# Extensions\n\nWoodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints.\n\nThere is currently one type of extension available:\n\n- [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly.\n- [Registry extension](./50-registry-extension.md) to get registry credentials from the extension.\n- [Secret extension](./55-secret-extension.md) to get secrets from an external service.\n\n## Security\n\n:::warning\nYou need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful\ndata like malicious pipeline configurations that could be executed.\n:::\n\nTo prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair.\nTo verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign).\nYou can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page.\n\n## Example extensions\n\nA simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions)\n\n## Configuration\n\nTo prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of:\n\n- Built-in networks:\n  - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.\n  - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.\n  - `external`: A valid non-private unicast IP, you can access all hosts on public internet.\n  - `*`: All hosts are allowed.\n- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6\n- (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*`\n"
  },
  {
    "path": "docs/docs/20-usage/72-linter.md",
    "content": "# Linter\n\nWoodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines.\n\n![errors and warnings in UI](./linter-warnings-errors.png)\n\n## Running the linter from CLI\n\nYou can run the linter also manually from the CLI:\n\n```shell\nwoodpecker-cli lint <workflow files>\n```\n\n## Bad habit warnings\n\nWoodpecker warns you if your configuration contains some bad habits.\n\n### Event filter for all steps\n\nAll your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well.\n\nExamples of an **incorrect** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n  - event: tag\n```\n\nThis will trigger the warning because the first item (`branch: main`) does not filter with an event.\n\n```yaml\nsteps:\n  - name: test\n    when:\n      branch: main\n\n  - name: deploy\n    when:\n      event: tag\n```\n\nExamples of a **correct** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n    event: push\n  - event: tag\n```\n\n```yaml\nsteps:\n  - name: test\n    when:\n      event: [tag, push]\n\n  - name: deploy\n    when:\n      - event: tag\n```\n"
  },
  {
    "path": "docs/docs/20-usage/75-project-settings.md",
    "content": "# Project settings\n\nAs the owner of a project in Woodpecker you can change project related settings via the web interface.\n\n![project settings](./project-settings.png)\n\n## Pipeline path\n\nThe path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`.\n\n## Repository hooks\n\nYour Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting.\n\n## Allow pull requests\n\nEnables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests.\n\n## Allow deployments\n\nEnables a pipeline to be started with the `deploy` event from a successful pipeline.\n\n:::danger\nOnly activate this option if you trust all users who have push access to your repository.\nOtherwise, these users will be able to steal secrets that are only available for `deploy` events.\n:::\n\n## Require approval for\n\nTo prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`.\n\n## Trusted\n\nIf you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes.\n\n:::note\n\nOnly server admins can set this option. If you are not a server admin this option won't be shown in your project settings.\n\n:::\n\n## Custom trusted clone plugins\n\nDuring the clone process, Git credentials (e.g., for private repositories) may be required.\nThese credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html).\n\nThese credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting.\n\nWith these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo.\nTo prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level.\nWithout an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step.\n\n:::info\nThis setting does not affect subsequent steps, nor does it allow direct pushes to the repository.\nTo enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push).\n:::\n\n## Project visibility\n\nYou can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners.\n\n- `Public` Every user can see your project without being logged in.\n- `Internal` Only authenticated users of the Woodpecker instance can see this project.\n- `Private` Only you and other owners of the repository can see this project.\n\n## Timeout\n\nAfter this timeout a pipeline has to finish or will be treated as timed out.\n\n## Cancel previous pipelines\n\nBy enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one.\n"
  },
  {
    "path": "docs/docs/20-usage/80-badges.md",
    "content": "# Status Badges\n\nWoodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code.\n\n## Badge endpoint\n\n```uri\n<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n```\n\nThe status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter.\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<branch>\n```\n\nBy default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state.\nIf you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event:\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?events=manual,cron\n```\n"
  },
  {
    "path": "docs/docs/20-usage/90-advanced-usage.md",
    "content": "# Advanced usage\n\n## Advanced YAML syntax\n\nYAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config:\n\n### Anchors & aliases\n\nYou can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config.\n\nTo convert this:\n\n```yaml\nsteps:\n  - name: test\n    image: golang:1.18\n    commands: go test ./...\n  - name: build\n    image: golang:1.18\n    commands: build\n```\n\nJust add a new section called **variables** like this:\n\n```diff\n+variables:\n+  - &golang_image 'golang:1.18'\n\n steps:\n   - name: test\n-    image: golang:1.18\n+    image: *golang_image\n     commands: go test ./...\n   - name: build\n-    image: golang:1.18\n+    image: *golang_image\n     commands: build\n```\n\n### Map merges and overwrites\n\n```yaml\nvariables:\n  - &base-plugin-settings\n    target: dist\n    recursive: false\n    try: true\n  - &special-setting\n    special: true\n  - &some-plugin codeberg.org/6543/docker-images/print_env\n\nsteps:\n  - name: develop\n    image: *some-plugin\n    settings:\n      <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map\n    when:\n      branch: develop\n\n  - name: main\n    image: *some-plugin\n    settings:\n      <<: *base-plugin-settings # merge one map and ...\n      try: false # ... overwrite original value\n      ongoing: false # ... adding a new value\n    when:\n      branch: main\n```\n\n### Sequence merges\n\n```yaml\nvariables:\n  pre_cmds: &pre_cmds\n    - echo start\n    - whoami\n  post_cmds: &post_cmds\n    - echo stop\n  hello_cmd: &hello_cmd\n    - echo hello\n\nsteps:\n  - name: step1\n    image: debian\n    commands:\n      - <<: *pre_cmds # prepend a sequence\n      - echo exec step now do dedicated things\n      - <<: *post_cmds # append a sequence\n  - name: step2\n    image: debian\n    commands:\n      - <<: [*pre_cmds, *hello_cmd] # prepend two sequences\n      - echo echo from second step\n      - <<: *post_cmds\n```\n\n### References\n\n- [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases)\n- [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml)\n\n## Persisting environment data between steps\n\nOne can create a file containing environment variables, and then source it in each step that needs them.\n\n```yaml\nsteps:\n  - name: init\n    image: bash\n    commands:\n      - echo \"FOO=hello\" >> envvars\n      - echo \"BAR=world\" >> envvars\n\n  - name: debug\n    image: bash\n    commands:\n      - source ./envvars\n      - echo $FOO\n```\n\n## Declaring global variables\n\nAs described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables:\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nNote that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps.\n\n## Docker in docker (dind) setup\n\n:::warning\nThis set up will only work on trusted repositories and for security reasons should only be used in private environments.\nSee [project settings](./75-project-settings.md#trusted) to enable \"trusted\" mode.\n:::\n\nThe snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service.\n\n:::note\nIf your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead.\n:::\n\nFirst we need to define a service running a docker with the `dind` tag.\nThis service must run in `privileged` mode:\n\n```yaml\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    ports:\n      - 2376\n```\n\nNext, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28).\n\nThis can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below).\n\n```diff\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n+    environment:\n+      DOCKER_TLS_CERTDIR: /dind-certs\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n     ports:\n       - 2376\n```\n\nIn the docker client step:\n\n1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon.\n   These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them).\n2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`)\n\nTest the connection with the docker client:\n\n```diff\nsteps:\n  - name: test\n    image: docker:cli # in production use something like 'docker:<major version>-cli'\n+    environment:\n+      DOCKER_HOST: \"tcp://docker:2376\"\n+      DOCKER_CERT_PATH: \"/dind-certs/client\"\n+      DOCKER_TLS_VERIFY: \"1\"\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n```\n\nThis step should output the server and client version information if everything has been set up correctly.\n\nFull example:\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /dind-certs\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    ports:\n      - 2376\n```\n"
  },
  {
    "path": "docs/docs/20-usage/_category_.yaml",
    "content": "label: 'Usage'\n# position: 2\ncollapsible: true\ncollapsed: false\n"
  },
  {
    "path": "docs/docs/30-administration/00-general.md",
    "content": "# General\n\nWoodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`).\n\nThe **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files.\n\nThe **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance.\n\nThe **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time).\n\n:::tip\nYou can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent.\n:::\n\n## Database\n\nWoodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page.\n\n## Forge\n\nWhat would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page.\n\n## Container images\n\n:::info\nNo `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch.\n:::\n\n- `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image)\n  - `vX.Y`\n  - `vX`\n- `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0).\n  - `vX.Y-alpine`\n  - `vX-alpine`\n- `next`: Built from the `main` branch\n- `pull_<PR_ID>`: Images built from Pull Request branches.\n\nImages are pushed to DockerHub and Quay.\n\n- woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server))\n- woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent))\n- woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli))\n- woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler))\n"
  },
  {
    "path": "docs/docs/30-administration/05-installation/10-docker-compose.md",
    "content": "# Docker Compose\n\nThis example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings.\n\nIt creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information.\n\nThe server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it.\n\n```yaml title=\"docker-compose.yaml\"\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:v3\n    ports:\n      - 8000:8000\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\n  woodpecker-agent:\n    image: woodpeckerci/woodpecker-agent:v3\n    command: agent\n    restart: always\n    depends_on:\n      - woodpecker-server\n    volumes:\n      - woodpecker-agent-config:/etc/woodpecker\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\nvolumes:\n  woodpecker-server-data:\n  woodpecker-agent-config:\n```\n\nWoodpecker must know its own address. You must therefore specify the public address in the format `<scheme>://<hostname>`. Please omit any trailing slashes:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_HOST=${WOODPECKER_HOST}\n```\n\nIt is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR}\n+      - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR}\n```\n\nIf the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_SECURE=true # defaults to false\n+      - WOODPECKER_GRPC_VERIFY=true # default\n```\n\nAs agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n+    volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nAgents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER=woodpecker-server:9000\n```\n\nThe server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Handling sensitive data\n\nThere are several options for handling sensitive data in `docker compose` or `docker swarm` configurations:\n\nFor Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure.\n\nAlternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret\n+    secrets:\n+      - woodpecker-agent-secret\n+\n+ secrets:\n+   woodpecker-agent-secret:\n+     external: true\n```\n\nTo store values in a docker secret you can use the following command:\n\n```bash\necho \"my_agent_secret_key\" | docker secret create woodpecker-agent-secret -\n```\n\n## SELinux Considerations\n\nIf you're running Woodpecker on a system with SELinux enabled (RHEL, CentOS, Fedora, etc.), you may need to add the `:z` or `:Z` option to volume mounts. For the Docker socket volume:\n\n```yaml\nvolumes:\n  - /var/run/docker.sock:/var/run/docker.sock:z\n```\n\nFor more details and other SELinux-related solutions, see the [Troubleshooting](../../20-usage/100-troubleshooting.md#selinux-issues) page.\n"
  },
  {
    "path": "docs/docs/30-administration/05-installation/20-helm-chart.md",
    "content": "# Helm Chart\n\nWoodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments:\n\n```bash\nhelm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version <VERSION>\n```\n\n## Metrics\n\nTo enable metrics gathering, set the following in values.yml:\n\n```yaml\nmetrics:\n  enabled: true\n  port: 9001\n```\n\nThis activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics.\n\nTo enable both Prometheus pod monitoring discovery, set:\n\n<!-- cspell:disable -->\n\n```yaml\nprometheus:\n  podmonitor:\n    enabled: true\n    interval: 60s\n    labels: {}\n```\n\n<!-- cspell:enable -->\n\nIf you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled:\n\n```yaml\n# Search all available namespaces\npodMonitorNamespaceSelector:\n  matchLabels: {}\n# Enable all available pod monitors\npodMonitorSelector:\n  matchLabels: {}\n```\n"
  },
  {
    "path": "docs/docs/30-administration/05-installation/30-packages.md",
    "content": "# Distribution packages\n\n## Official packages\n\n- DEB\n- RPM\n\nThe pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution.\n\n```Shell\nRELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '\"tag_name\":\\s\"v\\K[^\"]+')\n\n# Debian/Ubuntu (x86_64)\ncurl -fLOOO \"https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\"\nsudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\n\n# CentOS/RHEL (x86_64)\nsudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm\n```\n\nThe package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-server.service\"\n[Unit]\nDescription=WoodpeckerCI server\nDocumentation=https://woodpecker-ci.org/docs/administration/server-config\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env\nConditionPathExists=/etc/woodpecker/woodpecker-server.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-server.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-server\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-server.env\"\nWOODPECKER_OPEN=true\nWOODPECKER_HOST=${WOODPECKER_HOST}\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\nWOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\nAfter installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-agent.service\"\n[Unit]\nDescription=WoodpeckerCI agent\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/agent\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env\nConditionPathExists=/etc/woodpecker/woodpecker-agent.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-agent.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-agent\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-agent.env\"\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Community packages\n\n:::info\nWoodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions.\n:::\n\n- [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=)\n- [Arch Linux](https://archlinux.org/packages/?q=woodpecker)\n- [openSUSE](https://software.opensuse.org/package/woodpecker)\n- [YunoHost](https://apps.yunohost.org/app/woodpecker)\n- [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html)\n- [Easypanel](https://easypanel.io/docs/templates/woodpeckerci)\n- [Homebrew](https://formulae.brew.sh/formula/woodpecker-cli) (CLI only)\n\n### NixOS\n\n:::info\nThis module is not maintained by the Woodpecker developers.\nIf you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained.\n:::\n\nIn theory, the NixOS installation is very similar to the binary installation and supports multiple backends.\nIn practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken.\n\n<!-- cspell:words Optimisation -->\n\n```nix\n{ config\n, ...\n}:\nlet\n  domain = \"woodpecker.example.org\";\nin\n{\n  # This automatically sets up certificates via let's encrypt\n  security.acme.defaults.email = \"acme@example.com\";\n  security.acme.acceptTerms = true;\n\n  # Setting up a nginx proxy that handles tls for us\n  services.nginx = {\n    enable = true;\n    openFirewall = true;\n    recommendedTlsSettings = true;\n    recommendedOptimisation = true;\n    recommendedProxySettings = true;\n    virtualHosts.\"${domain}\" = {\n      enableACME = true;\n      forceSSL = true;\n      locations.\"/\".proxyPass = \"http://localhost:3007\";\n    };\n  };\n\n  services.woodpecker-server = {\n    enable = true;\n    environment = {\n      WOODPECKER_HOST = \"https://${domain}\";\n      WOODPECKER_SERVER_ADDR = \":3007\";\n      WOODPECKER_OPEN = \"true\";\n    };\n    # You can pass a file with env vars to the system it could look like:\n    # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX\n    environmentFile = \"/path/to/my/secrets/file\";\n  };\n\n  # This sets up a woodpecker agent\n  services.woodpecker-agents.agents.\"docker\" = {\n    enable = true;\n    # We need this to talk to the podman socket\n    extraGroups = [ \"podman\" ];\n    environment = {\n      WOODPECKER_SERVER = \"localhost:9000\";\n      WOODPECKER_MAX_WORKFLOWS = \"4\";\n      DOCKER_HOST = \"unix:///run/podman/podman.sock\";\n      WOODPECKER_BACKEND = \"docker\";\n    };\n    # Same as with woodpecker-server\n    environmentFile = [ \"/var/lib/secrets/woodpecker.env\" ];\n  };\n\n  # Here we setup podman and enable dns\n  virtualisation.podman = {\n    enable = true;\n    defaultNetwork.settings = {\n      dns_enabled = true;\n    };\n  };\n  # This is needed for podman to be able to talk over dns\n  networking.firewall.interfaces.\"podman0\" = {\n    allowedUDPPorts = [ 53 ];\n    allowedTCPPorts = [ 53 ];\n  };\n}\n```\n\nAll configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline.\n"
  },
  {
    "path": "docs/docs/30-administration/05-installation/_category_.yaml",
    "content": "label: 'Installation'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/10-server.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Server\n\n## Forge and User configuration\n\nWoodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge.\n\nYou can also restrict the registration:\n\n- closed registration and manually managing users with the CLI `woodpecker-cli user`\n- open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN`\n\n  ```ini\n  WOODPECKER_OPEN=false\n  WOODPECKER_ADMIN=john.smith,jane_doe\n  ```\n\n- open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS`\n\n  ```ini\n  WOODPECKER_OPEN=true\n  WOODPECKER_ORGS=dolores,dog-patch\n  ```\n\nAdministrators should also be explicitly set in your configuration.\n\n```ini\nWOODPECKER_ADMIN=john.smith,jane_doe\n```\n\n## Repository configuration\n\nWoodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here.\n\n```ini\nWOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user\n```\n\n## Databases\n\nThe default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind:\n\n- Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`.\n\n- Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably.\n\n- Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes.\n\n- Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice.\n\n### SQLite\n\nBy default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n+    volumes:\n+      - woodpecker-server-data:/var/lib/woodpecker/\n```\n\n### MySQL/MariaDB\n\nThe below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples.\nThe minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=mysql\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n```\n\n### PostgreSQL\n\nThe below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples.\nPlease use Postgres versions equal or higher than **11**.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=postgres\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable\n```\n\n## TLS\n\nWoodpecker supports SSL configuration by mounting certificates into your container.\n\n```ini\nWOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\nWOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n\nTLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library.\n\n### Container configuration\n\nIn addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     ports:\n+      - 80:80\n+      - 443:443\n       - 9000:9000\n```\n\nAdditionally, the certificate and key must be mounted and referenced:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\n+      - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n     volumes:\n+      - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt\n+      - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key\n```\n\n## Reverse Proxy\n\n### Apache\n\nThis guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration:\n\n<!-- cspell:ignore apacheconf -->\n\n```apacheconf\nProxyPreserveHost On\n\nRequestHeader set X-Forwarded-Proto \"https\"\n\nProxyPass / http://127.0.0.1:8000/\nProxyPassReverse / http://127.0.0.1:8000/\n```\n\nYou must have these Apache modules installed:\n\n- `proxy`\n- `proxy_http`\n\nYou must configure Apache to set `X-Forwarded-Proto` when using https.\n\n```diff\n ProxyPreserveHost On\n\n+RequestHeader set X-Forwarded-Proto \"https\"\n\n ProxyPass / http://127.0.0.1:8000/\n ProxyPassReverse / http://127.0.0.1:8000/\n```\n\n### Nginx\n\nThis guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide).\n\nExample configuration:\n\n```nginx\nserver {\n    listen 80;\n    server_name woodpecker.example.com;\n\n    location / {\n        proxy_set_header X-Forwarded-For $remote_addr;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Host $http_host;\n\n        proxy_pass http://127.0.0.1:8000;\n        proxy_redirect off;\n        proxy_http_version 1.1;\n        proxy_buffering off;\n\n        chunked_transfer_encoding off;\n    }\n}\n```\n\nYou must configure the proxy to set `X-Forwarded` proxy headers:\n\n```diff\n server {\n     listen 80;\n     server_name woodpecker.example.com;\n\n     location / {\n+        proxy_set_header X-Forwarded-For $remote_addr;\n+        proxy_set_header X-Forwarded-Proto $scheme;\n\n         proxy_pass http://127.0.0.1:8000;\n         proxy_redirect off;\n         proxy_http_version 1.1;\n         proxy_buffering off;\n\n         chunked_transfer_encoding off;\n     }\n }\n```\n\n### Caddy\n\nThis guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration:\n\n```caddy\n# expose WebUI and API\nwoodpecker.example.com {\n  reverse_proxy woodpecker-server:8000\n}\n\n# expose gRPC\nwoodpecker-agent.example.com {\n  reverse_proxy h2c://woodpecker-server:9000\n}\n```\n\n### Tunnelmole\n\n[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.\n\nStart by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation).\n\nAfter the installation, run the following command to start tunnelmole:\n\n```bash\ntmole 8000\n```\n\nIt will start a tunnel and will give a response like this:\n\n```bash\n➜  ~ tmole 8000\nhttp://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\nhttps://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\n```\n\nSet `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server.\n\n### Ngrok\n\n[Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command:\n\n```bash\nngrok http 8000\n```\n\nSet `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server.\n\n### Traefik\n\nTo install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https.\n\n<!-- cspell:words redirectscheme certresolver  -->\n\n```yaml\nservices:\n  server:\n    image: woodpeckerci/woodpecker-server:latest\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=your_admin_user\n      # other settings ...\n\n    networks:\n      - dmz # externally defined network, so that traefik can connect to the server\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n\n    deploy:\n      labels:\n        - traefik.enable=true\n\n        # web server\n        - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000\n\n        - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker-secure.tls=true\n        - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-secure.service=woodpecker-service\n\n        - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker.entrypoints=web\n        - traefik.http.routers.woodpecker.service=woodpecker-service\n\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker\n\n        #  gRPC service\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c\n\n        - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc-secure.tls=true\n        - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc\n\n        - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc.entrypoints=web\n        - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc\n\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker\n\nvolumes:\n  woodpecker-server-data:\n    driver: local\n\nnetworks:\n  dmz:\n    external: true\n```\n\n## Metrics\n\n### Endpoint\n\nWoodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above.\n\n```yaml\nglobal:\n  scrape_interval: 60s\n\nscrape_configs:\n  - job_name: 'woodpecker'\n    bearer_token: dummyToken...\n\n    static_configs:\n      - targets: ['woodpecker.domain.com']\n```\n\n### Authorization\n\nAn administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token: dummyToken...\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\nAs an alternative, the token can also be read from a file:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token_file: /etc/secrets/woodpecker-monitoring-token\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\n### Reference\n\nList of Prometheus metrics specific to Woodpecker:\n\n```yaml\n# HELP woodpecker_pipeline_count Pipeline count.\n# TYPE woodpecker_pipeline_count counter\nwoodpecker_pipeline_count{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\nwoodpecker_pipeline_count{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\n# HELP woodpecker_pipeline_time Build time.\n# TYPE woodpecker_pipeline_time gauge\nwoodpecker_pipeline_time{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 116\nwoodpecker_pipeline_time{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 155\n# HELP woodpecker_pipeline_total_count Total number of builds.\n# TYPE woodpecker_pipeline_total_count gauge\nwoodpecker_pipeline_total_count 1025\n# HELP woodpecker_pending_steps Total number of pending pipeline steps.\n# TYPE woodpecker_pending_steps gauge\nwoodpecker_pending_steps 0\n# HELP woodpecker_repo_count Total number of repos.\n# TYPE woodpecker_repo_count gauge\nwoodpecker_repo_count 9\n# HELP woodpecker_running_steps Total number of running pipeline steps.\n# TYPE woodpecker_running_steps gauge\nwoodpecker_running_steps 0\n# HELP woodpecker_user_count Total number of users.\n# TYPE woodpecker_user_count gauge\nwoodpecker_user_count 1\n# HELP woodpecker_waiting_steps Total number of pipeline waiting on deps.\n# TYPE woodpecker_waiting_steps gauge\nwoodpecker_waiting_steps 0\n# HELP woodpecker_worker_count Total number of workers.\n# TYPE woodpecker_worker_count gauge\nwoodpecker_worker_count 4\n```\n\n#### Example response structure\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n\n## UI customization\n\nWoodpecker supports custom JS and CSS files. These files must be present in the server's filesystem.\nThey can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment.\nThe configuration variables are independent of each other, which means it can be just one file present, or both.\n\n```ini\nWOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css\nWOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js\n```\n\nThe examples below show how to place a banner message in the top navigation bar of Woodpecker.\n\n```css title=\"woodpecker.css\"\n.banner-message {\n  position: absolute;\n  width: 280px;\n  height: 40px;\n  margin-left: 240px;\n  margin-top: 5px;\n  padding-top: 5px;\n  font-weight: bold;\n  background: red no-repeat;\n  text-align: center;\n}\n```\n\n```javascript title=\"woodpecker.js\"\n// place/copy a minified version of your preferred lightweight JavaScript library here ...\n!(function () {\n  'use strict';\n  function e() {} /*...*/\n})();\n\n$().ready(function () {\n  $('.app nav img').first().htmlAfter(\"<div class='banner-message'>This is a demo banner message :)</div>\");\n});\n```\n\n## Environment variables\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### LOG_FILE\n\n- Name: `WOODPECKER_LOG_FILE`\n- Default: `stderr`\n\nOutput destination for logs.\n'stdout' and 'stderr' can be used as special keywords.\n\n---\n\n### DATABASE_LOG\n\n- Name: `WOODPECKER_DATABASE_LOG`\n- Default: `false`\n\nEnable logging in database engine (currently xorm).\n\n---\n\n### DATABASE_LOG_SQL\n\n- Name: `WOODPECKER_DATABASE_LOG_SQL`\n- Default: `false`\n\nEnable logging of sql commands.\n\n---\n\n### DATABASE_MAX_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS`\n- Default: `100`\n\nMax database connections xorm is allowed create.\n\n---\n\n### DATABASE_IDLE_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS`\n- Default: `2`\n\nAmount of database connections xorm will hold open.\n\n---\n\n### DATABASE_CONNECTION_TIMEOUT\n\n- Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT`\n- Default: `3 Seconds`\n\nTime an active database connection is allowed to stay open.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOST\n\n- Name: `WOODPECKER_HOST`\n- Default: none\n\nServer fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix.\n\nExamples:\n\n- `WOODPECKER_HOST=http://woodpecker.example.org`\n- `WOODPECKER_HOST=http://example.org/woodpecker`\n- `WOODPECKER_HOST=http://example.org:1234/woodpecker`\n\n---\n\n### SERVER_ADDR\n\n- Name: `WOODPECKER_SERVER_ADDR`\n- Default: `:8000`\n\nConfigures the HTTP listener port.\n\n---\n\n### SERVER_ADDR_TLS\n\n- Name: `WOODPECKER_SERVER_ADDR_TLS`\n- Default: `:443`\n\nConfigures the HTTPS listener port when SSL is enabled.\n\n---\n\n### SERVER_CERT\n\n- Name: `WOODPECKER_SERVER_CERT`\n- Default: none\n\nPath to an SSL certificate used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_CERT=/path/to/cert.pem`\n\n---\n\n### SERVER_KEY\n\n- Name: `WOODPECKER_SERVER_KEY`\n- Default: none\n\nPath to an SSL certificate key used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_KEY=/path/to/key.pem`\n\n---\n\n### CUSTOM_CSS_FILE\n\n- Name: `WOODPECKER_CUSTOM_CSS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .CSS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css`\n\n---\n\n### CUSTOM_JS_FILE\n\n- Name: `WOODPECKER_CUSTOM_JS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .JS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js`\n\n---\n\n### GRPC_ADDR\n\n- Name: `WOODPECKER_GRPC_ADDR`\n- Default: `:9000`\n\nConfigures the gRPC listener port.\n\n---\n\n### GRPC_SECRET\n\n- Name: `WOODPECKER_GRPC_SECRET`\n- Default: `secret`\n\nConfigures the gRPC JWT secret.\n\n---\n\n### GRPC_SECRET_FILE\n\n- Name: `WOODPECKER_GRPC_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GRPC_SECRET` from the specified filepath.\n\n---\n\n### METRICS_SERVER_ADDR\n\n- Name: `WOODPECKER_METRICS_SERVER_ADDR`\n- Default: none\n\nConfigures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely.\n\nExample: `:9001`\n\n---\n\n### ADMIN\n\n- Name: `WOODPECKER_ADMIN`\n- Default: none\n\nComma-separated list of admin accounts.\n\nExample: `WOODPECKER_ADMIN=user1,user2`\n\n---\n\n### ORGS\n\n- Name: `WOODPECKER_ORGS`\n- Default: none\n\nComma-separated list of approved organizations.\n\nExample: `org1,org2`\n\n---\n\n### REPO_OWNERS\n\n- Name: `WOODPECKER_REPO_OWNERS`\n- Default: none\n\nRepositories by those owners will be allowed to be used in woodpecker.\n\nExample: `user1,user2`\n\n---\n\n### OPEN\n\n- Name: `WOODPECKER_OPEN`\n- Default: `false`\n\nEnable to allow user registration.\n\n---\n\n### AUTHENTICATE_PUBLIC_REPOS\n\n- Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`\n- Default: `false`\n\nAlways use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies.\n\n---\n\n### DEFAULT_ALLOW_PULL_REQUESTS\n\n- Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS`\n- Default: `true`\n\nThe default setting for allowing pull requests on a repo.\n\n---\n\n### DEFAULT_APPROVAL_MODE\n\n- Name: `WOODPECKER_DEFAULT_APPROVAL_MODE`\n- Default: `forks`\n\nThe default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`.\n\n---\n\n### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS\n\n- Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS`\n- Default: `pull_request, push`\n\nList of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\n\n---\n\n### DEFAULT_CLONE_PLUGIN\n\n- Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN`\n- Default: `docker.io/woodpeckerci/plugin-git`\n\nThe default docker image to be used when cloning the repo.\n\nIt is also added to the trusted clone plugin list.\n\n### DEFAULT_WORKFLOW_LABELS\n\n- Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS`\n- Default: none\n\nYou can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set.\n\nExample: `platform=linux/amd64,backend=docker`\n\n### DEFAULT_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`\n- Default: 60\n\nThe default time for a repo in minutes before a pipeline gets killed\n\n### MAX_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT`\n- Default: 120\n\nThe maximum time in minutes you can set in the repo settings before a pipeline gets killed\n\n---\n\n### SESSION_EXPIRES\n\n- Name: `WOODPECKER_SESSION_EXPIRES`\n- Default: `72h`\n\nConfigures the session expiration time.\nContext: when someone does log into Woodpecker, a temporary session token is created.\nAs long as the session is valid (until it expires or log-out),\na user can log into Woodpecker, without re-authentication.\n\n### PLUGINS_PRIVILEGED\n\n- Name: `WOODPECKER_PLUGINS_PRIVILEGED`\n- Default: none\n\nDocker images to run in privileged mode. Only change if you are sure what you do!\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n### PLUGINS_TRUSTED_CLONE\n\n- Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE`\n- Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git`\n\nPlugins which are trusted to handle the Git credential info in clone steps.\nIf a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos.\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n<!-- ---\n\n### `VOLUME`\n\n- Name: `WOODPECKER_VOLUME`\n- Default: none\n\nComma-separated list of Docker volumes that are mounted into every pipeline step.\n\nExample: `WOODPECKER_VOLUME=/path/on/host:/path/in/container:rw`| -->\n\n---\n\n### DOCKER_CONFIG\n\n- Name: `WOODPECKER_DOCKER_CONFIG`\n- Default: none\n\nConfigures a specific private registry config for all pipelines.\n\nExample: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json`\n\n---\n\n### ENVIRONMENT\n\n- Name: `WOODPECKER_ENVIRONMENT`\n- Default: none\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\nExample: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2`\n\n<!-- ---\n\n### NETWORK\n\n- Name: `WOODPECKER_NETWORK`\n- Default: none\n\nComma-separated list of Docker networks that are attached to every pipeline step.\n\nExample: `WOODPECKER_NETWORK=network1,network2` -->\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath\n\n---\n\n### DISABLE_USER_AGENT_REGISTRATION\n\n- Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION`\n- Default: false\n\nBy default, users can create new agents for their repos they have admin access to.\nIf an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements.\n\n:::note\nYou should set this option if you have, for example,\nglobal secrets and don't trust your users to create a rogue agent and pipeline for secret extraction.\n:::\n\n---\n\n### KEEPALIVE_MIN_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_MIN_TIME`\n- Default: none\n\nServer-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\n\nExample: `WOODPECKER_KEEPALIVE_MIN_TIME=10s`\n\n---\n\n### DATABASE_DRIVER\n\n- Name: `WOODPECKER_DATABASE_DRIVER`\n- Default: `sqlite3`\n\nThe database driver name. Possible values are `sqlite3`, `mysql` or `postgres`.\n\n---\n\n### DATABASE_DATASOURCE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE`\n- Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container\n\nThe database connection string. The default value is the path of the embedded SQLite database file.\n\nExample:\n\n```bash\n# MySQL\n# https://github.com/go-sql-driver/mysql#dsn-data-source-name\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n\n# PostgreSQL\n# https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable\n```\n\n---\n\n### DATABASE_DATASOURCE_FILE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath\n\n---\n\n### PROMETHEUS_AUTH_TOKEN\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN`\n- Default: none\n\nToken to secure the Prometheus metrics endpoint.\nMust be set to enable the endpoint.\n\n---\n\n### PROMETHEUS_AUTH_TOKEN_FILE\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath\n\n---\n\n### STATUS_CONTEXT\n\n- Name: `WOODPECKER_STATUS_CONTEXT`\n- Default: `ci/woodpecker`\n\nContext prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository.\n\n---\n\n### STATUS_CONTEXT_FORMAT\n\n- Name: `WOODPECKER_STATUS_CONTEXT_FORMAT`\n- Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}`\n\nTemplate for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language.\nSupported variables:\n\n- `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`)\n- `event`: the event which started the pipeline\n- `workflow`: the workflow's name\n- `owner`: the repo's owner\n- `repo`: the repo's name\n\n---\n\n### CONFIG_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_CONFIG_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a configuration extension endpoint, see [Configuration Extension](../../20-usage/72-extensions/40-configuration-extension.md)\n\n---\n\n### CONFIG_EXTENSION_EXCLUSIVE\n\n- Name: `CONFIG_EXTENSION_EXCLUSIVE`\n- Default: false\n\nWhether the forge request should be skipped for the global configuration endpoint.\n\n:::warning\nIf you enable this, all repos will exclusively use the global config service endpoint. There is no possibility to directly define pipelines in the forge, except the extension handles this case itself as well.\n:::\n\n---\n\n### CONFIG_EXTENSION_NETRC\n\n- Name: `WOODPECKER_CONFIG_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the config extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### SECRET_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_SECRET_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a secret extension endpoint, see [Secret Extension](../../20-usage/72-extensions/55-secret-extension.md)\n\n---\n\n### SECRET_EXTENSION_NETRC\n\n- Name: `WOODPECKER_SECRET_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the secret extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### REGISTRY_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_REGISTRY_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a registry extension endpoint, see [Registry Extension](../../20-usage/72-extensions/50-registry-extension.md)\n\n---\n\n### REGISTRY_EXTENSION_NETRC\n\n- Name: `WOODPECKER_REGISTRY_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the registry extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### EXTENSIONS_ALLOWED_HOSTS\n\n- Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS`\n- Default: `external`\n\nComma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list.\n\n---\n\n### FORGE_TIMEOUT\n\n- Name: `WOODPECKER_FORGE_TIMEOUT`\n- Default: 5s\n\nSpecify timeout when fetching the Woodpecker configuration from forge. See <https://pkg.go.dev/time#ParseDuration> for syntax reference.\n\n---\n\n### FORGE_RETRY\n\n- Name: `WOODPECKER_FORGE_RETRY`\n- Default: 3\n\nSpecify how many retries of fetching the Woodpecker configuration from a forge are done before we fail.\n\n---\n\n### ENABLE_SWAGGER\n\n- Name: `WOODPECKER_ENABLE_SWAGGER`\n- Default: true\n\nEnable the Swagger UI for API documentation.\n\n---\n\n### DISABLE_VERSION_CHECK\n\n- Name: `WOODPECKER_DISABLE_VERSION_CHECK`\n- Default: false\n\nDisable version check in admin web UI.\n\n---\n\n### LOG_STORE\n\n- Name: `WOODPECKER_LOG_STORE`\n- Default: `database`\n\nWhere to store logs. Possible values:\n\n- `database`: stores the logs in the database\n- `file`: stores logs in JSON files on the files system\n- `addon`: uses an [addon](./100-addons.md#log) to store logs\n\n---\n\n### LOG_STORE_FILE_PATH\n\n- Name: `WOODPECKER_LOG_STORE_FILE_PATH`\n- Default: none\n\nIf [`WOODPECKER_LOG_STORE`](#log_store) is:\n\n- `file`: Directory to store logs in\n- `addon`: The path to the addon executable\n\n---\n\n### EXPERT_WEBHOOK_HOST\n\n- Name: `WOODPECKER_EXPERT_WEBHOOK_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### EXPERT_FORGE_OAUTH_HOST\n\n- Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified public forge URL, used if forge url is not a public URL. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### FORCE_IGNORE_SERVICE_FAILURE\n\n- Name: `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE`\n- Default: true\n\n:::warning\nSince v3.14.0, Woodpecker can report the status of services and detached steps.\nBecause these can now fail, until v4.0.0 is released, service failures are ignored by default to preserve backward compatibility.\nWe encourage you to disable this option and update your pipeline configuration.\n:::\n\n---\n\n### GITHUB\\_\\*\n\nSee [GitHub configuration](./12-forges/20-github.md#configuration)\n\n---\n\n### GITEA\\_\\*\n\nSee [Gitea configuration](./12-forges/30-gitea.md#configuration)\n\n---\n\n### BITBUCKET\\_\\*\n\nSee [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration)\n\n---\n\n### GITLAB\\_\\*\n\nSee [GitLab configuration](./12-forges/40-gitlab.md#configuration)\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/100-addons.md",
    "content": "# Addons\n\nAddons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service.\n\n:::warning\nAddon forges are still experimental. Their implementation can change and break at any time.\n:::\n\n:::danger\nYou must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information.\n:::\n\n## Usage\n\nTo use an addon forge, download the correct addon version.\n\n### Forge\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file\n```\n\nIn case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`.\n\n#### List of addon forges\n\n- [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws).\n\n### Log\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_LOG_STORE=addon\nWOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file\n```\n\n## Developing addon forges\n\nSee [Addons](../../92-development/100-addons.md).\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/11-backends/10-docker.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Docker\n\nThis is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent.\n\n## Private registries\n\nWoodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config).\n\nTo add your credential helper to the Woodpecker server container you could use the following code to build a custom image:\n\n```dockerfile\nFROM woodpeckerci/woodpecker-server:latest-alpine\n\nRUN apk add -U --no-cache docker-credential-ecr-login\n```\n\n## Step specific configuration\n\n### Run user\n\nBy default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group:\n\n```yaml\nsteps:\n  - name: example\n    image: alpine\n    commands:\n      - whoami\n    backend_options:\n      docker:\n        user: 65534:65534\n```\n\nThe syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag.\n\n## Tips and tricks\n\n### Image cleanup\n\nThe agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.\n\n:::danger\nThe following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation.\n:::\n\n- Remove all unused images\n\n  <!-- cspell:ignore trunc -->\n\n  ```bash\n  docker image rm $(docker images --filter \"dangling=true\" -q --no-trunc)\n  ```\n\n- Remove Woodpecker volumes\n\n  ```bash\n  docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true  -q)\n  ```\n\n### Podman\n\nThere is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog).\n\n## Environment variables\n\n### BACKEND_DOCKER_NETWORK\n\n- Name: `WOODPECKER_BACKEND_DOCKER_NETWORK`\n- Default: none\n\nSet to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other!\n\n---\n\n### BACKEND_DOCKER_ENABLE_IPV6\n\n- Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6`\n- Default: `false`\n\nEnable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6.\n\n---\n\n### BACKEND_DOCKER_VOLUMES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n- Default: none\n\nList of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA\ncertificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM_SWAP\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_SHM_SIZE\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE`\n- Default: `0`\n\nThe maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_QUOTA\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA`\n- Default: `0`\n\nThe number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SHARES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES`\n- Default: `0`\n\nThe relative weight vs. other containers.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SET\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET`\n- Default: none\n\nComma-separated list to limit the specific CPUs or cores a pipeline container can use.\n\nExample: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2`\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Kubernetes\n\nThe Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps.\n\n## Metadata labels\n\nWoodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies.\n\nThe following metadata labels are supported:\n\n- `woodpecker-ci.org/forge-id`\n- `woodpecker-ci.org/repo-forge-id`\n- `woodpecker-ci.org/repo-id`\n- `woodpecker-ci.org/repo-name`\n- `woodpecker-ci.org/repo-full-name`\n- `woodpecker-ci.org/branch`\n- `woodpecker-ci.org/org-id`\n- `woodpecker-ci.org/task-uuid`\n- `woodpecker-ci.org/step`\n\n## Private registries\n\nIn addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML.\n\nPlace these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.\n\n## Step specific configuration\n\n### Resources\n\nThe Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory.\nWe recommend to add a `resources` definition to all steps to ensure efficient scheduling.\n\nHere is an example definition with an arbitrary `resources` definition below the `backend_options` section:\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        resources:\n          requests:\n            memory: 200Mi\n            cpu: 100m\n          limits:\n            memory: 400Mi\n            cpu: 1000m\n```\n\nYou can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis.\n\n### Runtime class\n\n`runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes.\n\n### Service account\n\n`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        # Use the service account `default` in the current namespace.\n        # This usually the same as wherever woodpecker is deployed.\n        serviceAccountName: default\n```\n\nTo give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)\n\n### Node selector\n\n`nodeSelector` specifies the labels which are used to select the node on which the step will be executed.\n\nLabels defined here will be appended to a list which already contains `\"kubernetes.io/arch\"`.\nBy default `\"kubernetes.io/arch\"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`.\nWithout a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures.\n\nTo overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`.\nA practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture.\nIn this case, one must define an arbitrary key in the matrix section of the respective matrix element:\n\n```yaml\nmatrix:\n  include:\n    - NAME: runner1\n      ARCH: arm64\n```\n\nAnd then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var:\n\n```yaml\n[...]\n    backend_options:\n      kubernetes:\n        nodeSelector:\n          kubernetes.io/arch: \"${ARCH}\"\n```\n\nYou can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent\nor [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis.\n\n### Tolerations\n\nWhen you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations.\n\nExample pipeline configuration:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go get\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        serviceAccountName: 'my-service-account'\n        resources:\n          requests:\n            memory: 128Mi\n            cpu: 1000m\n          limits:\n            memory: 256Mi\n        nodeSelector:\n          beta.kubernetes.io/instance-type: Standard_D2_v3\n        tolerations:\n          - key: 'key1'\n            operator: 'Equal'\n            value: 'value1'\n            effect: 'NoSchedule'\n            tolerationSeconds: 3600\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: topology.kubernetes.io/zone\n                      operator: In\n                      values:\n                        - eu-central-1a\n                        - eu-central-1b\n```\n\n### Affinity\n\nKubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods.\n\nYou can configure affinity at two levels:\n\n1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it\n2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden\n\n#### Agent-wide affinity\n\nTo apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  nodeAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n      nodeSelectorTerms:\n        - matchExpressions:\n            - key: node-role.kubernetes.io/worker\n              operator: In\n              values:\n                - \"true\"\n```\n\nBy default, per-step affinity settings are **not allowed** for security reasons. To enable them:\n\n```bash\nWOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true\n```\n\n:::warning\nEnabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications.\n:::\n\nWhen per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged).\n\n#### Example: agent affinity for co-location\n\nThis example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes.\n\nIt uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  podAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      matchLabelKeys:\n        - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n  podAntiAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      mismatchLabelKeys:\n      - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n```\n\n:::note\nThe `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`.\n:::\n\n#### Example: Node affinity for GPU workloads\n\nEnsure a step runs only on GPU-enabled nodes:\n\n```yaml\nsteps:\n  - name: train-model\n    image: tensorflow/tensorflow:latest-gpu\n    backend_options:\n      kubernetes:\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: accelerator\n                      operator: In\n                      values:\n                        - nvidia-tesla-v100\n```\n\n### Volumes\n\nTo mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.\n\nPersistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference.\n\n_If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._\n\nNOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver:\n\n```yaml\naccessModes:\n  - ReadWriteMany\n```\n\nAssuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step:\n\n```yaml\nsteps:\n  - name: \"Restore Cache\"\n    image: meltwater/drone-cache\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    settings:\n      mount:\n        - \"woodpecker-cache\"\n    [...]\n```\n\nOr as follows when using a normal image:\n\n```yaml\nsteps:\n  - name: \"Edit cache\"\n    image: alpine:latest\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    commands:\n      - echo \"Hello World\" > /woodpecker/src/cache/output.txt\n    [...]\n```\n\n### Security context\n\nUse the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:\n\n```yaml\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo Hello world\n    backend_options:\n      kubernetes:\n        securityContext:\n          runAsUser: 999\n          runAsGroup: 999\n          privileged: true\n    [...]\n```\n\nNote that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object.\nBy default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the\nconfiguration shown above will result in something like the following Pod spec:\n\n<!-- cspell:disable -->\n\n```yaml\nkind: Pod\nspec:\n  securityContext:\n    runAsUser: 999\n    runAsGroup: 999\n  containers:\n    - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0\n      image: alpine\n      securityContext:\n        privileged: true\n  [...]\n```\n\n<!-- cspell:enable -->\n\nYou can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      seccompProfile:\n        type: Localhost\n        localhostProfile: profiles/audit.json\n```\n\nor restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      apparmorProfile:\n        type: Localhost\n        localhostProfile: k8s-apparmor-example-deny-write\n```\n\nor configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always')\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      fsGroupChangePolicy: OnRootMismatch\n```\n\n:::note\nThe feature requires Kubernetes v1.30 or above.\n:::\n\nYou can set `allowPrivilegeEscalation` to `false` to prevent a container from gaining more privileges than its parent process.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      allowPrivilegeEscalation: false\n```\n\nYou can also drop [Linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) from a container. Adding capabilities is not allowed.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      capabilities:\n        drop:\n          - ALL\n```\n\n### Annotations and labels\n\nYou can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration:\n\n```yaml\nbackend_options:\n  kubernetes:\n    annotations:\n      workflow-group: alpha\n      io.kubernetes.cri-o.Devices: /dev/fuse\n    labels:\n      environment: ci\n      app.kubernetes.io/name: builder\n```\n\nIn order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent:\n[WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step).\n\n## Tips and tricks\n\n### CRI-O\n\nCRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration:\n\n```yaml\nworkspace:\n  base: '/woodpecker'\n  path: '/'\n```\n\nSee [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details.\n\n### `KUBERNETES_SERVICE_HOST` environment variable\n\nLike the below env vars used for configuration, this can be set in the environment for configuration of the agent.\nIt configures the address of the Kubernetes API server to connect to.\n\nIf running the agent within Kubernetes, this will already be set and you don't have to add it manually.\n\n### Headless services\n\nFor each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created,\nand all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname.\n\nUsing the headless services, the step pod is connected to directly, so any port on the other step pods can be reached.\n\nThis is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service.\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/woodpecker/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    commands:\n      - docker run hello-world\n\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    detached: true\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /woodpecker/dind-certs\n```\n\nIf ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP.\n\n## Environment variables\n\nThese env vars can be set in the `env:` sections of the agent.\n\n---\n\n### BACKEND_K8S_NAMESPACE\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE`\n- Default: `woodpecker`\n\nThe namespace to create worker Pods in.\n\n---\n\n### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION`\n- Default: `false`\n\nEnables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation.\n\nWith this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker.\n\n### BACKEND_K8S_VOLUME_SIZE\n\n- Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE`\n- Default: `10G`\n\nThe volume size of the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS`\n- Default: none\n\nThe storage class to use for the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_RWX\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX`\n- Default: `true`\n\nDetermines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead.\n\n---\n\n### BACKEND_K8S_POD_LABELS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS`\n- Default: none\n\nAdditional labels to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-label\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if additional Pod labels can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS`\n- Default: none\n\nAdditional annotations to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-annotation\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if Pod annotations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS`\n- Default: none\n\nAdditional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{\"effect\":\"NoSchedule\",\"key\":\"jobs\",\"operator\":\"Exists\"}]`.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP`\n- Default: `true`\n\nDetermines if Pod tolerations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_NODE_SELECTOR\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR`\n- Default: none\n\nAdditional node selector to apply to worker pods. Must be a YAML object, e.g. `{\"topology.kubernetes.io/region\":\"eu-central-1\"}`.\n\n---\n\n### BACKEND_K8S_SECCTX_NONROOT <!-- cspell:ignore SECCTX NONROOT -->\n\n- Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT`\n- Default: `false`\n\nDetermines if containers must be required to run as non-root users.\n\n---\n\n### BACKEND_K8S_PULL_SECRET_NAMES\n\n- Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`\n- Default: none\n\nSecret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\n\n---\n\n### BACKEND_K8S_PRIORITY_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS`\n- Default: none, which will use the default priority class configured in Kubernetes\n\nWhich [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/11-backends/30-local.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Local\n\n:::danger\nThe local backend executes pipelines on the local system without any isolation.\n:::\n\n:::note\nCurrently we do not support [services](../../../20-usage/60-services.md) for this backend.\n[Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095).\n:::\n\nSince the commands run directly in the same context as the agent (same user, same\nfilesystem), a malicious pipeline could be used to access the agent\nconfiguration especially the `WOODPECKER_AGENT_SECRET` variable.\n\nIt is recommended to use this backend only for private setup where the code and\npipeline can be trusted. It should not be used in a public instance where\nanyone can submit code or add new repositories. The agent should not run as a privileged user (root).\n\nThe local backend will use a random directory in `$TMPDIR` to store the cloned\ncode and execute commands.\n\nIn order to use this backend, you need to download (or build) the\n[agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine.\n\n## Step specific configuration\n\n### Shell\n\nThe `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is\nused to run the commands.\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: bash\n    commands: [...]\n```\n\n### Plugins\n\n```yaml\nsteps:\n  - name: build\n    image: /usr/bin/tree\n```\n\nIf no commands are provided, plugins are treated in the usual manner.\nIn the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path.\n\n## Environment variables\n\n### BACKEND_LOCAL_TEMP_DIR\n\n- Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR`\n- Default: default temp directory\n\nDirectory to create folders for workflows.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/11-backends/50-custom.md",
    "content": "# Custom\n\nIf none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend:\n\n```go\npackage main\n\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n  backendTypes \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc main() {\n  core.RunAgent([]backendTypes.Backend{\n    yourBackend,\n  })\n}\n```\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/11-backends/_category_.yaml",
    "content": "label: 'Backends'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/11-overview.md",
    "content": "# Forges\n\n## Supported features\n\n| Feature                                                                                                                | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |\n| ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- |\n| Event: Push                                                                                                            | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Tag                                                                                                             | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Pull-Request                                                                                                    | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Release                                                                                                         | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| Event: Deploy¹                                                                                                         | :white_check_mark:     | :x:                  | :x:                      | :x:                    | :x:                          | :x:                                                |\n| [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| [Multiple workflows](../../../20-usage/25-workflows.md)                                                                | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| [when.path filter](../../../20-usage/20-workflow-syntax.md#path)                                                       | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n\n¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks.\n\nIn addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/20-github.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitHub\n\nWoodpecker comes with built-in support for GitHub and GitHub Enterprise.\nTo use Woodpecker with GitHub the following environment variables should be set for the server component:\n\n```ini\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID\nWOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET\n```\n\nYou will get these values from GitHub when you register your OAuth application.\nTo do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App.\n\n:::warning\nDo not use a \"GitHub App\" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically)\n:::\n\n## App Settings\n\n- Name: An arbitrary name for your App\n- Homepage URL: The URL of your Woodpecker instance\n- Callback URL: `https://<your-woodpecker-instance>/authorize`\n- (optional) Upload the Woodpecker Logo: <https://avatars.githubusercontent.com/u/84780935?s=200&v=4>\n\n## Client Secret Creation\n\nAfter your App has been created, you can generate a client secret.\nUse this one for the `WOODPECKER_GITHUB_SECRET` environment variable.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITHUB\n\n- Name: `WOODPECKER_GITHUB`\n- Default: `false`\n\nEnables the GitHub driver.\n\n---\n\n### GITHUB_URL\n\n- Name: `WOODPECKER_GITHUB_URL`\n- Default: `https://github.com`\n\nConfigures the GitHub server address.\n\n---\n\n### GITHUB_CLIENT\n\n- Name: `WOODPECKER_GITHUB_CLIENT`\n- Default: none\n\nConfigures the GitHub OAuth client id to authorize access.\n\n---\n\n### GITHUB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITHUB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath.\n\n---\n\n### GITHUB_SECRET\n\n- Name: `WOODPECKER_GITHUB_SECRET`\n- Default: none\n\nConfigures the GitHub OAuth client secret. This is used to authorize access.\n\n---\n\n### GITHUB_SECRET_FILE\n\n- Name: `WOODPECKER_GITHUB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath.\n\n---\n\n### GITHUB_MERGE_REF\n\n- Name: `WOODPECKER_GITHUB_MERGE_REF`\n- Default: `true`\n\n---\n\n### GITHUB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITHUB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### GITHUB_PUBLIC_ONLY\n\n- Name: `WOODPECKER_GITHUB_PUBLIC_ONLY`\n- Default: `false`\n\nConfigures the GitHub OAuth client to only obtain a token that can manage public repositories.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/30-gitea.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Gitea\n\nWoodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITEA=true\nWOODPECKER_GITEA_URL=YOUR_GITEA_URL\nWOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT\nWOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET\n```\n\n## Gitea on the same host with containers\n\nIf you have Gitea also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `gitea`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea\n```\n\n## Registration\n\n### User OAuth Application\n\nRegister your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\n### System-wide OAuth Application\n\nIf you are the administrator of both Gitea and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Gitea site administrator level and are visible to all users.\n\nTo create a system-wide OAuth application in Gitea:\n\n1. Navigate to the site administration settings at `https://gitea.<host>/admin/settings/applications`\n2. Create a new OAuth2 application under the \"OAuth2 Applications\" section\n3. Configure the application with the same settings as above (callback URL, etc.)\n4. Use the generated client id and secret for Woodpecker configuration\n\nSystem-wide applications are particularly useful for:\n\n- Shared CI/CD environments where multiple users need Woodpecker access\n- Organizations that want centralized control over OAuth applications\n- Preventing user-level application quotas from affecting CI/CD operations\n\n### Local Connections\n\nIf you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook).\n\n![gitea oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITEA\n\n- Name: `WOODPECKER_GITEA`\n- Default: `false`\n\nEnables the Gitea driver.\n\n---\n\n### GITEA_URL\n\n- Name: `WOODPECKER_GITEA_URL`\n- Default: `https://try.gitea.io`\n\nConfigures the Gitea server address.\n\n---\n\n### GITEA_CLIENT\n\n- Name: `WOODPECKER_GITEA_CLIENT`\n- Default: none\n\nConfigures the Gitea OAuth client id. This is used to authorize access.\n\n---\n\n### GITEA_CLIENT_FILE\n\n- Name: `WOODPECKER_GITEA_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath\n\n---\n\n### GITEA_SECRET\n\n- Name: `WOODPECKER_GITEA_SECRET`\n- Default: none\n\nConfigures the Gitea OAuth client secret. This is used to authorize access.\n\n---\n\n### GITEA_SECRET_FILE\n\n- Name: `WOODPECKER_GITEA_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_SECRET` from the specified filepath\n\n---\n\n### GITEA_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITEA_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/35-forgejo.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Forgejo\n\nWoodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_FORGEJO=true\nWOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL\nWOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT\nWOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET\n```\n\n## Forgejo on the same host with containers\n\nIf you have Forgejo also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `forgejo`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo\n```\n\n## Registration\n\n### User OAuth Application\n\nRegister your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\n### System-wide OAuth Application\n\nIf you are the administrator of both Forgejo and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Forgejo site administrator level and are visible to all users.\n\nTo create a system-wide OAuth application in Forgejo:\n\n1. Navigate to the site administration settings at `https://forgejo.<host>/admin/settings/applications`\n2. Create a new OAuth2 application under the \"OAuth2 Applications\" section\n3. Configure the application with the same settings as above (callback URL, etc.)\n4. Use the generated client id and secret for Woodpecker configuration\n\nSystem-wide applications are particularly useful for:\n\n- Shared CI/CD environments where multiple users need Woodpecker access\n- Organizations that want centralized control over OAuth applications\n- Preventing user-level application quotas from affecting CI/CD operations\n\n### Local Connections\n\nIf you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).\n\n![forgejo oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### FORGEJO\n\n- Name: `WOODPECKER_FORGEJO`\n- Default: `false`\n\nEnables the Forgejo driver.\n\n---\n\n### FORGEJO_URL\n\n- Name: `WOODPECKER_FORGEJO_URL`\n- Default: `https://next.forgejo.org`\n\nConfigures the Forgejo server address.\n\n---\n\n### FORGEJO_CLIENT\n\n- Name: `WOODPECKER_FORGEJO_CLIENT`\n- Default: none\n\nConfigures the Forgejo OAuth client id. This is used to authorize access.\n\n---\n\n### FORGEJO_CLIENT_FILE\n\n- Name: `WOODPECKER_FORGEJO_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath\n\n---\n\n### FORGEJO_SECRET\n\n- Name: `WOODPECKER_FORGEJO_SECRET`\n- Default: none\n\nConfigures the Forgejo OAuth client secret. This is used to authorize access.\n\n---\n\n### FORGEJO_SECRET_FILE\n\n- Name: `WOODPECKER_FORGEJO_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath\n\n---\n\n### FORGEJO_SKIP_VERIFY\n\n- Name: `WOODPECKER_FORGEJO_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/40-gitlab.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitLab\n\nWoodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITLAB=true\nWOODPECKER_GITLAB_URL=http://gitlab.mycompany.com\nWOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82\nWOODPECKER_GITLAB_SECRET=30f5064039e6b359e075\n```\n\n## Registration\n\nYou must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application.\n\nPlease use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application.\n\nIf you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITLAB\n\n- Name: `WOODPECKER_GITLAB`\n- Default: `false`\n\nEnables the GitLab driver.\n\n---\n\n### GITLAB_URL\n\n- Name: `WOODPECKER_GITLAB_URL`\n- Default: `https://gitlab.com`\n\nConfigures the GitLab server address.\n\n---\n\n### GITLAB_CLIENT\n\n- Name: `WOODPECKER_GITLAB_CLIENT`\n- Default: none\n\nConfigures the GitLab OAuth client id. This is used to authorize access.\n\n---\n\n### GITLAB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITLAB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath\n\n---\n\n### GITLAB_SECRET\n\n- Name: `WOODPECKER_GITLAB_SECRET`\n- Default: none\n\nConfigures the GitLab OAuth client secret. This is used to authorize access.\n\n---\n\n### GITLAB_SECRET_FILE\n\n- Name: `WOODPECKER_GITLAB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath\n\n---\n\n### GITLAB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITLAB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/50-bitbucket.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket\n\nWoodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_BITBUCKET=true\nWOODPECKER_BITBUCKET_CLIENT=... # called \"Key\" in Bitbucket\nWOODPECKER_BITBUCKET_SECRET=...\n```\n\n## Registration\n\nYou must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`).\n\nPlease set a name and set the `Callback URL` like this:\n\n```uri\nhttps://<your-woodpecker-address>/authorize\n```\n\n![bitbucket oauth setup](bitbucket_oauth.png)\n\nPlease also be sure to check the following permissions:\n\n- Account: Email, Read\n- Workspace membership: Read\n- Projects: Read\n- Repositories: Read\n- Pull requests: Read\n- Webhooks: Read and Write\n\n![bitbucket permissions](bitbucket_permissions.png)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET\n\n- Name: `WOODPECKER_BITBUCKET`\n- Default: `false`\n\nEnables the Bitbucket driver.\n\n---\n\n### BITBUCKET_CLIENT\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT`\n- Default: none\n\nConfigures the Bitbucket OAuth client key. This is used to authorize access.\n\n---\n\n### BITBUCKET_CLIENT_FILE\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath\n\n---\n\n### BITBUCKET_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_SECRET`\n- Default: none\n\nConfigures the Bitbucket OAuth client secret. This is used to authorize access.\n\n---\n\n### BITBUCKET_SECRET_FILE\n\n- Name: `WOODPECKER_BITBUCKET_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath\n\n## Known Issues\n\nBitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details.\n\n## Missing Features\n\nPath filters for pull requests are not supported. We are interested in patches to include this functionality.\nIf you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de).\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket Datacenter / Server\n\n:::warning\nWoodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash.\n:::\n\nTo enable Bitbucket Server you should configure the Woodpecker container using the following environment variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BITBUCKET_DC=true\n+      - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo\n+      - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy\n+      - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com\n+      - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true\n\n   woodpecker-agent:\n     [...]\n```\n\n## Service Account\n\nWoodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories.\n\n## Registration\n\nWoodpecker must be registered with Bitbucket Datacenter / Server.\nIn the administration section of Bitbucket choose \"Application Links\" and then \"Create link\".\nWoodpecker should be listed as \"External Application\" and the direction should be set to \"Incoming\".\nNote the client id and client secret of the registration to be used in the configuration of Woodpecker.\n\nSee also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html).\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET_DC\n\n- Name: `WOODPECKER_BITBUCKET_DC`\n- Default: `false`\n\nEnables the Bitbucket Server driver.\n\n---\n\n### BITBUCKET_DC_URL\n\n- Name: `WOODPECKER_BITBUCKET_DC_URL`\n- Default: none\n\nConfigures the Bitbucket Server address.\n\n---\n\n### BITBUCKET_DC_CLIENT_ID\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client id.\n\n---\n\n### BITBUCKET_DC_CLIENT_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client secret.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME`\n- Default: none\n\nThis username is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD`\n- Default: none\n\nThe password is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath\n\n---\n\n### BITBUCKET_DC_SKIP_VERIFY\n\n- Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN\n\n- Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN`\n- Default: `false`\n\nWhen enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/12-forges/_category_.yaml",
    "content": "label: 'Forges'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/30-agent.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Agent\n\nAgents are configured by the command line or environment variables. At the minimum you need the following information:\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\n```\n\nThe following are automatically set and can be overridden:\n\n- `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname\n- `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1\n\n## Workflows per agent\n\nBy default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent.\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\nWOODPECKER_MAX_WORKFLOWS=4\n```\n\n## Agent registration\n\nWhen the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before.\n\nThere are two types of tokens to connect an agent to the server:\n\n### Using system token\n\nA _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents.\n\nIn that case registration process would be as following:\n\n1. The first time the agent communicates with the server, it is using the system token\n1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent\n1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`)\n1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server\n\n### Using agent token\n\nAn _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`.\n\nTo get an _agent token_ you have to register the agent manually in the server using the UI:\n\n1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent`\n   ![Agent creation](./new-agent-registration.png)\n   ![Agent created](./new-agent-created.png)\n1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET`\n1. The agent will connect to the server using the provided token and will update its status in the UI:\n   ![Agent connected](./new-agent-connected.png)\n\n## Environment variables\n\n### SERVER\n\n- Name: `WOODPECKER_SERVER`\n- Default: `localhost:9000`\n\nConfigures gRPC address of the server.\n\n---\n\n### USERNAME\n\n- Name: `WOODPECKER_USERNAME`\n- Default: `x-oauth-basic`\n\nThe gRPC username.\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`\n\n---\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOSTNAME\n\n- Name: `WOODPECKER_HOSTNAME`\n- Default: none\n\nConfigures the agent hostname.\n\n---\n\n### AGENT_CONFIG_FILE\n\n- Name: `WOODPECKER_AGENT_CONFIG_FILE`\n- Default: `/etc/woodpecker/agent.conf`\n\nConfigures the path of the agent config file.\n\n---\n\n### MAX_WORKFLOWS\n\n- Name: `WOODPECKER_MAX_WORKFLOWS`\n- Default: `1`\n\nConfigures the number of parallel workflows.\n\n---\n\n### AGENT_SINGLE_WORKFLOW\n\n- Name: `WOODPECKER_AGENT_SINGLE_WORKFLOW`\n- Default: `false`\n\nConfigures the agent to exit (shutdown) after executing one workflow. When configured,\n`WOODPECKER_MAX_WORKFLOWS` is forced to 1.\n\nThis one-shot mode is useful in ephemeral environments that are provisioned on demand\nby external automation — for example, when an autoscaler spins up a dedicated machine. In these setups, the agent starts, executes exactly one workflow, and exits, allowing the environment to be cleanly torn down afterward.\n\n---\n\n### AGENT_LABELS\n\n- Name: `WOODPECKER_AGENT_LABELS`\n- Default: none\n\nConfigures custom labels for the agent, to let workflows filter by it.\nUse a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard.\nIf you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched.\nBy default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed.\nTo learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels).\n\n---\n\n### HEALTHCHECK\n\n- Name: `WOODPECKER_HEALTHCHECK`\n- Default: `true`\n\nEnable healthcheck endpoint.\n\n---\n\n### HEALTHCHECK_ADDR\n\n- Name: `WOODPECKER_HEALTHCHECK_ADDR`\n- Default: `:3000`\n\nConfigures healthcheck endpoint address.\n\n---\n\n### KEEPALIVE_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_TIME`\n- Default: none\n\nAfter a duration of this time of no activity, the agent pings the server to check if the transport is still alive.\n\n---\n\n### KEEPALIVE_TIMEOUT\n\n- Name: `WOODPECKER_KEEPALIVE_TIMEOUT`\n- Default: `20s`\n\nAfter pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity.\n\n---\n\n### GRPC_SECURE\n\n- Name: `WOODPECKER_GRPC_SECURE`\n- Default: `false`\n\nConfigures if the connection to `WOODPECKER_SERVER` should be made using a secure transport.\n\n---\n\n### GRPC_VERIFY\n\n- Name: `WOODPECKER_GRPC_VERIFY`\n- Default: `true`\n\nConfigures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`.\n\n---\n\n## RETRY_TIMEOUT\n\n- Name: `WOODPECKER_RETRY_TIMEOUT`\n- Default: `2m`\n\nSet how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up.\n\n:::warning\nIf set to 0 we retry forever.\n:::\n\n---\n\n### BACKEND\n\n- Name: `WOODPECKER_BACKEND`\n- Default: `auto-detect`\n\nConfigures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`.\n\n### BACKEND_DOCKER\\_\\*\n\nSee [Docker backend configuration](./11-backends/10-docker.md#environment-variables)\n\n---\n\n### BACKEND_K8S\\_\\*\n\nSee [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables)\n\n---\n\n### BACKEND_LOCAL\\_\\*\n\nSee [Local backend configuration](./11-backends/30-local.md#environment-variables)\n\n### Advanced Settings\n\n:::warning\nOnly change these If you know what you do.\n:::\n\n#### CONNECT_RETRY_COUNT\n\n- Name: `WOODPECKER_CONNECT_RETRY_COUNT`\n- Default: `5`\n\nConfigures number of times agent retries to connect to the server.\n\n#### CONNECT_RETRY_DELAY\n\n- Name: `WOODPECKER_CONNECT_RETRY_DELAY`\n- Default: `2s`\n\nConfigures delay between agent connection retries to the server.\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/40-autoscaler.md",
    "content": "# Autoscaler\n\nIf your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler).\n\nPlease note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap).\n\n## Setup\n\n### docker compose\n\nIf you are using docker compose you can add the following to your `docker-compose.yaml` file:\n\n```yaml\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:next\n    [...]\n\n  woodpecker-autoscaler:\n    image: woodpeckerci/autoscaler:next\n    restart: always\n    depends_on:\n      - woodpecker-server\n    environment:\n      - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url\n      - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user\n      - WOODPECKER_MIN_AGENTS=0\n      - WOODPECKER_MAX_AGENTS=3\n      - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time\n      - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include \"https://\" in the value.\n      - WOODPECKER_GRPC_SECURE=true\n      - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents\n      - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below\n      - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud\n```\n"
  },
  {
    "path": "docs/docs/30-administration/10-configuration/_category_.yaml",
    "content": "label: 'Configuration'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/docs/30-administration/_category_.yaml",
    "content": "label: 'Administration'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/docs/92-development/01-getting-started.md",
    "content": "# Getting started\n\nYou can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea).\n\n## Gitpod\n\nIf you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing:\n\n- An IDE in the browser or bridged to your local VS-Code or Jetbrains\n- A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge\n- A pre-configured Woodpecker server\n- A single pre-configured Woodpecker agent node\n- Our docs preview server\n\nStart Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`.\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker)\n\n## Preparation for local development\n\n### Install Go\n\nInstall Golang as described by [this guide](https://go.dev/doc/install).\n\n### Install make\n\n> GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (<https://www.gnu.org/software/make/>).\n\nInstall make on:\n\n- Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/)\n- [Windows](https://stackoverflow.com/a/32127632/8461267)\n- Mac OS: `brew install make`\n\n### Install Node.js & `pnpm`\n\nInstall [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation.\n\nFor dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.\n[This guide](https://pnpm.io/installation) describes the installation of `pnpm`.\n\n### Install `pre-commit` (optional)\n\nWoodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code.\nTo apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage).\n\n### Create a `.env` file with your development configuration\n\nSimilar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it.\n\nA common config for debugging would look like this:\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ADMIN=your-username\n\nWOODPECKER_HOST=http://localhost:8000\n\n# github (sample for a forge config - see /docs/administration/forge/overview for other forges)\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=<redacted>\nWOODPECKER_GITHUB_SECRET=<redacted>\n\n# agent\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system\nWOODPECKER_MAX_WORKFLOWS=1\n\n# enable if you want to develop the UI\n# WOODPECKER_DEV_WWW_PROXY=http://localhost:8010\n\n# if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server\nWOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com\n\n# disable health-checks while debugging (normally not needed while developing)\nWOODPECKER_HEALTHCHECK=false\n\n# WOODPECKER_LOG_LEVEL=debug\n# WOODPECKER_LOG_LEVEL=trace\n```\n\n### Setup OAuth\n\nCreate an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md).\n\n## Developing with VS Code\n\nYou can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it.\n\nTo launch all needed services for local development, you can use \"Woodpecker CI\" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it.\n\nAs a starting guide for programming Go with VS Code, you can use this video guide:\n[![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80)\n\n### Debugging Woodpecker\n\nThe Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points.\n\n![Woodpecker debugging with VS Code](./vscode-debug.png)\n\n## Testing & linting code\n\nTo test or lint parts of Woodpecker, you can run one of the following commands:\n\n```bash\n# test server code\nmake test-server\n\n# test agent code\nmake test-agent\n\n# test cli code\nmake test-cli\n\n# test datastore / database related code like migrations of the server\nmake test-server-datastore\n\n# lint go code\nmake lint\n\n# lint UI code\nmake lint-frontend\n\n# test UI code\nmake test-frontend\n```\n\nIf you want to test a specific Go file, you can also use:\n\n```bash\ngo test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/<path-to-the-package-or-file-to-test>\n```\n\nOr you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands:\n\n![Run test via VS-Code](./vscode-run-test.png)\n\n## Run applications from terminal\n\nIf you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor.\n\n```bash title=\"start server\"\ngo run ./cmd/server\n```\n\n```bash title=\"start agent\"\ngo run ./cmd/agent\n```\n\n```bash title=\"execute cli command\"\ngo run ./cmd/cli [command]\n```\n"
  },
  {
    "path": "docs/docs/92-development/02-core-ideas.md",
    "content": "# Core ideas\n\n- A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂).\n- If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle).\n- What is used most often should be default.\n- Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md).\n\n## Addons and extensions\n\nIf you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an\n[addon](../30-administration/10-configuration/100-addons.md), [extension](../20-usage/72-extensions/40-configuration-extension.md) or an\n[external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points:\n\n- Is your change very specific to your setup and unlikely to be used by anyone else?\n- Does your change violate the [guidelines](#guidelines)?\n\nBoth should be false when you open a pull request to get your change into the core repository.\n\n### Guidelines\n\n#### Forges\n\nA new forge must support these features:\n\n- OAuth2\n- Webhooks\n"
  },
  {
    "path": "docs/docs/92-development/03-ui.md",
    "content": "# UI Development\n\nTo develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api.\n\n## Setup\n\nThe UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed).\n\nTesting UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files.\n\n![UI Proxy architecture](./ui-proxy.svg)\n\nStart the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.\nAfter starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000).\n\n### Usage with remote server\n\nIf you would like to test your UI changes on a \"real-world\" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables:\n\n- `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org`\n- `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser\n\nThen, open the UI at `http://localhost:8010`.\n\n## Tools and frameworks\n\nThe following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing.\n\n- [Vue 3](https://v3.vuejs.org/)\n  - use `setup` and composition api\n  - place (re-usable) components in `web/src/components/`\n  - views should have a route in `web/src/router.ts` and are located in `web/src/views/`\n- [Tailwind CSS](https://tailwindcss.com/)\n  - use Tailwind classes where possible\n  - if needed extend the Tailwind config to use new classes\n  - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier)\n- [Vite](https://vitejs.dev/) (similar to Webpack)\n- [Typescript](https://www.typescriptlang.org/)\n  - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:)\n- [eslint](https://eslint.org/)\n- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file\n  - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)\n\n## Messages and Translations\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.\nYou must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet)\n\nFor more information about translations see [Translations](./08-translations.md).\n"
  },
  {
    "path": "docs/docs/92-development/04-docs.md",
    "content": "# Documentation\n\nThe documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/).\n\nIf you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands:\n\n```bash\ncd docs/\n\npnpm install\n\n# build plugins used by the docs\npnpm build:woodpecker-plugins\n\n# start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually\npnpm start\n\n# or build the docs to deploy it to some static page hosting\npnpm build\n```\n"
  },
  {
    "path": "docs/docs/92-development/05-architecture.md",
    "content": "# Architecture\n\n## Module Interactions\n\n![Woodpecker architecture](./woodpecker-architecture.svg)\n\n<!--\n  To update the graph, first look at a simple svg of all module imports:\n  `go run github.com/loov/goda@latest graph 'go.woodpecker-ci.org/woodpecker/v3/...' | dot -Tsvg -o graph.svg`\n\n  generate a new svg of the graph using:\n  `dot -Tsvg woodpecker-architecture.dot -o woodpecker-architecture.svg`\n-->\n\n## System architecture\n\n### main package hierarchy\n\n| package            | meaning                                                        | imports                               |\n| ------------------ | -------------------------------------------------------------- | ------------------------------------- |\n| `cmd/**`           | parse command-line args & environment to stat server/cli/agent | all other                             |\n| `agent/**`         | code only agent (remote worker) will need                      | `pipeline`, `rpc`, `shared`           |\n| `cli/**`           | code only cli tool does need                                   | `pipeline`, `shared`, `woodpecker-go` |\n| `server/**`        | code only server will need                                     | `pipeline`, `rpc`, `shared`           |\n| `pipeline/**`      | core ci/cd engine from parsing to execution                    | `shared`                              |\n| `rpc/**`           | RPC interface for agent-server communication                   | `pipeline`                            |\n| `shared/**`        | code shared for all three main tools (go help utils)           | only std and external libs            |\n| `woodpecker-go/**` | go client for server rest api                                  | std                                   |\n\n### Server\n\n| package              | meaning                                                                        | imports                                                                                                                                                                                      |\n| -------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `server/api/**`      | handle web requests from `server/router`                                       | `pipeline`, `rpc`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) |\n| `server/badges/**`   | generate svg badges for pipelines                                              | `../model`                                                                                                                                                                                   |\n| `server/ccmenu/**`   | generate xml ccmenu for pipelines                                              | `../model`                                                                                                                                                                                   |\n| `server/rpc/**`      | gRPC server agents can connect to                                              | `rpc`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store`                                                                                              |\n| `server/logging/**`  | logging lib for gPRC server to stream logs while running                       | std                                                                                                                                                                                          |\n| `server/model/**`    | structs for store (db) and api (json)                                          | std                                                                                                                                                                                          |\n| `server/pipeline/**` | orchestrate pipelines (TODO: parts of it should move into /pipeline)           | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins`                                                                                                        |\n| `server/pubsub/**`   | pubsub lib for server to push changes to the WebUI                             | std                                                                                                                                                                                          |\n| `server/queue/**`    | queue lib for server where agents pull new pipelines from via gRPC             | `server/model`                                                                                                                                                                               |\n| `server/forge/**`    | forge lib for server to connect and handle forge specific stuff                | `shared`, `server/model`                                                                                                                                                                     |\n| `server/router/**`   | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web`                                                                                                                             |\n| `server/store/**`    | handle database                                                                | `server/model`                                                                                                                                                                               |\n| `server/web/**`      | server SPA                                                                     |                                                                                                                                                                                              |\n\n- `../` = `server/`\n\n### Agent\n\n| package        | meaning                                              | imports                                                |\n| -------------- | ---------------------------------------------------- | ------------------------------------------------------ |\n| `agent/**`     | agent implementation that runs workflows             | `pipeline`, `rpc`, `shared`                            |\n| `agent/rpc/**` | gRPC client for agent-server communication           | `rpc`, `pipeline/backend/types`, std and external libs |\n| `cmd/agent/**` | CLI interface for starting and configuring the agent | `agent`, std and external libs                         |\n\nThe agent is a remote worker that connects to the server via gRPC to receive pipeline execution instructions and report back execution state and logs.\nThe agent polls the server's queue for new work, executes pipeline steps using the pipeline engine, and streams results back to the server.\n\nTODO: Review cmd/agent/core to determine if any logic should be moved into the agent package for better separation of concerns.\n\n### CLI\n\n| package                  | meaning                                                                 | imports                                                                          |\n| ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |\n| `cli/admin/**`           | admin commands for server management (users, secrets, registries, etc.) | `../common`, `../internal`, `woodpecker-go`                                      |\n| `cli/common/**`          | shared utilities and helpers used across all CLI subcommands            | `../internal/config`, `../update`, `shared`                                      |\n| `cli/context/**`         | manage multiple server contexts (connections to different servers)      | `../common`, `../internal/config`, `../output`                                   |\n| `cli/exec/**`            | execute pipelines locally without server orchestration                  | `pipeline`, `../common`, `../lint`, `shared`                                     |\n| `cli/info/**`            | display information about the current user                              | `../common`, `../internal`                                                       |\n| `cli/internal/**`        | internal utilities for HTTP client, auth, and server communication      | `../internal/config`, `woodpecker-go`, `shared`                                  |\n| `cli/internal/config/**` | configuration file management (load, store, credentials)                | std and external libs                                                            |\n| `cli/lint/**`            | validate pipeline configuration files                                   | `pipeline/frontend/yaml`, `pipeline/frontend/yaml/linter`, `../common`, `shared` |\n| `cli/org/**`             | manage organization-level resources (secrets, registries)               | `../common`, `../internal`, `woodpecker-go`                                      |\n| `cli/output/**`          | formatting utilities for CLI output (tables, etc.)                      | std and external libs                                                            |\n| `cli/pipeline/**`        | manage pipeline operations (start, stop, approve, logs, etc.)           | `../common`, `../internal`, `../output`, `woodpecker-go`, `shared`               |\n| `cli/repo/**`            | manage repository-level resources (repos, crons, secrets, registries)   | `../common`, `../internal`, `../output`, `woodpecker-go`                         |\n| `cli/setup/**`           | interactive first-time setup wizard for CLI configuration               | `../internal/config`                                                             |\n| `cli/update/**`          | self-updater for the CLI binary                                         | std and external libs                                                            |\n| `cmd/cli/**`             | CLI entry point and command structure                                   | `cli/**`                                                                         |\n\nThe CLI provides a command-line interface for interacting with Woodpecker servers.\nEach subcommand is organized into its own package under `cli/<subcommand>/`.\n\nThe `cli/exec` subcommand allows local pipeline execution for testing and development by combining pipeline parsing and execution without requiring a running server or agent.\n\n- `../` = `cli/`\n\n### Engine\n\nThe engine is the shared kernel that validates, parses frontend facing config files, enrich it by the provided forge metadata and produce config for the backends to execute on based on that. It also contains the default backend implementations.\n\n#### Runtime\n\nThe runtime is the package controlling how a workflow is executed, and can be found at `pipeline/runtime`.\n\n<img src=\"/svg/woodpecker-workflow-run-flowchart.svg\" alt=\"Pipeline/runtime flow diagram\" style=\"max-width: 600px; width: 100%;\" />\n"
  },
  {
    "path": "docs/docs/92-development/06-conventions.md",
    "content": "# Conventions\n\n## Database naming\n\nDatabase tables are named plural, columns don't have any prefix.\n\nExample: Model name `Agent` with table name `agents` and columns `id`, `name`.\n"
  },
  {
    "path": "docs/docs/92-development/07-guides.md",
    "content": "# Guides\n\n## ORM\n\nWoodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection.\n\n## Add a new migration\n\nWoodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`.\n\n:::info\nAdding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created.\n:::\n\n:::warning\nYou should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager.\n:::\n\nTo automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start.\n\n## Constants of official images\n\nAll official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag.\n\n## Building images locally\n\n### Server\n\n```sh\n### build web component\nmake vendor\ncd web/\npnpm install --frozen-lockfile\npnpm build\ncd ..\n\n### define the platforms to build for (e.g. linux/amd64)\n# (the | is not a typo here)\nexport PLATFORMS='linux|amd64'\nmake cross-compile-server\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push .\n```\n\n:::info\nThe `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)).\nYou can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS).\n:::\n\n### Agent\n\n```sh\n### build the agent\nmake build-agent\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push .\n```\n\n### CLI\n\n```sh\n### build the CLI\nmake build-cli\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push .\n```\n"
  },
  {
    "path": "docs/docs/92-development/08-translations.md",
    "content": "# Translations\n\nTo translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.**\n\n<a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n  <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library.\n"
  },
  {
    "path": "docs/docs/92-development/09-openapi.md",
    "content": "# Swagger, API Spec and Code Generation\n\nWoodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically\ngenerate Swagger v2 API specifications and a nice looking Web UI from the source code.\nAlso, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger)\nand then being using on the community's website documentation.\n\nIt's paramount important to keep the gin handler function's godoc documentation up-to-date,\nto always have accurate API documentation.\nWhenever you change, add or enhance an API endpoint, please update the godoc.\n\nYou don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools.\n\n## Gin-Handler API documentation guideline\n\nHere's a typical example of how annotations for Swagger documentation look like...\n\n```go title=\"server/api/user.go\"\n// @Summary  Get a user\n// @Description Returns a user with the specified login name. Requires admin rights.\n// @Router   /users/{login} [get]\n// @Produce  json\n// @Success  200 {object} User\n// @Tags   Users\n// @Param   Authorization header string true \"Insert your personal access token\" default(Bearer <personal access token>)\n// @Param   login   path string true \"the user's login name\"\n// @Param   foobar  query   string false \"optional foobar parameter\"\n// @Param   page    query int  false \"for response pagination, page offset number\" default(1)\n// @Param   perPage query int  false \"for response pagination, max items per page\" default(50)\n```\n\n```go title=\"server/model/user.go\"\ntype User struct {\n  ID int64 `json:\"id\" xorm:\"pk autoincr 'user_id'\"`\n// ...\n} // @name User\n```\n\nThese guidelines aim to have consistent wording in the OpenAPI doc:\n\n- first word after `@Summary` and `@Summary` are always uppercase\n- `@Summary` has no `.` (dot) at the end of the line\n- model structs shall use custom short names, to ease life for API consumers, using `@name`\n- `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI\n- when pagination is used, `@Param page` and `@Param perPage` must be added manually\n- `@Param Authorization` is almost always present, there are just a few un-protected endpoints\n\nThere are many examples in the `server/api` package, which you can use a blueprint.\nMore enhanced information you can find here <https://github.com/swaggo/swag/blob/master/README.md#declarative-comments-format>\n\n### Manual code generation\n\n```bash title=\"generate the server's Go code containing the OpenAPI\"\nmake generate-openapi\n```\n\n```bash title=\"update the Markdown in the ./docs folder\"\nmake generate-docs\n```\n"
  },
  {
    "path": "docs/docs/92-development/09-testing.md",
    "content": "# Testing\n\n## Backend\n\n### Unit Tests\n\n[We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test)\nwith [`\"github.com/stretchr/testify/assert\"`](https://pkg.go.dev/github.com/stretchr/testify/assert) to simplify testing.\n\n### Integration Tests\n\n### Dummy backend\n\nThere is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave.\nTo enable it you need to build the agent or cli with the `test` build tag.\n\nAn example pipeline config would be:\n\n```yaml\nwhen:\n  event: manual\n\nsteps:\n  - name: echo\n    image: dummy\n    commands: echo \"hello woodpecker\"\n    environment:\n      SLEEP: '1s'\n\nservices:\n  echo:\n    image: dummy\n    commands: echo \"i am a service\"\n```\n\nThis could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`:\n\n<!-- cspell:disable -->\n\n```none\n9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: service\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: commands\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n```\n\n<!-- cspell:enable -->\n\nThere are also environment variables to alter step behavior:\n\n- `SLEEP: 10` will let the step wait 10 seconds\n- `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands`\n- `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled)\n- `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs\n- `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0\n- `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains\n\nYou can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`.\n"
  },
  {
    "path": "docs/docs/92-development/10-packaging.md",
    "content": "# Packaging\n\nIf you repackage it, we encourage to build from source, which requires internet connection.\n\nFor offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI\non the [release page](https://github.com/woodpecker-ci/woodpecker/releases).\n\n## Distribute web UI in own directory\n\nIf you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary.\n\nAdd `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path.\n\nExample: <!-- cspell:ignore webui -->\n\n```sh\ngo build -tags 'external_web' -ldflags '-s -w -extldflags \"-static\" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server\n```\n"
  },
  {
    "path": "docs/docs/92-development/100-addons.md",
    "content": "# Addons\n\nThe Woodpecker server supports addons for forges and the log store.\n\n:::warning\nAddons are still experimental. Their implementation can change and break at any time.\n:::\n\n## Bug reports\n\nIf you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name.\n\n## Creating addons\n\nAddons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin).\n\n### Writing your code\n\nThis example will use the Go language.\n\nDirectly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there.\n\nIn the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument.\nThis will take care of connecting the addon forge to the server.\n\n:::note\nIt is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process.\n:::\n\n### Example structure\n\nThis is an example for a forge addon.\n\n```go\npackage main\n\nimport (\n  \"context\"\n  \"net/http\"\n\n  \"go.woodpecker-ci.org/woodpecker/v3/server/forge/addon\"\n  forgeTypes \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n  \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc main() {\n  addon.Serve(config{})\n}\n\ntype config struct {\n}\n\n// `config` must implement `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`. You must directly use Woodpecker's packages - see imports above.\n```\n\n### Addon types\n\n| Type      | Addon package                                                 | Service interface                                                 |\n| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- |\n| Forge     | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon`       | `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`         |\n| Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `\"go.woodpecker-ci.org/woodpecker/v3/server/service/log\".Service` |\n"
  },
  {
    "path": "docs/docs/92-development/40-deprecations.md",
    "content": "# Deprecation Policy\n\n## Pipeline Configuration Changes\n\nPipeline configuration (YAML syntax) changes follow a strict deprecation process to ensure users have sufficient time to migrate.\n\n### Process Timeline\n\n1. **Minor Version N.x - Add Deprecation Warning**\n   - Linter shows a warning (not an error)\n   - Old syntax remains functional\n   - Documentation is updated to reflect the new syntax\n   - Warning message includes guidance on required changes\n\n2. **Major Version (N+1).0 - Warning Becomes Error**\n   - Linter issues an error (pipeline fails)\n   - Old syntax is no longer supported\n   - Breaking change is documented in the migration guide\n   - Users **must** update their configurations\n\n3. **Minor Version (N+1).x - Code Cleanup**\n   - Deprecated code paths are removed\n   - Implementation is simplified/refactored\n   - Parser no longer recognizes the old syntax\n\n### Example\n\nOld syntax: `secrets: [token]`\nNew syntax: `environment: { TOKEN: { from_secret: token } }`\n\n- **v2.5.0:** Deprecation warning added in linter; both syntaxes work\n- **v2.6-2.9:** Warning persists; both syntaxes remain functional\n- **v3.0.0:** Linter error; old syntax fails (breaking change)\n- **v3.1.0:** Deprecated code paths removed; parser simplified\n\n### Implementation Checklist\n\nWhen deprecating pipeline configuration syntax, ensure the following:\n\n- [ ] Add linter warning in `/pipeline/frontend/yaml/linter/`\n- [ ] Update JSON schema in `/pipeline/frontend/yaml/linter/schema`\n- [ ] Add test cases for deprecated syntax\n- [ ] Update documentation to reflect the new syntax\n"
  },
  {
    "path": "docs/docs/92-development/_category_.yaml",
    "content": "label: 'Development'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/docs/92-development/woodpecker-architecture.dot",
    "content": "digraph WoodpeckerArchitecture {\n  graph [\n    rankdir=TB,\n    splines=ortho,\n    nodesep=0.5,\n    ranksep=0.8,\n    fontname=\"Helvetica\"\n  ]\n\n  node [\n    shape=box,\n    style=\"rounded,filled\",\n    fillcolor=\"#2b2b2b\",\n    fontcolor=\"white\",\n    fontname=\"Helvetica\"\n  ]\n\n  edge [\n    color=\"#bdbdbd\",\n    arrowsize=0.7\n  ]\n\n  /* ===================== UI ===================== */\n  subgraph cluster_ui {\n    label=\"UI\"\n    fillcolor=\"#c7efe9\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    ui_web [label=\"web/\"]\n  }\n\n  /* ===================== SDK ===================== */\n  subgraph cluster_sdk {\n    label=\"SDK (woodpecker-go)\"\n    fillcolor=\"#e8f5e9\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    sdk [label=\"woodpecker-go\"]\n  }\n\n  /* ===================== CLI ===================== */\n  subgraph cluster_cli {\n    label=\"woodpecker-cli\"\n    fillcolor=\"#bfe9e0\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    cli_cmd  [label=\"cmd/cli/\"]\n    cli_core [label=\"cli/\"]\n  }\n\n  /* ===================== Agent ===================== */\n  subgraph cluster_agent {\n    label=\"woodpecker-agent\"\n    fillcolor=\"#ffe0c7\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    agent_cmd  [label=\"cmd/agent/\"]\n    agent_core [label=\"agent/\"]\n  }\n\n  /* ===================== Pipelines ===================== */\n  subgraph cluster_pipelines {\n    label=\"Pipelines\"\n    fillcolor=\"#ffe8d6\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    pipe_core      [label=\"pipeline/\"]\n    pipe_frontend  [label=\"pipeline/frontend/\\n(yaml)\"]\n    pipe_backend   [label=\"pipeline/backend/\\n(exec engines)\"]\n  }\n\n  /* ===================== Server ===================== */\n  subgraph cluster_server {\n    label=\"woodpecker-server\"\n    fillcolor=\"#dbe9ff\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    srv_cmd     [label=\"cmd/server/\"]\n    srv_router  [label=\"server/router/\"]\n    srv_api     [label=\"server/api/\"]\n    srv_grpc    [label=\"server/rpc/\"]\n    srv_queue   [label=\"server/queue/\"]\n    srv_pubsub  [label=\"server/pubsub/\"]\n    srv_store   [label=\"server/store/\"]\n    srv_model   [label=\"server/model/\"]\n    srv_forge   [label=\"server/forge/\"]\n  }\n\n  /* ===================== Shared Libs ===================== */\n  subgraph cluster_shared {\n    label=\"Shared Libs\"\n    fillcolor=\"#eeeeee\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    shared_util   [label=\"shared/util/\"]\n    shared_token  [label=\"shared/token/\"]\n    shared_http   [label=\"shared/httputil/\"]\n    shared_log    [label=\"shared/logger/\"]\n  }\n\n  /* ===================== External ===================== */\n  subgraph cluster_external {\n    label=\"External Systems\"\n    style=\"rounded,dashed\"\n    fontcolor=\"white\"\n\n    ext_scm [label=\"SCM Providers\", shape=cloud]\n    ext_db  [label=\"Database\", shape=cylinder]\n  }\n\n  /* ===================== Runtime Interactions ===================== */\n\n  /* UI */\n  ui_web -> srv_router [xlabel=\"HTTP\"]\n  ui_web -> srv_api    [xlabel=\"REST API\"]\n\n  /* CLI */\n  cli_cmd  -> cli_core\n  cli_core -> sdk\n  sdk      -> srv_api [xlabel=\"REST API\"]\n\n  /* Agent */\n  agent_cmd  -> agent_core\n  agent_core -> srv_grpc  [xlabel=\"gRPC connect\"]\n  agent_core -> srv_queue [xlabel=\"poll work\"]\n  agent_core -> pipe_backend [xlabel=\"execute steps\"]\n\n  /* Pipelines */\n  pipe_frontend -> pipe_core\n  pipe_core     -> pipe_backend\n\n  /* Server internal flow */\n  srv_cmd    -> srv_router\n  srv_router -> srv_api\n  srv_api    -> srv_store\n  srv_api    -> srv_pubsub\n  srv_api    -> srv_queue\n  srv_grpc   -> srv_queue\n  srv_store  -> srv_model\n\n  /* External integrations */\n  srv_forge -> ext_scm [xlabel=\"SCM API\"]\n  srv_store -> ext_db  [xlabel=\"SQL\"]\n\n  /* Shared libs usage (consumer -> library) */\n  srv_router -> shared_token\n  srv_api    -> shared_http\n  srv_grpc   -> shared_log\n  pipe_core  -> shared_util\n}\n"
  },
  {
    "path": "docs/docusaurus.config.ts",
    "content": "import * as path from 'path';\nimport type { VersionBanner, VersionOptions } from '@docusaurus/plugin-content-docs';\nimport type * as Preset from '@docusaurus/preset-classic';\nimport type { Config } from '@docusaurus/types';\nimport { themes } from 'prism-react-renderer';\n\nimport versions from './versions.json';\n\nconst docsVersions: { [version: string]: VersionOptions } = {\n  current: {\n    label: 'Next 🚧',\n    banner: 'unreleased' as VersionBanner,\n  },\n};\n\nconst includeVersions = ['current', versions[0]];\n\nversions.forEach((v, index) => {\n  const version = {\n    label: `${v}.x${index === 0 ? '' : ' 💀'}`,\n  };\n  if (index !== 0 && process.env.NODE_ENV !== 'development') {\n    version['banner'] = 'unmaintained';\n    includeVersions.push(v);\n  }\n  docsVersions[v] = version;\n});\n\nconst config = {\n  title: 'Woodpecker CI',\n  tagline: 'Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.',\n  url: 'https://woodpecker-ci.org',\n  baseUrl: '/',\n  onBrokenLinks: 'throw',\n  onBrokenAnchors: 'throw',\n  onDuplicateRoutes: 'throw',\n  organizationName: 'woodpecker-ci',\n  projectName: 'woodpecker-ci.github.io',\n  trailingSlash: false,\n  headTags: [\n    {\n      tagName: 'link',\n      attributes: {\n        href: 'https://floss.social/@WoodpeckerCI',\n        rel: 'me',\n      },\n    },\n  ],\n  themeConfig: {\n    navbar: {\n      title: 'Woodpecker',\n      logo: {\n        alt: 'Woodpecker Logo',\n        src: 'img/logo.svg',\n      },\n      items: [\n        {\n          type: 'doc',\n          docId: 'intro/index',\n          activeBaseRegex: 'docs/(?!migrations|awesome)',\n          position: 'left',\n          label: 'Docs',\n        },\n        {\n          to: '/plugins',\n          position: 'left',\n          label: 'Plugins',\n        },\n        { to: 'blog', label: 'Blog', position: 'left' },\n        {\n          label: 'More',\n          position: 'left',\n          items: [\n            {\n              to: '/migrations', // Always point to newest migration guide\n              activeBaseRegex: 'migrations',\n              label: 'Migrations',\n            },\n            {\n              to: '/awesome', // Always point to newest awesome list\n              activeBaseRegex: 'awesome',\n              label: 'Awesome',\n            },\n            {\n              to: '/api',\n              label: 'API',\n            },\n            {\n              to: '/about',\n              label: 'About',\n            },\n          ],\n        },\n        {\n          type: 'docsVersionDropdown',\n          position: 'right',\n          dropdownItemsAfter: [\n            {\n              to: '/versions',\n              label: 'All versions',\n            },\n          ],\n        },\n        {\n          label: 'Sponsor Us',\n          position: 'right',\n          className: 'header-sponsor-link',\n          href: 'https://opencollective.com/woodpecker-ci',\n        },\n        {\n          href: 'https://github.com/woodpecker-ci/woodpecker',\n          position: 'right',\n          className: 'header-github-link',\n          'aria-label': 'GitHub repository',\n        },\n      ],\n    },\n    footer: {\n      style: 'dark',\n      links: [\n        {\n          title: 'Docs',\n          items: [\n            {\n              label: 'Welcome to Woodpecker',\n              to: '/docs/intro',\n            },\n            {\n              label: 'Usage',\n              to: '/docs/usage/intro',\n            },\n            {\n              label: 'Administration',\n              to: '/docs/administration/general',\n            },\n            {\n              to: '/migrations', // Always point to newest migration guide\n              activeBaseRegex: 'migrations',\n              label: 'Migrations',\n            },\n            {\n              to: '/awesome', // Always point to newest awesome list\n              activeBaseRegex: 'awesome',\n              label: 'Awesome',\n            },\n            {\n              to: '/api',\n              label: 'API',\n            },\n            {\n              to: '/about',\n              label: 'About',\n            },\n          ],\n        },\n        {\n          title: 'Community',\n          items: [\n            {\n              label: 'Matrix',\n              href: 'https://matrix.to/#/#woodpecker:matrix.org',\n            },\n            {\n              label: 'Mastodon',\n              href: 'https://floss.social/@WoodpeckerCI',\n            },\n            {\n              label: 'Bluesky',\n              href: 'https://bsky.app/profile/woodpecker-ci.org',\n            },\n          ],\n        },\n        {\n          title: 'More',\n          items: [\n            {\n              label: 'Translate',\n              href: 'https://translate.woodpecker-ci.org/engage/woodpecker-ci/',\n            },\n            {\n              label: 'GitHub',\n              href: 'https://github.com/woodpecker-ci/woodpecker',\n            },\n            {\n              href: 'https://ci.woodpecker-ci.org/repos/3780',\n              label: 'CI',\n            },\n            {\n              href: 'https://opencollective.com/woodpecker-ci',\n              label: 'Open Collective',\n            },\n          ],\n        },\n      ],\n      copyright: `Copyright © ${new Date().getFullYear()} Woodpecker Authors. Built with Docusaurus.`,\n    },\n    prism: {\n      theme: themes.github,\n      darkTheme: themes.dracula,\n      additionalLanguages: [\n        'diff',\n        'json',\n        'docker',\n        'javascript',\n        'css',\n        'bash',\n        'nginx',\n        'apacheconf',\n        'ini',\n        'nix',\n        'uri',\n        // php is currently needed for redocusaurus\n        // https://github.com/rohit-gohri/redocusaurus/issues/388\n        'php',\n      ],\n    },\n    announcementBar: {\n      id: 'github-star',\n      content: `If you like Woodpecker-CI, <a href=https://github.com/woodpecker-ci/woodpecker rel=\"noopener noreferrer\" target=\"_blank\">give us a star on GitHub</a> ! ⭐️`,\n      backgroundColor: 'var(--ifm-color-primary)',\n      textColor: 'var(--ifm-color-gray-900)',\n    },\n    tableOfContents: {\n      minHeadingLevel: 2,\n      maxHeadingLevel: 4,\n    },\n    colorMode: {\n      respectPrefersColorScheme: true,\n    },\n  } satisfies Preset.ThemeConfig,\n  plugins: [\n    () => ({\n      name: 'docusaurus-plugin-favicon',\n      injectHtmlTags() {\n        return {\n          headTags: [\n            {\n              tagName: 'link',\n              attributes: {\n                rel: 'icon',\n                href: '/img/favicon.ico',\n                sizes: 'any',\n              },\n            },\n            {\n              tagName: 'link',\n              attributes: {\n                rel: 'icon',\n                href: '/img/favicon.svg',\n                type: 'image/svg+xml',\n              },\n            },\n          ],\n        };\n      },\n    }),\n    () => ({\n      name: 'webpack-config',\n      configureWebpack() {\n        return {\n          devServer: {\n            client: {\n              webSocketURL: 'auto://0.0.0.0:0/ws',\n            },\n          },\n        } as any;\n      },\n    }),\n  ],\n  themes: [\n    path.resolve(__dirname, 'plugins', 'woodpecker-plugins', 'dist'),\n    [\n      require.resolve('@easyops-cn/docusaurus-search-local'),\n      {\n        hashed: true,\n      },\n    ],\n  ],\n  presets: [\n    [\n      '@docusaurus/preset-classic',\n      {\n        docs: {\n          sidebarPath: require.resolve('./sidebars.js'),\n          editUrl: 'https://github.com/woodpecker-ci/woodpecker/edit/main/docs/',\n          includeCurrentVersion: true,\n          lastVersion: versions[0],\n          onlyIncludeVersions: includeVersions,\n          versions: docsVersions,\n        },\n        blog: {\n          blogTitle: 'Blog',\n          blogDescription: 'A blog for release announcements, turorials...',\n          onInlineAuthors: 'ignore',\n        },\n        theme: {\n          customCss: require.resolve('./src/css/custom.css'),\n        },\n      } satisfies Preset.Options,\n    ],\n    [\n      'redocusaurus',\n      {\n        // Plugin Options for loading OpenAPI files\n        specs: [\n          {\n            spec: 'openapi.json',\n            route: '/api/',\n          },\n        ],\n        // Theme Options for modifying how redoc renders them\n        theme: {\n          // Change with your site colors\n          primaryColor: '#4caf50',\n        },\n      },\n    ],\n  ],\n  markdown: {\n    format: 'detect',\n    hooks: {\n      onBrokenMarkdownLinks: 'throw',\n      onBrokenMarkdownImages: 'throw',\n    },\n  },\n  future: {\n    faster: true,\n    v4: true,\n  },\n} satisfies Config;\n\nexport default config;\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"woodpecker\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.33.4\",\n  \"scripts\": {\n    \"start\": \"cd ../ && make generate-docs && cd docs && docusaurus start\",\n    \"build\": \"pnpm build:woodpecker-plugins && docusaurus build\",\n    \"build:woodpecker-plugins\": \"cd plugins/woodpecker-plugins && pnpm i && pnpm build\",\n    \"swizzle\": \"docusaurus swizzle\",\n    \"deploy\": \"docusaurus deploy\",\n    \"clear\": \"docusaurus clear\",\n    \"serve\": \"docusaurus serve\",\n    \"write-translations\": \"docusaurus write-translations\",\n    \"write-heading-ids\": \"docusaurus write-heading-ids\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier -c .\"\n  },\n  \"dependencies\": {\n    \"@docusaurus/core\": \"^3.9.2\",\n    \"@docusaurus/faster\": \"^3.9.2\",\n    \"@docusaurus/plugin-content-blog\": \"^3.9.2\",\n    \"@docusaurus/preset-classic\": \"^3.9.2\",\n    \"@easyops-cn/docusaurus-search-local\": \"^0.55.1\",\n    \"clsx\": \"^2.1.1\",\n    \"prism-react-renderer\": \"^2.4.1\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"redocusaurus\": \"^2.5.0\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.5%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@docusaurus/module-type-aliases\": \"^3.9.2\",\n    \"@docusaurus/tsconfig\": \"3.10.1\",\n    \"@docusaurus/types\": \"^3.9.2\",\n    \"@ianvs/prettier-plugin-sort-imports\": \"^4.7.1\",\n    \"@types/node\": \"^25.3.3\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-helmet\": \"^6.1.11\",\n    \"@types/react-router-dom\": \"^5.3.3\",\n    \"prettier\": \"^3.8.1\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"serialize-javascript\": \"^7.0.3\",\n      \"follow-redirects\": \"^1.16.0\",\n      \"uuid\": \"^14.0.0\"\n    }\n  }\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/.gitignore",
    "content": "*.log\n.DS_Store\nnode_modules\ndist\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/package.json",
    "content": "{\n  \"name\": \"@woodpecker-ci/plugin-index\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"main\": \"dist/index.js\",\n  \"typings\": \"dist/index.d.ts\",\n  \"scripts\": {\n    \"start\": \"pnpm run style && concurrently 'tsc -w' 'tsc -w -p tsconfig.jsx.json'\",\n    \"build\": \"pnpm run style && tsc && tsc -p tsconfig.jsx.json\",\n    \"style\": \"mkdir -p dist/theme/ && cp src/theme/style.css dist/theme/style.css\"\n  },\n  \"devDependencies\": {\n    \"@docusaurus/module-type-aliases\": \"^3.9.2\",\n    \"@docusaurus/theme-classic\": \"^3.9.2\",\n    \"@docusaurus/types\": \"^3.9.2\",\n    \"@tsconfig/docusaurus\": \"^2.0.7\",\n    \"@types/node\": \"^24.10.9\",\n    \"axios\": \"^1.13.4\",\n    \"concurrently\": \"^9.2.1\",\n    \"isomorphic-dompurify\": \"^3.0.0\",\n    \"marked\": \"^18.0.0\",\n    \"slugify\": \"^1.6.6\",\n    \"tslib\": \"^2.8.1\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"peerDependencies\": {\n    \"react\": \"^17.0.2 || ^18.0.0 || ^19.0.0\",\n    \"react-dom\": \"^17.0.2 || ^18.0.0 || ^19.0.0\"\n  },\n  \"dependencies\": {\n    \"fuse.js\": \"^7.1.0\",\n    \"yaml\": \"^2.8.2\"\n  }\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/plugins.json",
    "content": "{\n  \"plugins\": [\n    {\n      \"name\": \"Clone plugin\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-git/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Docker Buildx\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/docker-buildx/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Codecov\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-codecov/master/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Surge preview\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/plugin-surge-preview/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"S3 upload\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/plugin-s3/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Node PM\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/node-pm/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Prettier\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/prettier/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Extend env\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-extend-env/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Regex check\",\n      \"docs\": \"https://codeberg.org/qwerty287/woodpecker-regex-check/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Gitea Create Pull Request\",\n      \"docs\": \"https://codeberg.org/woodpecker-community/gitea-pull-request-create-plugin/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Gitea Pull comment\",\n      \"docs\": \"https://raw.githubusercontent.com/markopolo123/gitea-comment-plugin/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Gitea publisher-golang\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-kit/woodpecker-gitea-publisher-golang/main/doc/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Git Push\",\n      \"docs\": \"https://raw.githubusercontent.com/appleboy/drone-git-push/master/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"WebDAV\",\n      \"docs\": \"https://raw.githubusercontent.com/ViViDboarder/drone-webdav/master/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Aptly publish\",\n      \"docs\": \"https://gitea.zionetrix.net/bn8/aptly-publish/raw/branch/master/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Trigger\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/trigger/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Release\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/release/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Sccache\",\n      \"docs\": \"https://seed.radicle.garden/raw/rad:zzmCBDptFLrfUEHSjcFsLJ2Aghav/head/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Woodpecker Email\",\n      \"docs\": \"https://gitnet.fr/deblan/woodpecker-email/raw/branch/develop/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Woodpecker Feishu Bot\",\n      \"docs\": \"https://github.com/wenerme/wode/raw/main/apps/woodpecker-feishu-bot/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"ntfy\",\n      \"docs\": \"https://codeberg.org/l-x/woodpecker-ntfy/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Trivy\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/trivy/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"MkDocs\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/mkdocs/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"TODO-Checker\",\n      \"docs\": \"https://codeberg.org/Epsilon_02/todo-checker/raw/branch/main/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Nextcloud Upload\",\n      \"docs\": \"https://raw.githubusercontent.com/Ellpeck/WoodpeckerPlugins/main/nextcloud-upload/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Kubernetes Deployment or StatefulSet Update\",\n      \"docs\": \"https://raw.githubusercontent.com/euryecetelecom/woodpeckerci-kubernetes/master/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Dockle\",\n      \"docs\": \"https://raw.githubusercontent.com/euryecetelecom/woodpeckerci-dockle/master/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"NixOS Remote Builder\",\n      \"docs\": \"https://codeberg.org/woodpecker-community/nix-remote-builder-plugin/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Release Helper\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-ready-release-go/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Nix - Attic\",\n      \"docs\": \"https://git.vdx.hu/voidcontext/woodpecker-plugin-nix-attic/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Codeberg Pages Deploy\",\n      \"docs\": \"https://codeberg.org/sugar700/plugin-codeberg-pages-deploy/raw/branch/master/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Reviewdog golangci-lint\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/reviewdog-golangci-lint/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Reviewdog ESLint\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/reviewdog-eslint/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Ansible\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/ansible/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Kaniko\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-kaniko/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Gradle Wrapper Validation\",\n      \"docs\": \"https://codeberg.org/beaks/gradle-wrapper-validation/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Sonatype Nexus\",\n      \"docs\": \"https://raw.githubusercontent.com/rockdrilla/woodpecker-sonatype-nexus/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Mastodon Post\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/mastodon-post/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Bluesky Post\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/bluesky-post/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Discord\",\n      \"docs\": \"https://raw.githubusercontent.com/appleboy/drone-discord/master/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Forge deployments\",\n      \"docs\": \"https://raw.githubusercontent.com/woodpecker-ci/plugin-deployments/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Twine\",\n      \"docs\": \"https://gitea.elara.ws/music-kraken/plugin-twine/raw/branch/master/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Gitea Package\",\n      \"docs\": \"https://codeberg.org/woodpecker-community/gitea-package/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Is It Up Yet?\",\n      \"docs\": \"https://raw.githubusercontent.com/dvjn/woodpecker-is-it-up-yet-plugin/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Docker Tags\",\n      \"docs\": \"https://raw.githubusercontent.com/dvjn/woodpecker-docker-tags-plugin/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"SSH SCP\",\n      \"docs\": \"https://raw.githubusercontent.com/appleboy/drone-scp/refs/heads/master/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Telegram\",\n      \"docs\": \"https://raw.githubusercontent.com/appleboy/drone-telegram/refs/heads/master/DOCS.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"EditorConfig Checker\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/editorconfig-checker/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"Microsoft Teams Notify\",\n      \"docs\": \"https://raw.githubusercontent.com/GECO-IT/woodpecker-plugin-teams-notify/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Basic Git Changelog\",\n      \"docs\": \"https://raw.githubusercontent.com/GECO-IT/woodpecker-plugin-git-basic-changelog/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Hugo\",\n      \"docs\": \"https://raw.githubusercontent.com/maurerle/woodpecker-hugo/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Home Assistant Notify\",\n      \"docs\": \"https://raw.githubusercontent.com/DHandspikerWade/woodpecker-plugin-ha-notify/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Microsoft Teams Notification (Advanced)\",\n      \"docs\": \"https://raw.githubusercontent.com/mobydeck/ci-teams-notification/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Pre-commit Runner\",\n      \"docs\": \"https://codeberg.org/sp1thas/woodpecker-ci-pre-commit/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Portainer Service Update\",\n      \"docs\": \"https://codeberg.org/woodpecker-community/portainer-service-update/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Peckify\",\n      \"docs\": \"https://codeberg.org/woodpecker-community/peckify/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"ASCII JUnit Test Report\",\n      \"docs\": \"https://raw.githubusercontent.com/brainbaking/woodpecker-ascii-junit/refs/heads/main/README.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"SonarQube\",\n      \"docs\": \"https://raw.githubusercontent.com/j04n-f/woodpecker-sonar-plugin/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Github App Tokens\",\n      \"docs\": \"https://raw.githubusercontent.com/yyewolf/woodpecker-plugins/refs/heads/main/github-app-token/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Github Comment\",\n      \"docs\": \"https://raw.githubusercontent.com/yyewolf/woodpecker-plugins/refs/heads/main/github-comment/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"BunnyCDN Cache Purge\",\n      \"docs\": \"https://codeberg.org/bentasker/woodpecker-ci-bunnycdn-cache-flush/raw/branch/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Buildah\",\n      \"docs\": \"https://raw.githubusercontent.com/404systems/plugin-buildah/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Opengrep\",\n      \"docs\": \"https://raw.githubusercontent.com/KalvadTech/woodpecker-opengrep/refs/heads/main/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"AgentScan\",\n      \"docs\": \"https://codeberg.org/woodpecker-plugins/agentscan/raw/branch/main/docs.md\",\n      \"verified\": true\n    },\n    {\n      \"name\": \"S3 Cache\",\n      \"docs\": \"https://codeberg.org/landre/woodpecker-plugins/raw/branch/main/cache/docs.md\",\n      \"verified\": false\n    },\n    {\n      \"name\": \"Laravel Forge\",\n      \"docs\": \"https://raw.githubusercontent.com/njaaazi/laravel-forge-woodpecker/main/docs.md\",\n      \"verified\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/index.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport { LoadContext, Plugin, PluginContentLoadedActions } from '@docusaurus/types';\nimport axios, { AxiosError } from 'axios';\nimport slugify from 'slugify';\n\nimport * as markdown from './markdown';\nimport { Content, WoodpeckerPlugin, WoodpeckerPluginHeader, WoodpeckerPluginIndexEntry } from './types';\n\nasync function loadContent(): Promise<Content> {\n  const file = path.join(__dirname, '..', 'plugins.json');\n\n  const pluginsIndex = JSON.parse(fs.readFileSync(file).toString()) as { plugins: WoodpeckerPluginIndexEntry[] };\n\n  const plugins = (\n    await Promise.all(\n      pluginsIndex.plugins.map(async (i): Promise<WoodpeckerPlugin | undefined> => {\n        let docsContent: string;\n        try {\n          const response = await axios(i.docs);\n          docsContent = response.data;\n        } catch (e) {\n          const axiosError = e as AxiosError;\n          console.error(\n            \"Can't fetch docs file\",\n            i.docs,\n            axiosError.message,\n            axiosError.response?.status,\n            axiosError.response?.statusText,\n          );\n          return undefined;\n        }\n\n        let docsHeader: WoodpeckerPluginHeader;\n        try {\n          docsHeader = markdown.getHeader<WoodpeckerPluginHeader>(docsContent);\n        } catch (e) {\n          console.error(\"Can't get header from docs file\", i.docs, (e as Error).message);\n          return undefined;\n        }\n\n        const docsBody = await markdown.getContent(docsContent);\n\n        if (!docsHeader.name) {\n          return undefined;\n        }\n\n        let pluginIconDataUrl: string | undefined;\n        if (docsHeader.icon) {\n          try {\n            const response = await axios(docsHeader.icon, {\n              responseType: 'arraybuffer',\n            });\n\n            const imgType = response.headers['content-type'];\n            if (imgType) {\n              pluginIconDataUrl = `data:${imgType.toString()};base64,${Buffer.from(response.data, 'binary').toString(\n                'base64',\n              )}`;\n            }\n          } catch (e) {\n            console.error(\"Can't fetch plugin icon\", docsHeader.icon, (e as AxiosError).message);\n          }\n        }\n\n        return {\n          name: docsHeader.name,\n          slug: slugify(docsHeader.name, { lower: true, strict: true }),\n          url: docsHeader.url,\n          icon: docsHeader.icon,\n          description: docsHeader.description,\n          docs: docsBody,\n          tags: docsHeader.tags || [],\n          author: docsHeader.author,\n          containerImage: docsHeader.containerImage,\n          containerImageUrl: docsHeader.containerImageUrl,\n          verified: i.verified || false,\n          iconDataUrl: pluginIconDataUrl,\n        } satisfies WoodpeckerPlugin;\n      }),\n    )\n  ).filter<WoodpeckerPlugin>((plugin): plugin is WoodpeckerPlugin => plugin !== undefined);\n\n  return {\n    plugins,\n  };\n}\n\nasync function contentLoaded({\n  content: { plugins },\n  actions,\n}: {\n  content: Content;\n  actions: PluginContentLoadedActions;\n}): Promise<void> {\n  const { createData, addRoute } = actions;\n\n  const pluginsJsonPath = await createData('plugins.json', JSON.stringify(plugins));\n\n  await Promise.all(\n    plugins.map(async (plugin, i) => {\n      const pluginJsonPath = await createData(`plugin-${i}.json`, JSON.stringify(plugin));\n\n      addRoute({\n        path: `/plugins/${plugin.slug}`,\n        component: '@theme/WoodpeckerPlugin',\n        modules: {\n          plugin: pluginJsonPath,\n        },\n        exact: true,\n      });\n    }),\n  );\n\n  addRoute({\n    path: '/plugins',\n    component: '@theme/WoodpeckerPluginList',\n    modules: {\n      plugins: pluginsJsonPath,\n    },\n    exact: true,\n  });\n}\n\nexport default function pluginWoodpeckerPluginsIndex(context: LoadContext, options: any): Plugin<Content> {\n  return {\n    name: 'woodpecker-plugins',\n    loadContent,\n    contentLoaded,\n    getThemePath() {\n      return path.join(__dirname, '..', 'dist', 'theme');\n    },\n    getTypeScriptThemePath() {\n      return path.join(__dirname, '..', 'src', 'theme');\n    },\n    getPathsToWatch() {\n      return [path.join(__dirname, '..', 'dist', '**', '*.{js,jsx,css}')];\n    },\n  };\n}\n\nconst getSwizzleComponentList = (): string[] => {\n  return ['WoodpeckerPluginList', 'WoodpeckerPlugin'];\n};\n\nexport { getSwizzleComponentList };\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/markdown.ts",
    "content": "import DOMPurify from 'isomorphic-dompurify';\nimport { parse as YAMLParse } from 'yaml';\n\nconst tokens = ['---', '---'];\nconst regexHeader = new RegExp('^' + tokens[0] + '([\\\\s|\\\\S]*?)' + tokens[1]);\nconst regexContent = new RegExp('^ *?\\\\' + tokens[0] + '[^]*?' + tokens[1] + '*');\n\nexport function getHeader<T = any>(data: string): T {\n  const header = getRawHeader(data);\n  return YAMLParse(header) as T;\n}\n\nexport function getRawHeader(data: string): string {\n  const header = regexHeader.exec(data);\n  if (!header) {\n    throw new Error(\"Can't get the header\");\n  }\n  return header[1];\n}\n\nexport async function getContent(data: string): Promise<string> {\n  const marked = await import('marked');\n\n  const content = data.replace(regexContent, '').replace(/<!--(.*?)-->/gm, '');\n  if (!content) {\n    throw new Error(\"Can't get the content\");\n  }\n  return DOMPurify.sanitize(marked.marked(content) as string);\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/theme/Icons.tsx",
    "content": "import React from 'react';\n\nexport const IconVerified = (size = 32) => (\n  <div title=\"This plugin is verified by the Woodpecker CI maintainers\">\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" style={{ color: '#0369a1', marginLeft: '1rem' }}>\n      <path\n        fill=\"currentColor\"\n        d=\"m8.6 22.5l-1.9-3.2l-3.6-.8l.35-3.7L1 12l2.45-2.8l-.35-3.7l3.6-.8l1.9-3.2L12 2.95l3.4-1.45l1.9 3.2l3.6.8l-.35 3.7L23 12l-2.45 2.8l.35 3.7l-3.6.8l-1.9 3.2l-3.4-1.45Zm2.35-6.95L16.6 9.9l-1.4-1.45l-4.25 4.25l-2.15-2.1L7.4 12Z\"\n      />\n    </svg>\n  </div>\n);\n\nexport const IconContainer = (size = 32) => (\n  <div title=\"Container\">\n    <svg width={size} height={size} viewBox=\"0 0 16 16\">\n      <path\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n        d=\"m10.41.24l4.711 2.774A1.767 1.767 0 0 1 16 4.54v5.01a1.77 1.77 0 0 1-.88 1.53l-7.753 4.521l-.002.001a1.767 1.767 0 0 1-1.774 0H5.59L.873 12.85A1.762 1.762 0 0 1 0 11.327V6.292c0-.304.078-.598.22-.855l.004-.005l.01-.019c.15-.262.369-.486.64-.643L8.641.239a1.75 1.75 0 0 1 1.765 0l.002.001zM9.397 1.534a.25.25 0 0 1 .252 0l4.115 2.422l-7.152 4.148a.267.267 0 0 1-.269 0L2.227 5.716l7.17-4.182zM7.365 9.402L8.73 8.61v4.46l-1.5.875V9.473a1.77 1.77 0 0 0 .136-.071zm2.864 2.794V7.741l1.521-.882v4.45l-1.521.887zm3.021-1.762l1.115-.65h.002a.268.268 0 0 0 .133-.232V5.264l-1.25.725v4.445zm-11.621 1.12l4.1 2.393V9.474a1.77 1.77 0 0 1-.138-.072L1.5 7.029v4.298c0 .095.05.181.129.227z\"\n      />\n    </svg>\n  </div>\n);\n\nexport const IconWebsite = (size = 32) => (\n  <svg width={size} height={size} viewBox=\"0 0 24 24\">\n    <g fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n      <path\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        d=\"M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12s4.477 10 10 10\"\n      />\n      <path\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        d=\"M13 2.05S16 6 16 12m-5 9.95S8 18 8 12c0-6 3-9.95 3-9.95M2.63 15.5H12m-9.37-7h18.74\"\n      />\n      <path\n        d=\"M21.879 17.917c.494.304.463 1.043-.045 1.101l-2.567.291l-1.151 2.312c-.228.459-.933.234-1.05-.334l-1.255-6.116c-.099-.48.333-.782.75-.525l5.318 3.271Z\"\n        clipRule=\"evenodd\"\n      />\n    </g>\n  </svg>\n);\n\nexport const IconPlugin = (maxWidth = 50) => (\n  <svg style={{ width: '100%', maxWidth: `${maxWidth}px` }} viewBox=\"0 0 24 24\">\n    <g fill=\"none\">\n      <path d=\"M0 0h24v24H0z\" />\n      <path\n        fill=\"currentColor\"\n        d=\"M2 9a3 3 0 0 1 3-3h2.853c.297 0 .48-.309.366-.583A2.474 2.474 0 0 1 8.083 5c-.331-1.487.792-3 2.417-3c1.626 0 2.748 1.513 2.417 3a2.48 2.48 0 0 1-.136.417c-.115.274.069.583.366.583H15a3 3 0 0 1 3 3v1.853c0 .297.308.48.583.366c.135-.056.273-.104.417-.136c1.487-.331 3 .791 3 2.417s-1.513 2.748-3 2.417a2.475 2.475 0 0 1-.417-.136c-.274-.115-.583.069-.583.366V19a3 3 0 0 1-3 3h-1.893c-.288 0-.473-.291-.39-.566c.041-.14.072-.284.085-.434a2.31 2.31 0 1 0-4.604 0c.013.15.044.294.086.434c.082.275-.103.566-.39.566H5a3 3 0 0 1-3-3v-2.893c0-.288.291-.473.566-.39c.14.041.284.072.434.085a2.31 2.31 0 1 0 0-4.604c-.15.013-.294.044-.434.086c-.275.082-.566-.103-.566-.39V9Z\"\n      />\n    </g>\n  </svg>\n);\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/theme/WoodpeckerPlugin.tsx",
    "content": "import Layout from '@theme/Layout';\nimport React from 'react';\n\nimport { WoodpeckerPlugin as WoodpeckerPluginType } from '../types';\nimport { IconContainer, IconPlugin, IconVerified, IconWebsite } from './Icons';\n\nexport function WoodpeckerPlugin({ plugin }: { plugin: WoodpeckerPluginType }) {\n  return (\n    <Layout title=\"Woodpecker CI plugins\" description=\"List of Woodpecker-CI plugins\">\n      <main className=\"margin-vert--lg container\">\n        <section>\n          <div className=\"container\">\n            <div className=\"wp-plugin-breadcrumbs\">\n              <a href=\"/plugins\">Plugins</a>\n              <span> / </span>\n              <span>{plugin.name}</span>\n            </div>\n            <div className=\"row\">\n              <div className=\"col col--10\">\n                <div style={{ display: 'flex', alignItems: 'center' }}>\n                  <h1 style={{ marginBottom: 0 }}>{plugin.name}</h1>\n                  {plugin.verified && IconVerified()}\n                </div>\n                {plugin.author && <span>by {plugin.author}</span>}\n\n                <div style={{ marginTop: '1rem' }}>\n                  {plugin.containerImage && (\n                    <div style={{ display: 'flex', gap: '.5rem', alignItems: 'center' }}>\n                      {IconContainer(20)}\n                      {plugin.containerImageUrl ? (\n                        <a href={plugin.containerImageUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n                          {plugin.containerImage}\n                        </a>\n                      ) : (\n                        <span>{plugin.containerImage}</span>\n                      )}\n                    </div>\n                  )}\n\n                  {plugin.url && (\n                    <a\n                      href={plugin.url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      style={{ display: 'flex', gap: '.5rem', alignItems: 'center' }}\n                    >\n                      <div style={{ color: 'var(--ifm-font-color-base)' }}>{IconWebsite(20)}</div> Website\n                    </a>\n                  )}\n\n                  {plugin.tags && (\n                    <div className=\"wp-plugin-tags\" style={{ marginTop: '.5rem' }}>\n                      {plugin.tags.map((tag, idx) => (\n                        <span className=\"badge badge--success\" key={idx}>\n                          {tag}\n                        </span>\n                      ))}\n                    </div>\n                  )}\n                </div>\n\n                <p style={{ marginTop: '2rem', marginBottom: '1rem' }}>{plugin.description}</p>\n              </div>\n              <div className=\"col col--2\">\n                {plugin.iconDataUrl ? <img src={plugin.iconDataUrl} width=\"150\" /> : IconPlugin(150)}\n              </div>\n            </div>\n            <hr style={{ margin: '1rem 0' }} />\n            <div dangerouslySetInnerHTML={{ __html: plugin.docs }} />\n          </div>\n        </section>\n      </main>\n    </Layout>\n  );\n}\n\nexport default WoodpeckerPlugin;\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/theme/WoodpeckerPluginList.tsx",
    "content": "import Layout from '@theme/Layout';\nimport Fuse from 'fuse.js';\nimport React, { useRef, useState } from 'react';\n\nimport './style.css';\n\nimport { WoodpeckerPlugin } from '../types';\nimport { IconPlugin, IconVerified } from './Icons';\n\nfunction PluginPanel({ plugin }: { plugin: WoodpeckerPlugin }) {\n  const pluginUrl = `/plugins/${plugin.slug}`;\n\n  return (\n    <a href={pluginUrl} className=\"card shadow--md wp-plugin-card\">\n      <div className=\"card__header row\">\n        <div className=\"col col--2 text--left\">\n          {plugin.iconDataUrl ? <img src={plugin.iconDataUrl} width=\"50\" /> : IconPlugin()}\n        </div>\n        <div className=\"col col--10\">\n          <h3>{plugin.name}</h3>\n          <p>{plugin.description}</p>\n          {plugin.tags && (\n            <div className=\"wp-plugin-tags\">\n              {plugin.tags.map((tag, idx) => (\n                <span className=\"badge badge--success\" key={idx}>\n                  {tag}\n                </span>\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n      {plugin.verified && <div className=\"wp-plugin-verified\">{IconVerified()}</div>}\n    </a>\n  );\n}\n\nexport function WoodpeckerPluginList({ plugins }: { plugins: WoodpeckerPlugin[] }) {\n  const applyForIndexUrl =\n    'https://github.com/woodpecker-ci/woodpecker/edit/main/docs/plugins/woodpecker-plugins/plugins.json';\n\n  const [query, setQuery] = useState('');\n\n  const fuse = useRef(\n    new Fuse(plugins, {\n      keys: ['name', 'description', 'tags'],\n      threshold: 0.3,\n    }),\n  );\n\n  const searchedPlugins = query.length >= 1 ? fuse.current.search(query).map((p) => p.item) : plugins;\n\n  return (\n    <Layout title=\"Woodpecker CI plugins\" description=\"List of all Woodpecker-CI plugins\">\n      <main className=\"margin-vert--lg container\">\n        <section>\n          <div style={{ display: 'flex', flexFlow: 'column', alignItems: 'center' }}>\n            <h1>Woodpecker CI plugins</h1>\n            <p>This list contains plugins which you can use to easily execute usual pipeline tasks.</p>\n            <a href={applyForIndexUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"button button--primary\">\n              🎉 Add your plugin\n            </a>\n          </div>\n          <div className=\"container\" style={{ display: 'flex', flexFlow: 'column', marginTop: '4rem' }}>\n            <input\n              type=\"search\"\n              autoComplete=\"off\"\n              value={query}\n              onChange={(event) => setQuery(event.currentTarget.value)}\n              placeholder=\"Search for a plugin ...\"\n              className=\"wp-plugin-search\"\n            />\n            <div className=\"wp-plugins-list\">\n              {searchedPlugins.map((plugin) => (\n                <PluginPanel key={plugin.name} plugin={plugin} />\n              ))}\n            </div>\n          </div>\n        </section>\n      </main>\n    </Layout>\n  );\n}\n\nexport default WoodpeckerPluginList;\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/theme/style.css",
    "content": ".wp-plugins-list {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));\n  grid-gap: 2rem;\n  margin-top: 2rem;\n}\n\n@media screen and (max-width: 450px) {\n  .wp-plugins-list {\n    grid-template-columns: auto;\n  }\n}\n\n.wp-plugin-card {\n  display: flex;\n  position: relative;\n  max-width: 32rem;\n  color: var(--ifm-navbar-link-color);\n  text-decoration: none;\n  padding: 0.5rem 0 1rem;\n  flex-grow: 1;\n  margin: 0 auto;\n  width: 100%;\n}\n\n.wp-plugin-card:hover {\n  color: var(--ifm-navbar-link-color);\n  text-decoration: none;\n}\n\n.wp-plugin-card:hover h3 {\n  color: var(--ifm-link-color);\n  text-decoration: underline;\n}\n\n.wp-plugin-card h3 {\n  color: var(--ifm-link-color);\n}\n\n.wp-plugin-verified {\n  position: absolute;\n  top: 0.75rem;\n  right: 1rem;\n  color: #0369a1;\n}\n\n.wp-plugin-tags {\n  display: flex;\n  gap: 0.5rem;\n  flex-wrap: wrap;\n}\n\n.wp-plugin-search {\n  width: 100%;\n  max-width: 32rem;\n  margin: 0 auto;\n  padding: 1rem 1rem 1rem 2.25rem;\n  font-size: 1.1rem;\n  appearance: none;\n  background: var(--ifm-navbar-search-input-background-color) var(--ifm-navbar-search-input-icon) no-repeat 0.75rem\n    1rem / 1.1rem 1.1rem;\n  border-radius: 0.5rem;\n  border: 1px solid var(--ifm-card-background-color);\n  color: var(--ifm-navbar-search-input-color);\n}\n\n.wp-plugin-search::placeholder {\n  color: var(--ifm-navbar-search-input-color);\n}\n\n.wp-plugin-breadcrumbs {\n  margin-bottom: 2rem;\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/src/types.ts",
    "content": "export type WoodpeckerPluginHeader = {\n  name?: string; // name of the plugin\n  description?: string; // short description of the plugin\n  url?: string; // url of the plugin normally link to forge\n  tags?: string[]; // tags to categorize the plugin\n  author?: string; // author of the plugin\n  icon?: string; // url pointing to an icon\n  containerImage?: string; // name of a container image\n  containerImageUrl?: string; // url to a container image registry\n};\n\nexport type WoodpeckerPluginIndexEntry = {\n  name: string; // name of the plugin\n  docs: string; // http url to the docs.md file\n  verified?: boolean; // plugins maintained by trusted parties\n};\n\nexport type WoodpeckerPlugin = WoodpeckerPluginHeader & {\n  name: string;\n  slug: string;\n  docs: string; // body of the docs .md file\n  verified: boolean; // we set verified to false when not explicitly set\n  iconDataUrl?: string;\n};\n\nexport type Content = {\n  plugins: WoodpeckerPlugin[];\n};\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/tsconfig.json",
    "content": "{\n  \"extends\": \"@tsconfig/docusaurus/tsconfig.json\",\n  \"include\": [\"src\", \"types\"],\n  \"exclude\": [\"node_modules\", \"**/__tests__/**/*\", \"**/dist/**/*\", \"src/theme\"],\n  \"compilerOptions\": {\n    \"declaration\": false,\n    \"declarationMap\": false,\n    \"esModuleInterop\": true,\n    \"importHelpers\": true,\n    \"moduleResolution\": \"Node16\",\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"sourceMap\": false,\n    \"strict\": true,\n    \"module\": \"Node16\",\n    \"target\": \"ES6\",\n    \"outDir\": \"dist\",\n    \"baseUrl\": \".\",\n    \"rootDir\": \"src\",\n    \"pretty\": true,\n    \"noEmit\": false\n  }\n}\n"
  },
  {
    "path": "docs/plugins/woodpecker-plugins/tsconfig.jsx.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"include\": [\"src/theme\"],\n  \"exclude\": [\"node_modules\", \"**/__tests__/**/*\", \"**/dist/**/*\"],\n  \"compilerOptions\": {\n    \"moduleResolution\": \"node\",\n    \"allowSyntheticDefaultImports\": true,\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"module\": \"esnext\",\n    \"jsx\": \"preserve\",\n    \"strict\": false\n  }\n}\n"
  },
  {
    "path": "docs/pnpm-workspace.yaml",
    "content": "packages:\n  - '.'\n  - 'plugins/**'\n\noverrides:\n  webpack-dev-server@<=5.2.0: '>=5.2.1'\n"
  },
  {
    "path": "docs/sidebars.js",
    "content": "module.exports = {\n  // let Docusaurus generates a sidebar from the docs folder structure\n  tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }],\n};\n"
  },
  {
    "path": "docs/src/components/HomepageFeatures.js",
    "content": "import clsx from 'clsx';\nimport React from 'react';\n\nimport styles from './HomepageFeatures.module.css';\n\nconst FeatureList = [\n  {\n    title: 'OpenSource and free',\n    Svg: require('../../static/img/feat-opensource.svg').default,\n    description: (\n      <>\n        Woodpecker is and always will be totally free. As Woodpecker's{' '}\n        <a href=\"https://github.com/woodpecker-ci/woodpecker\" target=\"_blank\">\n          source code\n        </a>{' '}\n        is open-source you can contribute to help evolving the project.\n      </>\n    ),\n  },\n  {\n    title: 'Based on docker containers',\n    Svg: require('../../static/img/feat-docker.svg').default,\n    description: (\n      <>\n        Woodpecker uses docker containers to execute pipeline steps. If you need more than a normal docker image, you\n        can create plugins to extend the pipeline features.{' '}\n        <a href=\"/docs/usage/plugins/overview\">How do plugins work?</a>\n      </>\n    ),\n  },\n  {\n    title: 'Multi workflows',\n    Svg: require('../../static/img/workflows.svg').default,\n    description: (\n      <>\n        Woodpecker allows you to easily create multiple workflows for your project. They can even depend on each other.\n        Check out the <a href=\"/docs/usage/workflows\">docs</a>\n      </>\n    ),\n  },\n];\n\nfunction Feature({ Svg, title, description }) {\n  return (\n    <div className={clsx('col col--4')}>\n      <div className=\"text--center\">\n        <Svg className={styles.featureSvg} alt={title} />\n      </div>\n      <div className=\"text--center padding-horiz--md\">\n        <h3>{title}</h3>\n        <p>{description}</p>\n      </div>\n    </div>\n  );\n}\n\nexport default function HomepageFeatures() {\n  return (\n    <section className={styles.features}>\n      <div className=\"container\">\n        <div className=\"row\">\n          {FeatureList.map((props, idx) => (\n            <Feature key={idx} {...props} />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "docs/src/components/HomepageFeatures.module.css",
    "content": ".features {\n  display: flex;\n  align-items: center;\n  padding: 2rem 0;\n  width: 100%;\n}\n\n.featureSvg {\n  height: 200px;\n  width: 200px;\n}\n"
  },
  {
    "path": "docs/src/css/custom.css",
    "content": "/**\n * Any CSS included here will be global. The classic template\n * bundles Infima by default. Infima is a CSS framework designed to\n * work well for content-centric websites.\n */\n\n/* You can override the default Infima variables here. */\n:root {\n  --ifm-color-primary: #369943;\n  --ifm-code-font-size: 95%;\n}\n\n.docusaurus-highlight-code-line {\n  background-color: rgba(0, 0, 0, 0.1);\n  display: block;\n  margin: 0 calc(-1 * var(--ifm-pre-padding));\n  padding: 0 var(--ifm-pre-padding);\n}\n\nhtml[data-theme='dark'] .docusaurus-highlight-code-line {\n  background-color: rgba(0, 0, 0, 0.3);\n}\n\n.header-github-link:hover {\n  opacity: 0.6;\n}\n\n.header-github-link:before {\n  content: '';\n  width: 24px;\n  height: 24px;\n  display: flex;\n  background: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E\")\n    no-repeat;\n}\n\nhtml[data-theme='dark'] .header-github-link:before {\n  background: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E\")\n    no-repeat;\n}\n\n.header-sponsor-link {\n  white-space: nowrap;\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.header-sponsor-link::before {\n  content: '';\n  width: 24px;\n  height: 24px;\n  background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'%3E%3Cpath fill='%23369943' d='M26 9.312c0-4.391-2.969-5.313-5.469-5.313-2.328 0-4.953 2.516-5.766 3.484-0.375 0.453-1.156 0.453-1.531 0-0.812-0.969-3.437-3.484-5.766-3.484-2.5 0-5.469 0.922-5.469 5.313 0 2.859 2.891 5.516 2.922 5.547l9.078 8.75 9.063-8.734c0.047-0.047 2.938-2.703 2.938-5.563zM28 9.312c0 3.75-3.437 6.891-3.578 7.031l-9.734 9.375c-0.187 0.187-0.438 0.281-0.688 0.281s-0.5-0.094-0.688-0.281l-9.75-9.406c-0.125-0.109-3.563-3.25-3.563-7 0-4.578 2.797-7.313 7.469-7.313 2.734 0 5.297 2.156 6.531 3.375 1.234-1.219 3.797-3.375 6.531-3.375 4.672 0 7.469 2.734 7.469 7.313z'/%3E%3C/svg%3E\")\n    no-repeat;\n}\n"
  },
  {
    "path": "docs/src/pages/about.md",
    "content": "# About\n\nWoodpecker has been originally forked from Drone 0.8 as the Drone CI license was changed after the 0.8 release from Apache 2.0 to a proprietary license. Woodpecker is based on this latest freely available version.\n\n## History\n\nWoodpecker was originally forked by [@laszlocph](https://github.com/laszlocph) in 2019.\n\nA few important time points:\n\n- [`2fbaa56`](https://github.com/woodpecker-ci/woodpecker/commit/2fbaa56eee0f4be7a3ca4be03dbd00c1bf5d1274) is the first commit of the fork, made on Apr 3, 2019.\n- The first release [v0.8.91](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.91) was published on Apr 6, 2019.\n- On Aug 27, 2019, the project was renamed to \"Woodpecker\" ([`630c383`](https://github.com/woodpecker-ci/woodpecker/commit/630c383181b10c4ec375e500c812c4b76b3c52b8)).\n- The first release under the name \"Woodpecker\" was published on Sep 9, 2019 ([v0.8.104](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.104)).\n\n## Differences to Drone\n\nWoodpecker is a community-focused software that will stay free and open source forever, while Drone is managed by [Harness](https://harness.io/) and published under [Polyform Small Business](https://polyformproject.org/licenses/small-business/1.0.0/) license.\n"
  },
  {
    "path": "docs/src/pages/awesome.md",
    "content": "# Awesome Woodpecker\n\nA curated list of assets (tools, projects, blog posts) related to Woodpecker CI.\n\nIf you want to add a new entry, open a [pull-request](https://github.com/woodpecker-ci/woodpecker/edit/main/docs/docs/92-awesome.md).\n\n## Official Resources\n\n- [Woodpecker CI pipeline configs](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) - Complex setup containing different kind of pipelines\n  - [Golang tests](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/test.yaml)\n  - [Typescript, eslint & Vue](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/web.yaml)\n  - [Docusaurus & publishing to GitHub Pages](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docs.yaml)\n  - [Docker container building](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docker.yaml)\n\n## Projects using Woodpecker\n\n- [Woodpecker CI](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) itself\n- [All official plugins](https://github.com/woodpecker-ci?q=plugin&type=all)\n- [dessalines/thumb-key](https://github.com/dessalines/thumb-key/blob/main/.woodpecker.yml) - Android Jetpack compose linting and building\n- [Vieter](https://git.rustybever.be/vieter-v/vieter) - Archlinux/Pacman repository server & automated package build system\n  - [Rieter](https://git.rustybever.be/Chewing_Bever/rieter) - Rewrite of the Vieter project in Rust\n- [Alex](https://git.rustybever.be/Chewing_Bever/alex) - Minecraft server wrapper designed to automate backups & complement Docker installations\n\n## Tools\n\n- [Convert Drone CI pipelines to Woodpecker CI](https://codeberg.org/lafriks/woodpecker-pipeline-transform)\n- [Ansible NAS](https://github.com/davestephens/ansible-nas/) - a homelab Ansible playbook that can set up Woodpecker CI and Gitea\n- [picus](https://github.com/windsource/picus) - Picus connects to a Woodpecker CI server and creates an agent in the cloud when there are pending workflows.\n- [Hetzner cloud](https://www.hetzner.com/cloud) based [Woodpecker compatible autoscaler](https://git.ljoonal.xyz/ljoonal/hetzner-ci-autoscaler) - Creates and destroys VPS instances based on the count of pending & running jobs.\n- [woodpecker-lint](https://git.schmidl.dev/schtobia/woodpecker-lint) - A repository for linting a Woodpecker config file via pre-commit hook\n- [woodpecker-shellcheck](https://codeberg.org/rfinnie/woodpecker-shellcheck) - A pre-commit hook which runs workflow steps' commands through [shellcheck](https://www.shellcheck.net/) lint.\n- [Grafana Dashboard](https://github.com/Janik-Haag/woodpecker-grafana-dashboard) - A dashboard visualizing information exposed by the Woodpecker prometheus endpoint.\n- [woodpecker-autoscaler](https://github.com/Lerentis/woodpecker-autoscaler) - Yet another Woodpecker autoscaler currently targeting [Hetzner cloud](https://www.hetzner.com/cloud) that works in parallel to other autoscaler implementations.\n- [Woodpecker MCP](https://github.com/j04n-f/woodpecker-mcp) - A Model Context Protocol (MCP) server that connects AI assistants to Woodpecker CI. Debug pipeline failures, analyze build logs, and troubleshoot CI/CD configurations with AI assistance.\n\n## Configuration Services\n\n- [Dynamic Pipelines for Nix Flakes](https://github.com/pinpox/woodpecker-flake-pipeliner) - Define pipelines as Nix Flake outputs\n\n## Pipelines\n\n- [Collection of pipeline examples](https://codeberg.org/Codeberg-CI/examples)\n\n## Posts & tutorials\n\n- [Step-by-step guide to modern, secure and Open-source CI setup](https://devforth.io/blog/step-by-step-guide-to-modern-secure-ci-setup/)\n- [Using Woodpecker CI for my static sites](https://jan.wildeboer.net/2022/07/Woodpecker-CI-Jekyll/)\n- [Woodpecker CI @ Codeberg](https://www.sarkasti.eu/articles/post/woodpecker/)\n- [Deploy Docker/Compose using Woodpecker CI](https://hinty.io/vverenko/deploy-docker-compose-using-woodpecker-ci/)\n- [Installing Woodpecker CI in your personal homelab](https://pwa.io/articles/installing-woodpecker-in-your-homelab/)\n- [Locally Cached Nix CI with Woodpecker](https://blog.kotatsu.dev/posts/2023-04-21-woodpecker-nix-caching/)\n- [How to run Cypress auto-tests on Woodpecker CI and report results to Slack](https://devforth.io/blog/how-to-run-cypress-auto-tests-on-woodpecker-ci-and-report-results-to-slack/)\n- [Quest For CICD - WoodpeckerCI](https://omaramin.me/posts/woodpecker/)\n- [Installing gitea and woodpecker using binary packages](https://neelex.com/2023/03/26/Installing-gitea-using-binary-packages/)\n- [Deploying mdbook to codeberg pages using Woodpecker CI](https://www.markpitblado.me/blog/deploying-mdbook-to-codeberg-pages-using-woodpecker-ci/)\n- [Deploy a Fly app with Woodpecker CI](https://joeroe.io/2024/01/09/deploy-fly-woodpecker-ci.html)\n- [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/)\n- [Simple selfhosted CI/CD with Woodpecker](https://xyquadrat.ch/blog/simple-ci-with-woodpecker/)\n- [Notes to self on Woodpecker-CI](https://jpmens.net/2023/09/22/notes-to-self-on-woodpecker-ci/)\n- [CI/CD with Woodpecker and Gitea](https://wilw.dev/blog/2023/04/23/woodpecker-ci/)\n- [Dookerized deploy setup using Woodpecker CI and Harbor registry](https://devforth.io/blog/dookerized-deploy-setup-using-woodpecker-ci-and-harbor-registry/)\n- [Woodpecker Shenanigans](https://jan.wildeboer.net/2024/12/Woodpecker-Shenanigans/)\n- [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/)\n- [Building a blog using Hugo, MinIO, and Woodpecker CI](https://bluemedia.dev/blog/blog-using-hugo-minio-and-woodpcker-ci/)\n- [Woodpecker CI](https://blog.mariom.pl/posts/2023/03/woodpecker/)\n- [Deploy Gitea and Woodpecker CI with Docker Compose](https://www.alexruf.net/posts/deploy-gitea-woodpecker-docker-compose/)\n- [Improving Multi-Arch Image Build Performance by not Emulating](https://blog.mei-home.net/posts/improving-container-image-build-perf-with-buildah/)\n- [CI pipelines with Woodpecker](https://blog.reinhard.codes/2024/11/19/ci-pipelines-with-woodpecker/)\n- [Setting up Woodpecker CI at home](https://jamesbrechtel.com/posts/wasting-time-for-misery-and-loss/)\n- [Woodpecker CI with automatic runner creation](https://planet.kde.org/jonah-bruchert-2023-05-13-woodpecker-ci-with-automatic-runner-creation/)\n- [Self-Hosted CI: Install and Run Woodpecker CI on Your VPS](https://mangohost.net/blog/self-hosted-ci-install-and-run-woodpecker-ci-on-your-vps/)\n- [Automating My Blog With Gitea and Woodpecker](https://bgenc.net/2022.11.19.automating-my-blog-with-gitea-and-woodpecker/)\n- [Testcontainers in Woodpecker CI](https://gaborpihaj.com/posts/testcontainers-in-woodpecker-ci/)\n\n## Videos\n\n- [Replace Ansible Semaphore with Woodpecker CI](https://www.youtube.com/watch?v=d610YPvCB0E)\n- [\"unexpected EOF\" error when trying to pair Woodpecker CI served through the Caddy with Gitea](https://www.youtube.com/watch?v=n7Hyvt71Np0)\n- [CICD Environment in Docker Swarm behind Caddy Server - Part 2 Woodpeckerci](https://www.youtube.com/watch?v=rkbw_k7JvS0)\n- [How to Build & Publish Custom Docker Container using Gitea & Woodpecker behind Caddy Server | TUNEIT](https://www.youtube.com/watch?v=9m7DbgL1mNk)\n- [Radicle Woodpecker CI Integration](https://www.youtube.com/watch?v=Ks1nbYLn4P8)\n- [woodpecker-ci/woodpecker - Gource visualisation](https://www.youtube.com/watch?v=38JuakZ6m5s)\n- [Woodpecker CI](https://www.youtube.com/watch?v=Htd98Mepu4s)\n\n## Plugins\n\nWe have a separate [index](/plugins) for plugins.\n"
  },
  {
    "path": "docs/src/pages/index.module.css",
    "content": "/**\n * CSS files with the .module.css suffix will be treated as CSS modules\n * and scoped locally.\n */\n\n.heroBanner {\n  padding: 4rem 0;\n  text-align: center;\n  position: relative;\n  overflow: hidden;\n}\n\n@media screen and (max-width: 966px) {\n  .heroBanner {\n    padding: 2rem;\n  }\n}\n\n.buttons {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n"
  },
  {
    "path": "docs/src/pages/index.tsx",
    "content": "import Link from '@docusaurus/Link';\nimport useDocusaurusContext from '@docusaurus/useDocusaurusContext';\nimport Layout from '@theme/Layout';\nimport clsx from 'clsx';\nimport React from 'react';\n\nimport HomepageFeatures from '../components/HomepageFeatures';\nimport styles from './index.module.css';\n\nfunction HomepageHeader() {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <header className={clsx('hero hero--primary', styles.heroBanner)}>\n      <div className=\"container\">\n        <h1 className=\"hero__title\">{siteConfig.title}</h1>\n        <p className=\"hero__subtitle\">{siteConfig.tagline}</p>\n        <div className={styles.buttons}>\n          <Link className=\"button button--lg button--secondary\" to=\"/docs/intro\">\n            Woodpecker Tutorial - 5min ⏱️\n          </Link>\n        </div>\n      </div>\n    </header>\n  );\n}\n\nexport default function Home() {\n  const { siteConfig } = useDocusaurusContext();\n  return (\n    <Layout\n      title={`${siteConfig.title}`}\n      description=\"Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.\"\n    >\n      <HomepageHeader />\n      <main>\n        <HomepageFeatures />\n      </main>\n    </Layout>\n  );\n}\n"
  },
  {
    "path": "docs/src/pages/migrations.md",
    "content": "<!-- markdownlint-disable no-duplicate-heading -->\n\n# Migrations\n\nTo enhance the usability of Woodpecker and meet evolving security standards, occasional migrations are necessary. While we aim to minimize these changes, some are unavoidable. If you experience significant issues during a migration to a new version, please let us know so maintainers can reassess the updates.\n\n## `next`\n\n### User-facing migrations\n\n- (Kubernetes) Deprecated `step` label on pod in favor of new namespaced label `woodpecker-ci.org/step`. The `step` label will be removed in a future update.\n- deprecated `CI_COMMIT_AUTHOR_AVATAR` and `CI_PREV_COMMIT_AUTHOR_AVATAR` env vars in favor of `CI_PIPELINE_AVATAR` and `CI_PREV_PIPELINE_AVATAR`\n- deprecated `runs_on` workflow property in favor of `when.status`.\n\n### Admin-facing migrations\n\n- changed env var `WOODPECKER_CONFIG_SERVICE_ENDPOINT` to `WOODPECKER_CONFIG_EXTENSION_ENDPOINT`\n\n#### Extensions\n\nExtension HTTP calls (as of now the configuration extension) will by default only be allowed to contact external hosts. Set `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` accordingly to allow additional hosts as needed.\n\n### API changes\n\n- The pipeline model has been changed to use nested objects grouped based on the event (e.g. instead of a generic `title` it now uses `pr.title`). Following properties are deprecated and should be replaced by the their new counterparts:\n  - `sender` =>\n    - `cron` for cron events\n\n### Internal changes\n\n- Renamed the server flag `config-service-endpoint` to `config-extension-endpoint`\n\n## 3.0.0\n\n### User-facing migrations\n\n#### Workflow syntax changes\n\n- `secrets` have been entirely removed in favor of `environment` combined with the `from_secret` syntax ([#4363](https://github.com/woodpecker-ci/woodpecker/pull/4363)).\n  As `secrets` are just normal env vars which are masked, the goal was to allow them to be declared next to normal env vars and at the same time reduce the keyword syntax count.\n  Additionally, the `from_secret` syntax gives more flexibility in naming.\n  Whereas beforehand `secrets` where always named after their initial secret name, the `from_secret` reference can now be different.\n  Last, one can inject multiple different env vars from the same secret reference.\n\n  2.x:\n\n  ```yaml\n  secrets: [my_token]\n  ```\n\n  3.x:\n\n  ```yaml\n  environment:\n    MY_TOKEN:\n      from_secret: my_token\n  ```\n\n  Learn more about using [secrets](https://woodpecker-ci.org/docs/next/usage/secrets#usage)\n\n- The `includes` and `excludes` event filter options have been removed\n- Previously, env vars have been automatically sanitized to uppercase.\n  As this has been confusing, the type-case of the secret definition is now respected ([#3375](https://github.com/woodpecker-ci/woodpecker/pull/3375)).\n- The `environment` filter option has been removed in favor of `when.evaluate`\n- Grouping of steps via `steps.[name].group` should now be done using `steps.[name].depends_on`\n\n#### Environment variables\n\n- Environment variables must now be defined as maps. List definitions are disallowed. ([#4016](https://github.com/woodpecker-ci/woodpecker/pull/4016))\n\n  2.x:\n\n  ```yaml\n  environment:\n    - ENV1=value1\n  ```\n\n  3.x:\n\n  ```yaml\n  environment:\n    ENV1: value1\n  ```\n\nThe following built-in environment variables have been removed/replaced:\n\n- `CI_COMMIT_URL` has been deprecated in favor of `CI_PIPELINE_FORGE_URL`\n- `CI_STEP_FINISHED` as it was empty during execution\n- `CI_PIPELINE_FINISHED` as it was empty during execution\n- `CI_PIPELINE_STATUS` due to always being set to `success`\n- `CI_STEP_STATUS` due to always being set to `success`\n- `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST`\n\nEnvironment variables which are empty after workflow parsing are not being injected into the build but filtered out beforehand ([#4193](https://github.com/woodpecker-ci/woodpecker/pull/4193))\n\n#### Security\n\n- The \"gated\" option, which restricted which pipelines can start right away without requiring approval, has been replaced by \"require-approval\" option. Even though this feature ([#3348](https://github.com/woodpecker-ci/woodpecker/pull/3348)) was backported to 2.8, no default is explicitly set.\n  The new default in 3.0 is to require approval only for forked repositories.\n  This allows easier management of dependency bots and other trusted entities having write access to the repository.\n\n#### Former deprecations\n\nThe following syntax deprecations will now result in an error:\n\n- `pipeline:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916))\n- `platform:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916))\n- `branches:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916))\n\n#### CLI changes\n\nThe following restructuring was done to achieve a more consistent grouping:\n\n| Old Command                                 | New Command                                 |\n| ------------------------------------------- | ------------------------------------------- |\n| `woodpecker-cli registry`                   | `woodpecker-cli repo registry`              |\n| `woodpecker-cli secret --global`            | `woodpecker-cli admin secret`               |\n| `woodpecker-cli user`                       | `woodpecker-cli admin user`                 |\n| `woodpecker-cli log-level`                  | `woodpecker-cli admin log-level`            |\n| `woodpecker-cli secret --organization`      | `woodpecker-cli org secret`                 |\n| `woodpecker-cli deploy`                     | `woodpecker-cli pipeline deploy`            |\n| `woodpecker-cli log`                        | `woodpecker-cli pipeline log`               |\n| `woodpecker-cli cron`                       | `woodpecker-cli repo cron`                  |\n| `woodpecker-cli secret --repository`        | `woodpecker-cli repo secret`                |\n| `woodpecker-cli pipeline logs`              | `woodpecker-cli pipeline log show`          |\n| `woodpecker-cli (registry,secret,...) info` | `woodpecker-cli (registry,secret,...) show` |\n\n([#4467](https://github.com/woodpecker-ci/woodpecker/pull/4467) and [#4481](https://github.com/woodpecker-ci/woodpecker/pull/4481))\n\n#### API changes\n\n- Removed deprecated `registry/` endpoint. Use `registries`, `/authorize/token`\n\n#### Miscellaneous\n\n- For `woodpecker-cli` containers, `/woodpecker` has been set as the default `workdir`\n\n- Plugin filters for secrets (in the \"secrets\" repo settings) can now validate against tags.\n  Additionally, the description has been updated to reflect that these filters only apply to plugins ([#4069](https://github.com/woodpecker-ci/woodpecker/pull/4069)).\n\n- SDK changes:\n  - The SDK fields `start_time`, `end_time`, `created_at`, `started_at`, `finished_at` and `reviewed_at` have been renamed to `started`, `finished`, `created`, `started`, `finished`, `reviewed` ([#3968](https://github.com/woodpecker-ci/woodpecker/pull/3968))\n  - The `trusted` field of the repo model was changed from `boolean` to `object` ([#4025](https://github.com/woodpecker-ci/woodpecker/pull/4025))\n\n- CRON definitions now follow standard Linux syntax without seconds. An automatic migration will attempt to update your\n  settings - ensure the update completes successfully.\n\n  Example definition for a CRON job running at 8 am daily:\n\n  2.x:\n\n  ```sh\n  0 0 8 * * *\n  ```\n\n  3.x:\n\n  ```sh\n  0 8 * * *\n  ```\n\n- Native Let's Encrypt certificate support has been dropped as it was almost unused and causing frequent issues.\n  Let's Encrypt needs to be set up standalone now. The SSL key pair can still be used in `WOODPECKER_SERVER_CERT` and `WOODPECKER_SERVER_KEY` as an alternative to using a reverse proxy for TLS termination. ([#4541](https://github.com/woodpecker-ci/woodpecker/pull/4541))\n\n- The filename of the CLI binary changed for DEB and RPM packages, it is now called `woodpecker-cli` instead of `woodpecker`.\n\n### Admin-facing migrations\n\n#### Updated tokens\n\nThe Webhook tokens have been changed for enhanced security and therefore existing repositories need to be updated using the `Repair all` button in the admin settings ([#4013](https://github.com/woodpecker-ci/woodpecker/pull/4013)).\n\n#### Image tags\n\n- The `latest` tag has been dropped to avoid accidental major version upgrades.\n  A dedicated semver tag specification must be used, i.e., either a fixed version (like `v3.0.0`) or a rolling tag (e.g. `v3.0` or `v3`).\n\n- Previously, some (official) plugins were granted the `privileged` option by default to allow simplified usage.\n  To streamline this process and enhance security transparency, no plugin is granted the `privileged` options by default anymore.\n  To allow the use of these plugins in >= 3.0, they must be set explicitly through `WOODPECKER_PLUGINS_PRIVILEGED` on the admin side.\n  This change mainly impacts the use of the `woodpeckerci/plugin-docker-buildx` plugin, which now will not work anymore unless explicitly listed through this env var ([#4053](https://github.com/woodpecker-ci/woodpecker/pull/4053))\n\n- Environment variable deprecations:\n\n  | Deprecated Variable              | New Variable                         |\n  | -------------------------------- | ------------------------------------ |\n  | `WOODPECKER_LOG_XORM`            | `WOODPECKER_DATABASE_LOG`            |\n  | `WOODPECKER_LOG_XORM_SQL`        | `WOODPECKER_DATABASE_LOG_SQL`        |\n  | `WOODPECKER_FILTER_LABELS`       | `WOODPECKER_AGENT_LABELS`            |\n  | `WOODPECKER_ESCALATE`            | `WOODPECKER_PLUGINS_PRIVILEGED`      |\n  | `WOODPECKER_DEFAULT_CLONE_IMAGE` | `WOODPECKER_DEFAULT_CLONE_PLUGIN`    |\n  | `WOODPECKER_DEV_OAUTH_HOST`      | `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` |\n  | `WOODPECKER_DEV_GITEA_OAUTH_URL` | `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` |\n  | `WOODPECKER_ROOT_PATH`           | `WOODPECKER_HOST`                    |\n  | `WOODPECKER_ROOT_URL`            | `WOODPECKER_HOST`                    |\n\n- The resource limit settings for the \"docker\" backend were moved from the server into agent configuration.\n  This allows setting limits on an agent-level which allows greater resource definition granularity ([#3174](https://github.com/woodpecker-ci/woodpecker/pull/3174))\n\n- \"Kubernetes\" backend: previously the image pull secret name was hard-coded to `regcred`.\n  To allow more flexibility and specifying multiple pull secrets, the default has been removed.\n  Image pull secrets must now be set explicitly via env var `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` ([#4005](https://github.com/woodpecker-ci/woodpecker/pull/4005))\n\n- Webhook signatures now use the `rfc9421` protocol\n\n- Git is now the only officially supported SCM.\n  No others were supported previously, but the existence of the env var `CI_REPO_SCM` indicated that others might be.\n  The env var has now been removed including unused code associated with it. ([#4346](https://github.com/woodpecker-ci/woodpecker/pull/4346))\n\n#### Rootless images\n\nWoodpecker now supports running rootless images by adjusting the entrypoints and directory permissions in the containers in a way that allows non-privileged users to execute tasks.\n\nIn addition, all images published by Woodpecker (Server, Agent, CLI) now use a non-privileged user (`woodpecker` with UID and GID `1000`) by default. If you have volumes attached to the containers, you may need to change the ownership of these directories from `root` to `woodpecker` by executing `chown -R 1000:1000 <mount dir>`.\n\n:::info\nThe agent image must remain rootful by default to be able to mount the Docker socket when Woodpecker is used with the `docker` backend.\nThe helm chart will start to use a non-privileged user by utilizing `securityContext`.\nRunning a completely rootless agent with the `docker` backend may be possible by using a rootless docker daemon.\nHowever, this requires more work and is currently not supported.\n:::\n\n## 2.7.2\n\nTo secure your instance, set `WOODPECKER_PLUGINS_PRIVILEGED` to only allow specific versions of the `woodpeckerci/plugin-docker-buildx` plugin, use version 5.0.0 or above. This prevents older, potentially unstable versions from being privileged.\n\nFor example, to allow only version 5.0.0, use:\n\n```bash\nWOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0\n```\n\nTo allow multiple versions, you can separate them with commas:\n\n```bash\nWOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0,woodpeckerci/plugin-docker-buildx:5.1.0\n```\n\nThis setup ensures only specified, stable plugin versions are given privileged access.\n\nRead more about it in [#4213](https://github.com/woodpecker-ci/woodpecker/pull/4213)\n\n## 2.0.0\n\n- Dropped deprecated `CI_BUILD_*`, `CI_PREV_BUILD_*`, `CI_JOB_*`, `*_LINK`, `CI_SYSTEM_ARCH`, `CI_REPO_REMOTE` built-in environment variables\n- Deprecated `platform:` filter in favor of `labels:`, [read more](/docs/usage/workflow-syntax#filter-by-platform)\n- Secrets `event` property was renamed to `events` and `image` to `images` as both are lists. The new property `events` / `images` has to be used in the api. The old properties `event` and `image` were removed.\n- The secrets `plugin_only` option was removed. Secrets with images are now always only available for plugins using listed by the `images` property. Existing secrets with a list of `images` will now only be available to the listed images if they are used as a plugin.\n- Removed `build` alias for `pipeline` command in CLI\n- Removed `ssh` backend. Use an agent directly on the SSH machine using the `local` backend.\n- Removed `/hook` and `/stream` API paths in favor of `/api/(hook|stream)`. You may need to use the \"Repair repository\" button in the repo settings or \"Repair all\" in the admin settings to recreate the forge hook.\n- Removed `WOODPECKER_DOCS` config variable\n- Renamed `link` to `url` (including all API fields)\n- Deprecated `CI_COMMIT_URL` env var, use `CI_PIPELINE_FORGE_URL`\n\n## 1.0.0\n\n- The signature used to verify extension calls (like those used for the [config-extension](/docs/next/usage/extensions/configuration-extension)) done by the Woodpecker server switched from using a shared-secret HMac to an ed25519 key-pair. Read more about it at the [config-extensions](/docs/next/usage/extensions/configuration-extension) documentation.\n- Refactored support for old agent filter labels and expressions. Learn how to use the new [filter](/docs/usage/workflow-syntax#labels)\n- Renamed step environment variable `CI_SYSTEM_ARCH` to `CI_SYSTEM_PLATFORM`. Same applies for the cli exec variable.\n- Renamed environment variables `CI_BUILD_*` and `CI_PREV_BUILD_*` to `CI_PIPELINE_*` and `CI_PREV_PIPELINE_*`, old ones are still available but deprecated\n- Renamed environment variables `CI_JOB_*` to `CI_STEP_*`, old ones are still available but deprecated\n- Renamed environment variable `CI_REPO_REMOTE` to `CI_REPO_CLONE_URL`, old is still available but deprecated\n- Renamed environment variable `*_LINK` to `*_URL`, old ones are still available but deprecated\n- Renamed API endpoints for pipelines (`<owner>/<repo>/builds/<buildId>` -> `<owner>/<repo>/pipelines/<pipelineId>`), old ones are still available but deprecated\n- Updated Prometheus gauge `build_*` to `pipeline_*`\n- Updated Prometheus gauge `*_job_*` to `*_step_*`\n- Renamed config env `WOODPECKER_MAX_PROCS` to `WOODPECKER_MAX_WORKFLOWS` (still available as fallback) <!-- cspell:ignore PROCS -->\n- The pipelines are now also read from `.yaml` files, the new default order is `.woodpecker/*.yml` and `.woodpecker/*.yaml` (without any prioritization) -> `.woodpecker.yml` -> `.woodpecker.yaml`\n- Dropped support for [Coding](https://coding.net/), [Gogs](https://gogs.io) and Bitbucket Server (Stash).\n- `/api/queue/resume` & `/api/queue/pause` endpoint methods were changed from `GET` to `POST`\n- rename `pipeline:` key in your workflow config to `steps:`\n- If you want to migrate old logs to the new format, watch the error messages on start. If there are none we are good to go, else you have to plan a migration that can take hours. Set `WOODPECKER_MIGRATIONS_ALLOW_LONG` to true and let it run.\n- Using `repo-id` in favor of `owner/repo` combination\n  - :warning: The api endpoints `/api/repos/{owner}/{repo}/...` were replaced by new endpoints using the repos id `/api/repos/{repo-id}`\n  - To find the id of a repo use the `/api/repos/lookup/{repo-full-name-with-slashes}` endpoint.\n  - The existing badge endpoint `/api/badges/{owner}/{repo}` will still work, but whenever possible try to use the new endpoint using the `repo-id`: `/api/badges/{repo-id}`.\n  - The UI urls for a repository changed from `/repos/{owner}/{repo}/...` to `/repos/{repo-id}/...`. You will be redirected automatically when using the old url.\n  - The woodpecker-go api-client is now using the `repo-id` instead of `owner/repo` for all functions\n- Using `org-id` in favour of `owner` name\n  - :warning: The api endpoints `/api/orgs/{owner}/...` were replaced by new endpoints using the orgs id `/api/repos/{org-id}`\n  - To find the id of orgs use the `/api/orgs/lookup/{org_full_name}` endpoint.\n  - The UI urls for a organization changed from `/org/{owner}/...` to `/orgs/{org-id}/...`. You will be redirected automatically when using the old url.\n  - The woodpecker-go api-client is now using the `org-id` instead of `org name` for all functions\n- The `command:` field has been removed from steps. If you were using it, please check if the entrypoint of the image you used is a shell.\n  - If it is a shell, simply rename `command:` to `commands:`.\n  - If it's not, you need to prepend the entrypoint before and also rename it (e.g., `commands: <entrypoint> <cmd>`).\n\n## 0.15.0\n\n- Default value for custom pipeline path is now empty / un-set which results in following resolution:\n\n  `.woodpecker/*.yml` -> `.woodpecker.yml` -> `.drone.yml`\n\n  Only projects created after updating will have an empty value by default. Existing projects will stick to the current pipeline path which is `.drone.yml` in most cases.\n\n  Read more about it at the [Project Settings](/docs/usage/project-settings#pipeline-path)\n\n- From version `0.15.0` ongoing there will be three types of docker images: `latest`, `next` and `x.x.x` with an alpine variant for each type like `latest-alpine`.\n  If you used `latest` before to try pre-release features you should switch to `next` after this release.\n\n- Dropped support for `DRONE_*` environment variables. The according `WOODPECKER_*` variables must be used instead.\n  Additionally some alternative namings have been removed to simplify maintenance:\n  - `WOODPECKER_AGENT_SECRET` replaces `WOODPECKER_SECRET`, `DRONE_SECRET`, `WOODPECKER_PASSWORD`, `DRONE_PASSWORD` and `DRONE_AGENT_SECRET`.\n  - `WOODPECKER_HOST` replaces `DRONE_HOST` and `DRONE_SERVER_HOST`.\n  - `WOODPECKER_DATABASE_DRIVER` replaces `DRONE_DATABASE_DRIVER` and `DATABASE_DRIVER`.\n  - `WOODPECKER_DATABASE_DATASOURCE` replaces `DRONE_DATABASE_DATASOURCE` and `DATABASE_CONFIG`.\n\n- Dropped support for `DRONE_*` environment variables in pipeline steps. Pipeline meta-data can be accessed with `CI_*` variables.\n  - `CI_*` prefix replaces `DRONE_*`\n  - `CI` value is now `woodpecker`\n  - `DRONE=true` has been removed\n  - Some variables got deprecated and will be removed in future versions. Please migrate to the new names. Same applies for `DRONE_` of them.\n    - CI_ARCH => use CI_SYSTEM_ARCH\n    - CI_COMMIT => CI_COMMIT_SHA\n    - CI_TAG => CI_COMMIT_TAG\n    - CI_PULL_REQUEST => CI_COMMIT_PULL_REQUEST\n    - CI_REMOTE_URL => use CI_REPO_REMOTE\n    - CI_REPO_BRANCH => use CI_REPO_DEFAULT_BRANCH\n    - CI_PARENT_BUILD_NUMBER => use CI_BUILD_PARENT\n    - CI_BUILD_TARGET => use CI_BUILD_DEPLOY_TARGET\n    - CI_DEPLOY_TO => use CI_BUILD_DEPLOY_TARGET\n    - CI_COMMIT_AUTHOR_NAME => use CI_COMMIT_AUTHOR\n    - CI_PREV_COMMIT_AUTHOR_NAME => use CI_PREV_COMMIT_AUTHOR\n    - CI_SYSTEM => use CI_SYSTEM_NAME\n    - CI_BRANCH => use CI_COMMIT_BRANCH\n    - CI_SOURCE_BRANCH => use CI_COMMIT_SOURCE_BRANCH\n    - CI_TARGET_BRANCH => use CI_COMMIT_TARGET_BRANCH\n\n  For all available variables and their descriptions have a look at [built-in-environment-variables](/docs/usage/environment#built-in-environment-variables).\n\n- Prometheus metrics have been changed from `drone_*` to `woodpecker_*`\n\n- Base path has moved from `/var/lib/drone` to `/var/lib/woodpecker`\n\n- Default workspace base path has moved from `/drone` to `/woodpecker`\n\n- Default SQLite database location has changed:\n  - `/var/lib/drone/drone.sqlite` -> `/var/lib/woodpecker/woodpecker.sqlite`\n  - `drone.sqlite` -> `woodpecker.sqlite`\n\n- Plugin Settings moved into `settings` section:\n\n  ```diff\n   steps:\n   something:\n     image: my/plugin\n  -  setting1: foo\n  -  setting2: bar\n  +  settings:\n  +    setting1: foo\n  +    setting2: bar\n  ```\n\n- `WOODPECKER_DEBUG` option for server and agent got removed in favor of `WOODPECKER_LOG_LEVEL=debug`\n\n- Remove unused server flags which can safely be removed from your server config: `WOODPECKER_QUIC`, `WOODPECKER_GITHUB_SCOPE`, `WOODPECKER_GITHUB_GIT_USERNAME`, `WOODPECKER_GITHUB_GIT_PASSWORD`, `WOODPECKER_GITHUB_PRIVATE_MODE`, `WOODPECKER_GITEA_GIT_USERNAME`, `WOODPECKER_GITEA_GIT_PASSWORD`, `WOODPECKER_GITEA_PRIVATE_MODE`, `WOODPECKER_GITLAB_GIT_USERNAME`, `WOODPECKER_GITLAB_GIT_PASSWORD`, `WOODPECKER_GITLAB_PRIVATE_MODE`\n\n- Dropped support for manually setting the agents platform with `WOODPECKER_PLATFORM`. The platform is now automatically detected.\n\n- Use `WOODPECKER_STATUS_CONTEXT` instead of the deprecated options `WOODPECKER_GITHUB_CONTEXT` and `WOODPECKER_GITEA_CONTEXT`.\n\n## 0.14.0\n\nNo breaking changes\n\n## From Drone\n\n:::warning\nMigration from Drone is only possible if you were running Drone <= v0.8.\n:::\n\n1. Make sure you are already running Drone v0.8\n2. Upgrade to Woodpecker v0.14.4, migration will be done during startup\n3. Upgrade to the latest Woodpecker version. Pay attention to the breaking changes listed above.\n"
  },
  {
    "path": "docs/src/pages/versions.md",
    "content": "# Versions\n\nWoodpecker is having two different kinds of releases: **stable** and **next**.\n\nIf you want all (new) features of Woodpecker while supporting us with feedback and are willing to accept some possible bugs from time to time, you should use the next release, otherwise use the stable release.\n\nWe plan to release a new version every four weeks and will release the next version as a stable version.\n\n## Stable version\n\nThe **stable** releases are official versions following [semver](https://semver.org/). By default, only the latest stable release will receive bug fixes. Once a new major or minor release is available, previous minor versions might receive security patches, but won't be updated with bug fixes anymore (so called backporting) by default.\n\n### Breaking changes\n\nAs of semver guidelines, breaking changes will be released as a major version. We will hold back\nbreaking changes to not release many majors each containing just a few breaking changes.\nPrior to the release of a major version, a release candidate (RC) will be published to allow easy testing,\nthe actual release will be about a week later.\n\n### Deprecations & migrations\n\nAll deprecations and migrations for Woodpecker users and instance admins are documented in the [migration guide](/migrations).\n\n## Next version (current state of the `main` branch)\n\nThe **next** version contains all bugfixes and features from `main` branch. Normally it should be pretty stable, but as its frequently updated, it might contain some bugs from time to time. There are no binaries for this version.\n\n## Past versions (Not maintained anymore)\n\nHere you can find documentation for previous versions of Woodpecker.\n\n[Changelog](https://github.com/woodpecker-ci/woodpecker/blob/main/CHANGELOG.md)\n\n|         |            |                                                                                       |\n| ------- | ---------- | ------------------------------------------------------------------------------------- |\n| 3.14.0  | 2026-05-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.14.0/docs/docs/)  |\n| 3.13.0  | 2026-01-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.13.0/docs/docs/)  |\n| 3.12.0  | 2025-11-18 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.12.0/docs/docs/)  |\n| 3.11.0  | 2025-10-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.11.0/docs/docs/)  |\n| 3.10.0  | 2025-09-28 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.10.0/docs/docs/)  |\n| 3.9.0   | 2025-08-20 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.9.0/docs/docs/)   |\n| 3.8.0   | 2025-07-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.8.0/docs/docs/)   |\n| 3.7.0   | 2025-06-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.7.0/docs/docs/)   |\n| 3.6.0   | 2025-05-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.6.0/docs/docs/)   |\n| 3.5.0   | 2025-04-02 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.5.0/docs/docs/)   |\n| 3.4.0   | 2025-03-17 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.4.0/docs/docs/)   |\n| 3.3.0   | 2025-03-04 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.3.0/docs/docs/)   |\n| 3.2.0   | 2025-02-26 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.2.0/docs/docs/)   |\n| 3.1.0   | 2025-02-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.1.0/docs/docs/)   |\n| 3.0.0   | 2025-01-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.0.0/docs/docs/)   |\n| 2.8.3   | 2025-01-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.3/docs/docs/)   |\n| 2.8.2   | 2024-12-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.2/docs/docs/)   |\n| 2.8.1   | 2024-12-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.1/docs/docs/)   |\n| 2.8.0   | 2024-11-28 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.0/docs/docs/)   |\n| 2.7.3   | 2024-11-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.3/docs/docs/)   |\n| 2.7.2   | 2024-11-03 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.2/docs/docs/)   |\n| 2.7.1   | 2024-09-07 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.1/docs/docs/)   |\n| 2.7.0   | 2024-07-18 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.0/docs/docs/)   |\n| 2.6.1   | 2024-07-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.6.1/docs/docs/)   |\n| 2.6.0   | 2024-06-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.6.0/docs/docs/)   |\n| 2.5.0   | 2024-06-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.5.0/docs/docs/)   |\n| 2.4.1   | 2024-03-20 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.4.1/docs/docs/)   |\n| 2.4.0   | 2024-03-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.4.0/docs/docs/)   |\n| 2.3.0   | 2024-01-31 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.3.0/docs/docs/)   |\n| 2.2.2   | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.2/docs/docs/)   |\n| 2.2.1   | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.1/docs/docs/)   |\n| 2.2.0   | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.0/docs/docs/)   |\n| 2.1.1   | 2023-12-27 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.1.1/docs/docs/)   |\n| 2.1.0   | 2023-12-26 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.1.0/docs/docs/)   |\n| 2.0.0   | 2023-12-23 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.0.0/docs/docs/)   |\n| 1.0.5   | 2023-11-09 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.5/docs/docs/)   |\n| 1.0.4   | 2023-11-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.4/docs/docs/)   |\n| 1.0.3   | 2023-10-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.3/docs/docs/)   |\n| 1.0.2   | 2023-08-16 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.2/docs/docs/)   |\n| 1.0.1   | 2023-08-08 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.1/docs/docs/)   |\n| 1.0.0   | 2023-07-29 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.0/docs/docs/)   |\n| 0.15.11 | 2023-07-12 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.11/docs/docs/) |\n| 0.15.10 | 2023-07-09 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.10/docs/docs/) |\n| 0.15.9  | 2023-05-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.9/docs/docs/)  |\n| 0.15.8  | 2023-04-29 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.8/docs/docs/)  |\n| 0.15.7  | 2023-03-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.7/docs/docs/)  |\n| 0.15.6  | 2022-12-23 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.6/docs/docs/)  |\n| 0.15.5  | 2022-10-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.5/docs/docs/)  |\n| 0.15.4  | 2022-09-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.4/docs/docs/)  |\n| 0.15.3  | 2022-06-16 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.3/docs/docs/)  |\n| 0.15.2  | 2022-06-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.2/docs/docs/)  |\n| 0.15.1  | 2022-04-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.1/docs/docs/)  |\n| 0.15.0  | 2022-02-24 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.0/docs/docs/)  |\n| 0.14.4  | 2022-01-31 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.4/docs/docs/)  |\n| 0.14.3  | 2021-10-30 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.3/docs/docs/)  |\n| 0.14.2  | 2021-10-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.2/docs/docs/)  |\n| 0.14.1  | 2021-09-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.1/docs/docs/)  |\n| 0.14.0  | 2021-08-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.0/docs/docs/)  |\n\nIf you are using an older version of Woodpecker and would like to view docs for this version, please use GitHub to browse the repository at your tag.\n"
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n  \"extends\": \"@docusaurus/tsconfig\",\n  \"include\": [\"src/\"]\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/10-intro/index.md",
    "content": "# Welcome to Woodpecker\n\nWoodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics.\n\n## Have you ever heard of CI/CD or pipelines?\n\nDon't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of\nchecks, tests and routines along the way. A typical pipeline might include the following steps:\n\n1. Running tests\n2. Building your application\n3. Deploying your application\n\n[Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd)\n\n## Do you know containers?\n\nIf you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/).\n\n## Already have access to a Woodpecker instance?\n\nThen you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md).\n\n## Want to start from scratch and deploy your own Woodpecker instance?\n\nWoodpecker is [pretty lightweight](../30-administration/00-getting-started.md#hardware-requirements) and will even run on your Raspberry Pi. You can follow the [deployment guide](../30-administration/00-getting-started.md) to set up your own Woodpecker instance.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/10-intro.md",
    "content": "# Your first pipeline\n\nLet's get started and create your first pipeline.\n\n## 1. Repository Activation\n\nTo activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click.\n\n![new repository list](repo-new.png)\n\nTo enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something\nthat is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.).\n\n## 2. Define first workflow\n\nAfter enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository:\n\n```yaml title=\".woodpecker/my-first-workflow.yaml\"\nwhen:\n  - event: push\n    branch: main\n\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"This is the build step\"\n      - echo \"binary-data-123\" > executable\n  - name: a-test-step\n    image: golang:1.16\n    commands:\n      - echo \"Testing ...\"\n      - ./executable\n```\n\n**So what did we do here?**\n\n1. We defined your first workflow file `my-first-workflow.yaml`.\n2. This workflow will be executed when a push event happens on the `main` branch,\n   because we added a filter using the `when` section:\n\n   ```diff\n   + when:\n   +   - event: push\n   +     branch: main\n\n   ...\n   ```\n\n3. We defined two steps: `build` and `a-test-step`\n\nThe steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`.\n\nIn the `build` step we use the `debian` image and build a \"binary file\" called `executable`.\n\nIn the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it.\n\nYou can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to:\n\n```diff\n steps:\n   - name: build\n-    image: debian\n+    image: mycompany/image-with-awscli\n     commands:\n       - aws help\n```\n\n## 3. Push the file and trigger first pipeline\n\nIf you push this file to your repository now, Woodpecker will already execute your first pipeline.\n\nYou can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository.\n\n![pipeline view](./pipeline.png)\n\nAs you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps.\n\nThis for example allows the first step to build your application using your source code and as the second step will receive\nthe same workspace it can use the previously built binary and test it.\n\n## 4. Use a plugin for reusable tasks\n\nSometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md).\n\nIf you want to get a Slack notification after your pipeline has finished, you can add a Slack plugin to your pipeline:\n\n```yaml\n---\n- name: notify me on Slack\n  image: plugins/slack\n  settings:\n    channel: developers\n    username: woodpecker\n    password:\n      from_secret: slack_token\n  when:\n    status: [success, failure] # This will execute the step on success and failure\n```\n\nTo configure a plugin you can use the `settings` section.\n\nSometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md).\n\nSimilar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed.\n\nLearn more about [plugins](./51-plugins/51-overview.md).\n\nAs you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/100-troubleshooting.md",
    "content": "# Troubleshooting\n\n## How to debug clone issues\n\n(And what to do with an error message like `fatal: could not read Username for 'https://<url>': No such device or address`)\n\nThis error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`:\n\n```ini\nWOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true\n```\n\nIf that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container \"hang\":\n\n```yaml\nskip_clone: true\n\nsteps:\n  build:\n    image: debian:stable-backports\n    commands:\n      - apt update\n      - apt install -y inetutils-ping wget\n      - ping -c 4 git.example.com\n      - wget git.example.com\n      - sleep 9999999\n```\n\nGet the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf  bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline:\n\n```bash\ngit init\ngit remote add origin https://git.example.com/username/repo.git\ngit fetch --no-tags origin +refs/heads/branch:\n```\n\n(replace the url AND the branch with the correct values, use your username and password as log in values)\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/15-terminology/architecture.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 226,\n      \"versionNonce\": 1002880859,\n      \"isDeleted\": false,\n      \"id\": \"UczUX5VuNnCB1rVvUJVfm\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.098092529257,\n      \"y\": 320.8758615860986,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 472.8823858375721,\n      \"height\": 183.19688715994928,\n      \"seed\": 917720693,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 161,\n      \"versionNonce\": 286006267,\n      \"isDeleted\": false,\n      \"id\": \"sKPZmBSWUdAYfBs4ByItH\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 539.5451038202509,\n      \"y\": 345.2419383247636,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 82.46875,\n      \"height\": 32.199999999999996,\n      \"seed\": 1485551573,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Server\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Server\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 333,\n      \"versionNonce\": 448586907,\n      \"isDeleted\": false,\n      \"id\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 649.8080506852966,\n      \"y\": 427.60908869342575,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 136,\n      \"height\": 60,\n      \"seed\": 1783625013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"r90dckf8trHemYzEwCgCW\"\n        },\n        {\n          \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 298,\n      \"versionNonce\": 1244067771,\n      \"isDeleted\": false,\n      \"id\": \"r90dckf8trHemYzEwCgCW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 703.8080506852966,\n      \"y\": 441.5090886934257,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 28,\n      \"height\": 32.199999999999996,\n      \"seed\": 660965013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113383,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"UI\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"originalText\": \"UI\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 105,\n      \"versionNonce\": 265992667,\n      \"isDeleted\": false,\n      \"id\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 800.9240766836483,\n      \"y\": 421.4987043996123,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135.3671503686619,\n      \"height\": 62.2689029398432,\n      \"seed\": 1115810805,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"svsVhxCbatcLj7lQLch0P\"\n        },\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 83,\n      \"versionNonce\": 1706870395,\n      \"isDeleted\": false,\n      \"id\": \"svsVhxCbatcLj7lQLch0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 828.1594096804793,\n      \"y\": 436.53315586953386,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.896484375,\n      \"height\": 32.199999999999996,\n      \"seed\": 2074781013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"GRPC\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"originalText\": \"GRPC\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 270,\n      \"versionNonce\": 418660123,\n      \"isDeleted\": false,\n      \"id\": \"hSrrwwnm9y7R-_CnJtaK1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.567103519039,\n      \"y\": 556.4146894573112,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 1983197877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 154,\n      \"versionNonce\": 871605179,\n      \"isDeleted\": false,\n      \"id\": \"8tsYgVssKnBd_Zw1QuqNz\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1298.4367898442752,\n      \"y\": 566.567242947784,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 96.5234375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1321669653,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent 1\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent 1\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 1323136091,\n      \"isDeleted\": false,\n      \"id\": \"eeugZg73_yD_6uLBBgmcX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 404.5001910129067,\n      \"y\": 707.1233710221009,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 210.068359375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1901447541,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"User => Browser\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"User => Browser\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"ellipse\",\n      \"version\": 106,\n      \"versionNonce\": 1501835515,\n      \"isDeleted\": false,\n      \"id\": \"mlDhl4OOc-H1tNgh77AAW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 482.5857164810477,\n      \"y\": 602.4394551739279,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 46.024748503793035,\n      \"height\": 44.21988070606176,\n      \"seed\": 791073493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"line\",\n      \"version\": 166,\n      \"versionNonce\": 627726747,\n      \"isDeleted\": false,\n      \"id\": \"ADEXzdYAhvj-_wVRftTIg\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 459.12202200277807,\n      \"y\": 697.1964604319912,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.31792517362464,\n      \"height\": 31.585599568061298,\n      \"seed\": 349155381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          42.415150610916044,\n          -28.87829787146393\n        ],\n        [\n          80.31792517362464,\n          2.7073016965973693\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 231,\n      \"versionNonce\": 801271355,\n      \"isDeleted\": false,\n      \"id\": \"xmz4J-rxLIjfUQ4q19PjD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 516.8788931508789,\n      \"y\": 870.4664542146543,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#fff4e6\",\n      \"width\": 385.34512717560705,\n      \"height\": 60.464035142111264,\n      \"seed\": 3531157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 93,\n      \"versionNonce\": 728690395,\n      \"isDeleted\": false,\n      \"id\": \"gSbpry_947XArfI7b6AAL\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 636.1468430141358,\n      \"y\": 878.5884970070326,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 132.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1989076725,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Autoscaler\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Autoscaler\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 118,\n      \"versionNonce\": 1258445691,\n      \"isDeleted\": false,\n      \"id\": \"WVy0mdTGbUx08RuxdQUH8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 523.3741602213286,\n      \"y\": 907.372811672524,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 369.1484375,\n      \"height\": 18.4,\n      \"seed\": 979386453,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Starts agents based on amount of pending pipelines\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Starts agents based on amount of pending pipelines\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 373,\n      \"versionNonce\": 1254044699,\n      \"isDeleted\": false,\n      \"id\": \"0Y1RcqzVFBFqh-wy-APMI\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1232.1955835481922,\n      \"y\": 605.8737363119278,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 292.6171875,\n      \"height\": 18.4,\n      \"seed\": 561999285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Executes pending workflows of a pipeline\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Executes pending workflows of a pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 630,\n      \"versionNonce\": 983038139,\n      \"isDeleted\": false,\n      \"id\": \"lGumbhMs3xx1vU2632hli\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 505.62283787078286,\n      \"y\": 383.42044095379515,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.015625,\n      \"height\": 36.8,\n      \"seed\": 722595605,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Central unit of a \\nWoodpecker instance \",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Central unit of a \\nWoodpecker instance \",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 131,\n      \"versionNonce\": 137308507,\n      \"isDeleted\": false,\n      \"id\": \"PbSQXehWVLYcQGXYFpd-B\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 971.7123256059622,\n      \"y\": 171.06951064323448,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 274.3443117379593,\n      \"height\": 74.90311522655017,\n      \"seed\": 1435321461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 96,\n      \"versionNonce\": 1222067707,\n      \"isDeleted\": false,\n      \"id\": \"2P2tz29C_2sUzVNSpaG17\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.5206131439782,\n      \"y\": 183.12082907329545,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 73.14453125,\n      \"height\": 32.199999999999996,\n      \"seed\": 884403669,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Forge\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Forge\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 141,\n      \"versionNonce\": 1133694619,\n      \"isDeleted\": false,\n      \"id\": \"0eYhFYPuRanZ7wkR2OlHO\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 986.864582863368,\n      \"y\": 225.1223531590797,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 247.234375,\n      \"height\": 18.4,\n      \"seed\": 1201957685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 55,\n      \"versionNonce\": 991183675,\n      \"isDeleted\": false,\n      \"id\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 820.419424341303,\n      \"y\": 340.29123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 247151765,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"bcUL-u4zkLA9CLG2YdaeN\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 38,\n      \"versionNonce\": 2008949723,\n      \"isDeleted\": false,\n      \"id\": \"bcUL-u4zkLA9CLG2YdaeN\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 831.853994653803,\n      \"y\": 358.79123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 94.130859375,\n      \"height\": 23,\n      \"seed\": 1638982133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Webhooks\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"originalText\": \"Webhooks\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 93,\n      \"versionNonce\": 295891067,\n      \"isDeleted\": false,\n      \"id\": \"Bphhue86mMXHN4klGamM3\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 697.3018309300141,\n      \"y\": 339.607928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 92986197,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"0YxY2hEPyDWFqR8_-f6bn\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 87,\n      \"versionNonce\": 2055547163,\n      \"isDeleted\": false,\n      \"id\": \"0YxY2hEPyDWFqR8_-f6bn\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 727.4522215550141,\n      \"y\": 358.107928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 56.69921875,\n      \"height\": 23,\n      \"seed\": 43952309,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"OAuth\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Bphhue86mMXHN4klGamM3\",\n      \"originalText\": \"OAuth\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 284,\n      \"versionNonce\": 1205292475,\n      \"isDeleted\": false,\n      \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1205.6453201409104,\n      \"y\": 250.4849674923464,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 272.1094712799886,\n      \"height\": 94.31865813977868,\n      \"seed\": 982632981,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"0eYhFYPuRanZ7wkR2OlHO\",\n        \"focus\": -0.8418551162334328,\n        \"gap\": 6.962614333266799\n      },\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          -69.68740859223726,\n          65.87860410965993\n        ],\n        [\n          -272.1094712799886,\n          94.31865813977868\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 53,\n      \"versionNonce\": 1803962459,\n      \"isDeleted\": false,\n      \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1050.575099048673,\n      \"y\": 297.96357160200637,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 170.765625,\n      \"height\": 36.8,\n      \"seed\": 1046069109,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"sends events like push, \\ntag, ...\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"originalText\": \"sends events like push, tag, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 487,\n      \"versionNonce\": 335895291,\n      \"isDeleted\": false,\n      \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 792.0835609101814,\n      \"y\": 316.38601649373913,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 176.92139414789008,\n      \"height\": 122.73778943055902,\n      \"seed\": 1681656021,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yvJTQ64RU50N6-hxEQlkl\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"UczUX5VuNnCB1rVvUJVfm\",\n        \"focus\": -0.03867359238356983,\n        \"gap\": 4.489845092359474\n      },\n      \"endBinding\": {\n        \"elementId\": \"PbSQXehWVLYcQGXYFpd-B\",\n        \"focus\": 0.7798878042817562,\n        \"gap\": 2.707370547890605\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          60.422360349016344,\n          -71.97786730696657\n        ],\n        [\n          176.92139414789008,\n          -122.73778943055902\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 62,\n      \"versionNonce\": 301106427,\n      \"isDeleted\": false,\n      \"id\": \"yvJTQ64RU50N6-hxEQlkl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 773.7910775091977,\n      \"y\": 226.00814918677256,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 157.4296875,\n      \"height\": 36.8,\n      \"seed\": 500049461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"allows users to login \\nusing existing account\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"originalText\": \"allows users to login using existing account\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 393,\n      \"versionNonce\": 598459861,\n      \"isDeleted\": false,\n      \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 936.9267543177084,\n      \"y\": 458.95033086418084,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 215.17788326846676,\n      \"height\": 93.99151368376693,\n      \"seed\": 234198933,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"rFf6NIofw6UBOyAFwg0Kn\"\n        }\n      ],\n      \"updated\": 1697530127259,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.30339107267010673,\n        \"gap\": 1\n      },\n      \"endBinding\": {\n        \"elementId\": \"hSrrwwnm9y7R-_CnJtaK1\",\n        \"focus\": -0.14057158065513534,\n        \"gap\": 3.4728449093634026\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          130.0760301643047,\n          42.90930518030268\n        ],\n        [\n          215.17788326846676,\n          93.99151368376693\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 8,\n      \"versionNonce\": 1693330843,\n      \"isDeleted\": false,\n      \"id\": \"rFf6NIofw6UBOyAFwg0Kn\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 997.4942845557462,\n      \"y\": 473.9409015069133,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 1592253685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 270,\n      \"versionNonce\": 1855882619,\n      \"isDeleted\": false,\n      \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 886.0581619083632,\n      \"y\": 485.67004123832135,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 174.09447592006472,\n      \"height\": 326.4905563076211,\n      \"seed\": 1479177813,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"apyMCAv2GIN_yzHXwX4tY\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.1341191028023529,\n        \"gap\": 1.9024338988657519\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"focus\": -0.7088661407505865,\n        \"gap\": 4.060573862784622\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          44.14165353942735,\n          196.18483635907205\n        ],\n        [\n          174.09447592006472,\n          326.4905563076211\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 66,\n      \"versionNonce\": 2007745083,\n      \"isDeleted\": false,\n      \"id\": \"apyMCAv2GIN_yzHXwX4tY\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 849.4927841977906,\n      \"y\": 663.4548775973934,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 882041781,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 347,\n      \"versionNonce\": 1353818811,\n      \"isDeleted\": false,\n      \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 534.9278465333664,\n      \"y\": 595.2199151317081,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 113.88020415193023,\n      \"height\": 119.81968366814112,\n      \"seed\": 944153877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"_A8uznhnpXuQBYzjP-iVx\",\n        \"focus\": 0.5397285671082249,\n        \"gap\": 1\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          113.88020415193023,\n          -119.81968366814112\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 61,\n      \"versionNonce\": 1099141979,\n      \"isDeleted\": false,\n      \"id\": \"j56ZKRwmXk72nHrZzLz_1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1081.8110514012087,\n      \"y\": 652.5253283508498,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 566.7373014532342,\n      \"height\": 68.58600908319681,\n      \"seed\": 112933493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 82,\n      \"versionNonce\": 1879994363,\n      \"isDeleted\": false,\n      \"id\": \"cAVYXfBRnfuGAv7QTQVow\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1300.6584159706863,\n      \"y\": 658.8425033454967,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 77.83203125,\n      \"height\": 23,\n      \"seed\": 951460821,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Backend\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Backend\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 376,- add some images explaining the architecture & terminology with\npipeline -> workflow -> step\n- combine advanced config usage\n- rename pipeline syntax to workflow syntax (and most references to\npipeline steps etc as well)\n- update agent registration part\n- add bug note to secrets encryption setting\n- remove usage from readme to point to up-to-date docs page\n- typos\n- closes #1408\n\n---------\n      \"angle\": 0,\n      \"x\": 1094.1972977313717,\n      \"y\": 681.8988272758752,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530.9453125,\n      \"height\": 55.199999999999996,\n      \"seed\": 843899189,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 50\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 384,\n      \"versionNonce\": 1778969915,\n      \"isDeleted\": false,\n      \"id\": \"pxF49EKDNO6IZq_34i7bY\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1064.2132116912126,\n      \"y\": 754.5018564383092,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 954528405,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 154,\n      \"versionNonce\": 1988988379,\n      \"isDeleted\": false,\n      \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 904.0288881242177,\n      \"y\": 882.4966027880746,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.83070714434325,\n      \"height\": 32.735025983189644,\n      \"seed\": 1228134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yNxAOEPZu_Jl7mnI01OXs\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"xmz4J-rxLIjfUQ4q19PjD\",\n        \"gap\": 1.8048677977312764,\n        \"focus\": 0.31250963573550006\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"gap\": 1.353616422651612,\n        \"focus\": 0.36496042109885213\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          158.83070714434325,\n          -32.735025983189644\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 25,\n      \"versionNonce\": 1393410779,\n      \"isDeleted\": false,\n      \"id\": \"yNxAOEPZu_Jl7mnI01OXs\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 963.8856479463893,\n      \"y\": 856.9290897964797,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 39.1171875,\n      \"height\": 18.4,\n      \"seed\": 759107925,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113387,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"starts\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"originalText\": \"starts\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 187,\n      \"versionNonce\": 671224603,\n      \"isDeleted\": false,\n      \"id\": \"sSj4Pda-fo-BBYM_dzml6\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1296.0854928322988,\n      \"y\": 776.6118140041631,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 104.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1381768885,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/15-terminology/index.md",
    "content": "# Terminology\n\n## Glossary\n\n- **Woodpecker CI**: The project name around Woodpecker.\n- **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code.\n- **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration.\n- **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC.\n- **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines.\n- **Pipeline**: A sequence of [workflows][Workflow] that are executed on the code. [Pipelines][Pipeline] are triggered by events.\n- **Workflow**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each [workflow][Workflow] has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker).\n- **Steps**: Individual commands, actions or tasks within a [workflow][Workflow].\n- **Code**: Refers to the files tracked by the version control system used by the [forge][Forge].\n- **Repos**: Short for repositories, these are storage locations where code is stored.\n- **Forge**: The hosting platform or service where the repositories are hosted.\n- **Workspace**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps.\n- **Event**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI.\n- **Commit**: A defined state of the code, usually associated with a version control system like Git.\n- **Matrix**: A configuration option that allows the execution of [workflows][Workflow] for each value in the [matrix][Matrix].\n- **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow].\n- **Plugins**: [Plugins][Plugin] are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings.\n- **Container**: A lightweight and isolated environment where commands are executed.\n- **YAML File**: A file format used to define and configure [workflows][Workflow].\n- **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel.\n- **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge].\n- **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions.\n\n## Woodpecker architecture\n\n![Woodpecker architecture](architecture.svg)\n\n## Pipeline, workflow & step\n\n![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg)\n\n## Pipeline events\n\n- `push`: A push event is triggered when a commit is pushed to a branch.\n- `pull_request`: A pull request event is triggered when a pull request is opened or a new commit is pushed to it.\n- `pull_request_closed`: A pull request closed event is triggered when a pull request is closed or merged.\n- `tag`: A tag event is triggered when a tag is pushed.\n- `release`: A release event is triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](../20-workflow-syntax.md#evaluate) with [environment variables](../50-environment.md#built-in-environment-variables).)\n- `manual`: A manual event is triggered when a user manually triggers a pipeline.\n- `cron`: A cron event is triggered when a cron job is executed.\n\n## Conventions\n\nSometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker:\n\n- Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()`\n- Use the term **pipelines** instead of the previous **builds**\n- Use the term **steps** instead of the previous **jobs**\n- Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users\n\n<!-- References -->\n\n[Pipeline]: ../20-workflow-syntax.md\n[Workflow]: ../25-workflows.md\n[Forge]: ../../30-administration/11-forges/11-overview.md\n[Plugin]: ../51-plugins/51-overview.md\n[Workspace]: ../20-workflow-syntax.md#workspace\n[Matrix]: ../30-matrix-workflows.md\n[Docker]: ../../30-administration/22-backends/10-docker.md\n[Local]: ../../30-administration/22-backends/20-local.md\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/15-terminology/pipeline-workflow-step.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 97,\n      \"versionNonce\": 257762037,\n      \"isDeleted\": false,\n      \"id\": \"Y3hYdpX9r1qWfyHWs7AXT\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 393.622323134362,\n      \"y\": 336.02197155458475,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 366.3936710429598,\n      \"height\": 499.95605689083004,\n      \"seed\": 875444373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 67,\n      \"versionNonce\": 369556565,\n      \"isDeleted\": false,\n      \"id\": \"g1Eb010Kx_KFryVqNYWBQ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 520.0116988873679,\n      \"y\": 363.32095846456355,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 99.626953125,\n      \"height\": 32.199999999999996,\n      \"seed\": 1466195445,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Pipeline\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 314,\n      \"versionNonce\": 1983028731,\n      \"isDeleted\": false,\n      \"id\": \"9o-DNP0YdlIGVz1kEm_hW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 407.1590381712276,\n      \"y\": 410.9252244837219,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1869535061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 156,\n      \"versionNonce\": 1495247317,\n      \"isDeleted\": false,\n      \"id\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 490.3194993196821,\n      \"y\": 473.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 111355061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"ya0JzDo-4oscHIq87TZ_D\"\n        },\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 156,\n      \"versionNonce\": 1469425461,\n      \"isDeleted\": false,\n      \"id\": \"ya0JzDo-4oscHIq87TZ_D\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 566.0118821321821,\n      \"y\": 478.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1084671509,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 236,\n      \"versionNonce\": 1535319541,\n      \"isDeleted\": false,\n      \"id\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 491.2218643672577,\n      \"y\": 519.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 812596085,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"FRby8A9aUiKvHpM5mCdDN\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 231,\n      \"versionNonce\": 28677973,\n      \"isDeleted\": false,\n      \"id\": \"FRby8A9aUiKvHpM5mCdDN\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 583.0324112422577,\n      \"y\": 524.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1849820373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 291,\n      \"versionNonce\": 571598005,\n      \"isDeleted\": false,\n      \"id\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 489.6426911083554,\n      \"y\": 567.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1840554549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"UOwxmKIS0W62CFt_ffEy4\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 4032021,\n      \"isDeleted\": false,\n      \"id\": \"UOwxmKIS0W62CFt_ffEy4\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 581.4532379833554,\n      \"y\": 572.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 330077077,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 296,\n      \"versionNonce\": 1539516059,\n      \"isDeleted\": false,\n      \"id\": \"9laL3864YWOna6NQlVDqq\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 630.0635849044402,\n      \"y\": 383.14314287821776,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 294.3024370154917,\n      \"height\": 36.656016722015465,\n      \"seed\": 207575285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -1.000156025347643,\n        \"gap\": 27.782081605504118\n      },\n      \"endBinding\": {\n        \"elementId\": \"vS2PNUbmeBe3EPxl-dID8\",\n        \"focus\": 0.7761987167055517,\n        \"gap\": 8.978940924346716\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          294.3024370154917,\n          -36.656016722015465\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 249,\n      \"versionNonce\": 2076402229,\n      \"isDeleted\": false,\n      \"id\": \"vS2PNUbmeBe3EPxl-dID8\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 933.3449628442786,\n      \"y\": 336.02200598023114,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 301.298828125,\n      \"height\": 46,\n      \"seed\": 1632793173,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 751,\n      \"versionNonce\": 1371044827,\n      \"isDeleted\": false,\n      \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 751.1619011845514,\n      \"y\": 440.8355079324799,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 160.46519124360202,\n      \"height\": 2.2452348338335923,\n      \"seed\": 1331388341,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -0.6591700594229558,\n        \"gap\": 3.8807513696519322\n      },\n      \"endBinding\": {\n        \"elementId\": \"wfFvnFZuh0npL9hh0ez7o\",\n        \"focus\": 0.7652411053273549,\n        \"gap\": 20.75618622779257\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          160.46519124360202,\n          -2.2452348338335923\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 819540565,\n      \"isDeleted\": false,\n      \"id\": \"TbejdIYo_qNDw15yLP2IB\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 406.0812257713851,\n      \"y\": 626.8305540252475,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1553965333,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 466,\n      \"versionNonce\": 663477,\n      \"isDeleted\": false,\n      \"id\": \"wfFvnFZuh0npL9hh0ez7o\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 932.383278655946,\n      \"y\": 424.0107569968011,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 481.2890625,\n      \"height\": 115,\n      \"seed\": 781497973,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 110\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 464,\n      \"versionNonce\": 734626075,\n      \"isDeleted\": false,\n      \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 741.0645380446722,\n      \"y\": 492.31283255558515,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 178.4459423531871,\n      \"height\": 83.08707392565111,\n      \"seed\": 536879061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"q4TKpiq2KAwPaz19GdhtK\",\n        \"focus\": -0.7697471991854113,\n        \"gap\": 3.7450387249900814\n      },\n      \"endBinding\": {\n        \"elementId\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n        \"focus\": -0.7822252364700005,\n        \"gap\": 8.360835317635974\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          178.4459423531871,\n          83.08707392565111\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 327,\n      \"versionNonce\": 371646421,\n      \"isDeleted\": false,\n      \"id\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 927.8713157154953,\n      \"y\": 563.2132686484658,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 491.357421875,\n      \"height\": 46,\n      \"seed\": 385310005,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 91,\n      \"versionNonce\": 1180085909,\n      \"isDeleted\": false,\n      \"id\": \"0tGx2VdJLNf7W6HD76dtO\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 427.6895298601876,\n      \"y\": 432.3583566254258,\n      \"strokeColor\": \"#9c36b5\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 143.876953125,\n      \"height\": 23,\n      \"seed\": 450883221,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"build\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"build\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 338,\n      \"versionNonce\": 957223925,\n      \"isDeleted\": false,\n      \"id\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.7251825950889,\n      \"y\": 685.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 711939061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"8EqaPnZX2CgLaF08UNZZg\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 340,\n      \"versionNonce\": 510774613,\n      \"isDeleted\": false,\n      \"id\": \"8EqaPnZX2CgLaF08UNZZg\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 563.4175654075889,\n      \"y\": 690.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1370164565,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 421,\n      \"versionNonce\": 97999541,\n      \"isDeleted\": false,\n      \"id\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 488.62754764266447,\n      \"y\": 731.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 2145950389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"DX10t075MMDu7BLtuUaij\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 417,\n      \"versionNonce\": 2011446293,\n      \"isDeleted\": false,\n      \"id\": \"DX10t075MMDu7BLtuUaij\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 580.4380945176645,\n      \"y\": 736.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 500005909,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 475,\n      \"versionNonce\": 1284370805,\n      \"isDeleted\": false,\n      \"id\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.04837438376217,\n      \"y\": 779.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1666134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"-xogFSFcP-Vv5cuOSFm8T\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 476,\n      \"versionNonce\": 1092221653,\n      \"isDeleted\": false,\n      \"id\": \"-xogFSFcP-Vv5cuOSFm8T\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 578.8589212587622,\n      \"y\": 784.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1840462549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 125,\n      \"versionNonce\": 1310578741,\n      \"isDeleted\": false,\n      \"id\": \"N1a9yL7Pts16hUKY9-vhw\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 424.78852030984035,\n      \"y\": 646.2446482189896,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 133.857421875,\n      \"height\": 23,\n      \"seed\": 361699381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"test\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"test\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 184,\n      \"versionNonce\": 2127603131,\n      \"isDeleted\": false,\n      \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 737.454940151797,\n      \"y\": 535.9141784615474,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 190.41665096887027,\n      \"height\": 112.96427727851824,\n      \"seed\": 80234901,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.8392895251910331,\n        \"gap\": 2.0300115262207328\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          190.41665096887027,\n          112.96427727851824\n        ]\n      ]\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 327,\n      \"versionNonce\": 780710651,\n      \"isDeleted\": false,\n      \"id\": \"379hO6Dc5rygB38JgDbVo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 738.8084877231549,\n      \"y\": 591.3526691276127,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 186.8066399682357,\n      \"height\": 57.68023784868956,\n      \"seed\": 211046133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"2WwuMWX7YawqK0i1rDPJo\",\n        \"focus\": -0.5776522830934517,\n        \"gap\": 2.1657966147995467\n      },\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.7269489945238884,\n        \"gap\": 4.286474955497397\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          186.8066399682357,\n          57.68023784868956\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 285,\n      \"versionNonce\": 1165977685,\n      \"isDeleted\": false,\n      \"id\": \"0TjxOfERekC91N3yciQIq\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 929.901602646888,\n      \"y\": 632.4760859429873,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 518.076171875,\n      \"height\": 46,\n      \"seed\": 997763157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/20-workflow-syntax.md",
    "content": "# Workflow syntax\n\nThe Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status.\n\n:::note\nAn exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run.\n:::\n\n:::note\nWe support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility.\nRead more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3)\n:::\n\nExample steps:\n\n```yaml\nsteps:\n  - name: backend\n    image: golang\n    commands:\n      - go build\n      - go test\n  - name: frontend\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\nIn the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary.\n\nThe name is optional, if not added the steps will be numerated.\n\nAnother way to name a step is by using dictionaries:\n\n```yaml\nsteps:\n  backend:\n    image: golang\n    commands:\n      - go build\n      - go test\n  frontend:\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\n## Skip Commits\n\nWoodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive.\n\n```bash\ngit commit -m \"updated README [CI SKIP]\"\n```\n\n## Steps\n\nEvery step of your workflow executes commands inside a specified container.<br>\nThe defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).<br>\nThe associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\n### File changes are incremental\n\n- Woodpecker clones the source code in the beginning of the workflow\n- Changes to files are persisted through steps as the same volume is mounted to all steps\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"test content\" > myfile\n  - name: a-test-step\n    image: debian\n    commands:\n      - cat myfile\n```\n\n### `image`\n\nWoodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers.\n\nWhen using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands.\n\n```diff\n steps:\n   - name: build\n+    image: golang:1.6\n     commands:\n       - go build\n       - go test\n\n   - name: publish\n+    image: plugins/docker\n     repo: foo/bar\n\n services:\n   - name: database\n+    image: mysql\n```\n\nWoodpecker supports any valid Docker image from any Docker registry:\n\n```yaml\nimage: golang\nimage: golang:1.7\nimage: library/golang:1.7\nimage: index.docker.io/library/golang\nimage: index.docker.io/library/golang:1.7\n```\n\nWoodpecker does not automatically upgrade container images. Example configuration to always pull the latest image when updates are available:\n\n```diff\n steps:\n   - name: build\n     image: golang:latest\n+    pull: true\n```\n\nLearn more how you can use images from [different registries](./41-registries.md).\n\n### `commands`\n\nCommands of every step are executed serially as if you would enter them into your local shell.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\nThere is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script:\n\n```bash\n#!/bin/sh\nset -e\n\ngo build\ngo test\n```\n\nThe above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed:\n\n```bash\ndocker run --entrypoint=build.sh golang\n```\n\n:::note\nOnly build steps can define commands. You cannot use commands with plugins or services.\n:::\n\n### `entrypoint`\n\nAllows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `[\"/bin/sh\", \"-c\"]`).\n\nIf you define [`commands`](#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\nYou can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`.\n\n### `environment`\n\nWoodpecker provides the ability to pass environment variables to individual steps.\n\nFor more details, check the [environment docs](./50-environment.md).\n\n### `secrets`\n\nWoodpecker provides the ability to store named parameters external to the YAML configuration file, in a central secret store. These secrets can be passed to individual steps of the workflow at runtime.\n\nFor more details, check the [secrets docs](./40-secrets.md).\n\n### `failure`\n\nSome of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n       - go build\n       - go test\n+    failure: ignore\n```\n\n### `when` - Conditional Execution\n\nWoodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ subconditions are true.\nA condition can be a check like:\n\n```diff\n steps:\n   - name: slack\n     image: plugins/slack\n     settings:\n       channel: dev\n+    when:\n+      - event: pull_request\n+        repo: test/test\n+      - event: push\n+        branch: main\n```\n\nThe `slack` step is executed if one of these conditions is met:\n\n1. The pipeline is executed from a pull request in the repo `test/test`\n2. The pipeline is executed from a push to `maiǹ`\n\n#### `repo`\n\nExample conditional execution by repository:\n\n```diff\n steps:\n   - name: slack\n     image: plugins/slack\n     settings:\n       channel: dev\n+    when:\n+      - repo: test/test\n```\n\n#### `branch`\n\n:::note\nBranch conditions are not applied to tags.\n:::\n\nExample conditional execution by branch:\n\n```diff\n steps:\n   - name: slack\n     image: plugins/slack\n     settings:\n       channel: dev\n+    when:\n+      - branch: main\n```\n\n> The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only.\n\nExecute a step if the branch is `main` or `develop`:\n\n```yaml\nwhen:\n  - branch: [main, develop]\n```\n\nExecute a step if the branch starts with `prefix/*`:\n\n```yaml\nwhen:\n  - branch: prefix/*\n```\n\nThe branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples:\n\n- `*\\\\/*` to match patterns with exactly 1 `/`\n- `*\\\\/**` to match patters with at least 1 `/`\n- `*` to match patterns without `/`\n- `**` to match everything\n\nExecute a step using custom include and exclude logic:\n\n```yaml\nwhen:\n  - branch:\n      include: [main, release/*]\n      exclude: [release/1.0.0, release/1.1.*]\n```\n\n#### `event`\n\nAvailable events: `push`, `pull_request`, `pull_request_closed`, `tag`, `release`, `deployment`, `cron`, `manual`\n\nExecute a step if the build event is a `tag`:\n\n```yaml\nwhen:\n  - event: tag\n```\n\nExecute a step if the pipeline event is a `push` to a specified branch:\n\n```diff\nwhen:\n  - event: push\n+   branch: main\n```\n\nExecute a step for multiple events:\n\n```yaml\nwhen:\n  - event: [push, tag, deployment]\n```\n\n#### `cron`\n\nThis filter **only** applies to cron events and filters based on the name of a cron job.\n\nMake sure to have a `event: cron` condition in the `when`-filters as well.\n\n```yaml\nwhen:\n  - event: cron\n    cron: sync_* # name of your cron job\n```\n\n[Read more about cron](./45-cron.md)\n\n#### `ref`\n\nThe `ref` filter compares the git reference against which the workflow is executed.\nThis allows you to filter, for example, tags that must start with **v**:\n\n```yaml\nwhen:\n  - event: tag\n    ref: refs/tags/v*\n```\n\n#### `status`\n\nThere are use cases for executing steps on failure, such as sending notifications for failed workflow / pipeline. Use the status constraint to execute steps even when the workflow fails:\n\n```diff\n steps:\n   - name: slack\n     image: plugins/slack\n     settings:\n       channel: dev\n+    when:\n+      - status: [ success, failure ]\n```\n\n#### `platform`\n\n:::note\nThis condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch.\n:::\n\nExecute a step for a specific platform:\n\n```yaml\nwhen:\n  - platform: linux/amd64\n```\n\nExecute a step for a specific platform using wildcards:\n\n```yaml\nwhen:\n  - platform: [linux/*, windows/amd64]\n```\n\n#### `matrix`\n\nExecute a step for a single matrix permutation:\n\n```yaml\nwhen:\n  - matrix:\n      GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n```\n\n#### `instance`\n\nExecute a step only on a certain Woodpecker instance matching the specified hostname:\n\n```yaml\nwhen:\n  - instance: stage.woodpecker.company.com\n```\n\n#### `path`\n\n:::info\nPath conditions are applied only to **push** and **pull_request** events.\nIt is currently **only available** for GitHub, GitLab and Gitea (version 1.18.0 and newer)\n:::\n\nExecute a step only on a pipeline with certain files being changed:\n\n```yaml\nwhen:\n  - path: 'src/*'\n```\n\nYou can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`.\n\nFor pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases.\n\n```yaml\nwhen:\n  - path:\n      include: ['.woodpecker/*.yaml', '*.ini']\n      exclude: ['*.md', 'docs/**']\n      ignore_message: '[ALL]'\n      on_empty: true\n```\n\n:::info\nPassing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting.\n:::\n\n#### `evaluate`\n\nExecute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression.\n\nThe expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library.\n\nRun on pushes to the default branch for the repository `owner/repo`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'\n```\n\nRun on commits created by user `woodpecker-ci`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n```\n\nSkip all commits containing `please ignore me` in the commit message:\n\n```yaml\nwhen:\n  - evaluate: 'not (CI_COMMIT_MESSAGE contains \"please ignore me\")'\n```\n\nRun on pull requests with the label `deploy`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"deploy\"'\n```\n\nSkip step only if `SKIP=true`, run otherwise or if undefined:\n\n```yaml\nwhen:\n  - evaluate: 'SKIP != \"true\"'\n```\n\n### `depends_on`\n\nNormally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`:\n\n```diff\n steps:\n   - name: build # build will be executed immediately\n     image: golang\n     commands:\n       - go build\n\n   - name: deploy\n     image: plugins/docker\n     settings:\n       repo: foo/bar\n+    depends_on: [build, test] # deploy will be executed after build and test finished\n\n   - name: test # test will be executed immediately as no dependencies are set\n     image: golang\n     commands:\n       - go test\n```\n\n:::note\nYou can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified.\n\n```yaml\nsteps:\n  - name: check code format\n    image: mstruebing/editorconfig-checker\n    depends_on: [] # enable parallel steps\n  ...\n```\n\n:::\n\n### `volumes`\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\nFor more details check the [volumes docs](./70-volumes.md).\n\n### `detach`\n\nWoodpecker gives the ability to detach steps to run them in background until the workflow finishes.\n\nFor more details check the [service docs](./60-services.md#detachment).\n\n### `directory`\n\nUsing `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run.\n\n## `services`\n\nWoodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow.\n\nFor more details check the [services docs](./60-services.md).\n\n## `workspace`\n\nThe workspace defines the shared volume and working directory shared by all workflow steps.\nThe default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`).\nSo an example would be `/woodpecker/src/github.com/octocat/hello-world`.\n\nThe workspace can be customized using the workspace block in the YAML file:\n\n```diff\n+workspace:\n+  base: /go\n+  path: src/github.com/octocat/hello-world\n\n steps:\n   - name: build\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n```\n\n:::note\nPlugins will always have the workspace base at `/woodpecker`\n:::\n\nThe base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps.\n\n```diff\n workspace:\n+  base: /go\n   path: src/github.com/octocat/hello-world\n\n steps:\n   - name: deps\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n   - name: build\n     image: node:latest\n     commands:\n       - go build\n```\n\nThis would be equivalent to the following docker commands:\n\n```bash\ndocker volume create my-named-volume\n\ndocker run --volume=my-named-volume:/go golang:latest\ndocker run --volume=my-named-volume:/go node:latest\n```\n\nThe path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path.\n\n```diff\n workspace:\n   base: /go\n+  path: src/github.com/octocat/hello-world\n```\n\n```bash\ngit clone https://github.com/octocat/hello-world \\\n  /go/src/github.com/octocat/hello-world\n```\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `matrix`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations.\n\nFor more details check the [matrix build docs](./30-matrix-workflows.md).\n\n## `labels`\n\nYou can set labels for your workflow to select an agent to execute the workflow on. An agent will pick up and run a workflow when **every** label assigned to it matches the agents labels.\n\nTo set additional agent labels, check the [agent configuration options](../30-administration/15-agent-config.md#woodpecker_filter_labels). Agents will have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of the agent backend) and `repo=*`. Agents can use a `*` as a wildcard for a label. For example `repo=*` will match every repo.\n\nWorkflow labels with an empty value will be ignored.\nBy default, each workflow has at least the `repo=your-user/your-repo-name` label. If you have set the [platform attribute](#platform) for your workflow it will have a label like `platform=your-os/your-arch` as well.\n\nYou can add additional labels as a key value map:\n\n```diff\n+labels:\n+  location: europe # only agents with `location=europe` or `location=*` will be used\n+  weather: sun\n+  hostname: \"\" # this label will be ignored as it is empty\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\n### Filter by platform\n\nTo configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key.\nHave a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`.\n\nExample:\n\nAssuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`.\n\n```diff\n+labels:\n+  platform: linux/arm64\n\n steps:\n   [...]\n```\n\n## `variables`\n\nWoodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration.\n\nFor more details and examples check the [Advanced usage docs](./90-advanced-usage.md)\n\n## `clone`\n\nWoodpecker automatically configures a default clone step if not explicitly defined. When using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be on your `$PATH` for the default clone step to work. If not, you can still write a manual clone step.\n\nYou can manually configure the clone step in your workflow for customization:\n\n```diff\n+clone:\n+  git:\n+    image: woodpeckerci/plugin-git\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\nExample configuration to override depth:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n+    settings:\n+      partial: false\n+      depth: 50\n```\n\nExample configuration to use a custom clone plugin:\n\n```diff\n clone:\n   - name: git\n+    image: octocat/custom-git-plugin\n```\n\nExample configuration to clone Mercurial repository:\n\n```diff\n clone:\n   - name: hg\n+    image: plugins/hg\n+    settings:\n+      path: bitbucket.org/foo/bar\n```\n\n### Git Submodules\n\nTo use the credentials that cloned the repository to clone it's submodules, update `.gitmodules` to use `https` instead of `git`:\n\n```diff\n [submodule \"my-module\"]\n path = my-module\n-url = git@github.com:octocat/my-module.git\n+url = https://github.com/octocat/my-module.git\n```\n\nTo use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n     settings:\n       recursive: true\n+      submodule_override:\n+        my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  ...\n```\n\n## `skip_clone`\n\nBy default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using:\n\n```yaml\nskip_clone: true\n```\n\n## `when` - Global workflow conditions\n\nWoodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue.\n\nFor more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution).\n\nExample conditional execution by branch:\n\n```diff\n+when:\n+  branch: main\n+\n steps:\n   - name: slack\n     image: plugins/slack\n     settings:\n       channel: dev\n```\n\nThe workflow now triggers on `main`, but also if the target branch of a pull request is `main`.\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `depends_on`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword.\n\n## `runs_on`\n\nWorkflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example.\n\n## Privileged mode\n\nWoodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities.\n\n:::info\nPrivileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     environment:\n       - DOCKER_HOST=tcp://docker:2375\n     commands:\n       - docker --tls=false ps\n\n services:\n   - name: docker\n     image: docker:dind\n     commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false\n+    privileged: true\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/25-workflows.md",
    "content": "# Workflows\n\nA pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps.\n\nIn case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow.\n\nBy placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored.\n\nYou can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md).\n\n## Benefits of using workflows\n\n- faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote\n- better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying\n- utilizing more agents to speed up the execution of the whole pipeline\n\n## Example workflow definition\n\n:::warning\nPlease note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow.\nIf you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket).\n:::\n\n```bash\n.woodpecker/\n├── .build.yaml\n├── .deploy.yaml\n├── .lint.yaml\n└── .test.yaml\n```\n\n```yaml title=\".woodpecker/.build.yaml\"\nsteps:\n  - name: build\n    image: debian:stable-slim\n    commands:\n      - echo building\n      - sleep 5\n```\n\n```yaml title=\".woodpecker/.deploy.yaml\"\nsteps:\n  - name: deploy\n    image: debian:stable-slim\n    commands:\n      - echo deploying\n\ndepends_on:\n  - lint\n  - build\n  - test\n```\n\n```yaml title=\".woodpecker/.test.yaml\"\nsteps:\n  - name: test\n    image: debian:stable-slim\n    commands:\n      - echo testing\n      - sleep 5\n\ndepends_on:\n  - build\n```\n\n```yaml title=\".woodpecker/.lint.yaml\"\nsteps:\n  - name: lint\n    image: debian:stable-slim\n    commands:\n      - echo linting\n      - sleep 5\n```\n\n## Status lines\n\nEach workflow will report its own status back to your forge.\n\n## Flow control\n\nThe workflows run in parallel on separate agents and share nothing.\n\nDependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully.\n\nThe name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`.\n\n```diff\n steps:\n   - name: deploy\n     image: debian:stable-slim\n     commands:\n       - echo deploying\n\n+depends_on:\n+  - lint\n+  - build\n+  - test\n```\n\nWorkflows that need to run even on failures should set the `runs_on` tag.\n\n```diff\n steps:\n   - name: notify\n     image: debian:stable-slim\n     commands:\n       - echo notifying\n\n depends_on:\n   - deploy\n\n+runs_on: [ success, failure ]\n```\n\n:::info\nSome workflows don't need the source code, like creating a notification on failure.\nRead more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone)\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/30-matrix-workflows.md",
    "content": "# Matrix workflows\n\nWoodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations.\n\n:::warning\nWoodpecker currently supports a maximum of **27 matrix axes** per workflow.\nIf your matrix exceeds this number, any additional axes will be silently ignored.\n:::\n\nExample matrix definition:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  REDIS_VERSION:\n    - 2.6\n    - 2.8\n    - 3.0\n```\n\nExample matrix definition containing only specific combinations:\n\n```yaml\nmatrix:\n  include:\n    - GO_VERSION: 1.4\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.6\n      REDIS_VERSION: 3.0\n```\n\n## Interpolation\n\nMatrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:8\n    - mysql:5\n    - mariadb:10.1\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go get\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: ${DATABASE}\n```\n\nExample YAML file after injecting the matrix parameters:\n\n```diff\n steps:\n   - name: build\n-    image: golang:${GO_VERSION}\n+    image: golang:1.4\n     commands:\n       - go get\n       - go build\n       - go test\n+    environment:\n+      - GO_VERSION=1.4\n+      - DATABASE=mysql:8\n\n services:\n   - name: database\n-    image: ${DATABASE}\n+    image: mysql:8\n```\n\n## Examples\n\n### Example matrix pipeline based on Docker image tag\n\n```yaml\nmatrix:\n  TAG:\n    - 1.7\n    - 1.8\n    - latest\n\nsteps:\n  - name: build\n    image: golang:${TAG}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline based on container image\n\n```yaml\nmatrix:\n  IMAGE:\n    - golang:1.7\n    - golang:1.8\n    - golang:latest\n\nsteps:\n  - name: build\n    image: ${IMAGE}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline using multiple platforms\n\n```yaml\nmatrix:\n  platform:\n    - linux/amd64\n    - linux/arm64\n\nlabels:\n  platform: ${platform}\n\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n\n  - name: test-arm-only\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n      - echo \"Arm is cool!\"\n    when:\n      platform: linux/arm*\n```\n\n:::note\nIf you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/22-backends/40-kubernetes.md#node-selector).\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/40-secrets.md",
    "content": "# Secrets\n\nWoodpecker provides the ability to store named parameters external to the YAML configuration file, in a central secret store. These secrets can be passed to individual steps of the pipeline at runtime.\n\nWoodpecker provides three different levels to add secrets to your pipeline. The following list shows the priority of the different levels. If a secret is defined in multiple levels, will be used following this priorities: Repository secrets > Organization secrets > Global secrets.\n\n1. **Repository secrets**: They are available to all pipelines of an repository.\n2. **Organization secrets**: They are available to all pipelines of an organization.\n3. **Global secrets**: Can be configured by an instance admin.\n   They are available to all pipelines of the **whole** Woodpecker instance and should therefore **only** be used for secrets that are allowed to be read by **all** users.\n\n## Usage\n\n### Use secrets in commands\n\n:::warning\nThe use of secrets is deprecated as of version 2.8 and planned to be removed with version 3.\nInstead, you can use the _secrets in settings and environment_ approach outlined below.\nYou can already migrate to this strategy with version 2.8.\n:::\n\nSecrets are exposed to your pipeline steps and plugins as uppercase environment variables and can therefore be referenced in the commands section of your pipeline,\nonce their usage is declared in the `secrets` section:\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n+      - echo $docker_username\n+      - echo $DOCKER_PASSWORD\n+    secrets: [ docker_username, DOCKER_PASSWORD ]\n```\n\nThe case of the environment variables is not changed, but secret matching is done case-insensitively. In the example above, `DOCKER_PASSWORD` would also match if the secret is called `docker_password`.\n\n### Use secrets in settings and environment\n\nYou can set a setting or environment value from secrets using the `from_secret` syntax.\n\nThe example below passes a secret called `secret_token` as an environment variable that will be called `TOKEN_ENV`:\n\n```diff\n steps:\n   env-secret-example:\n     image: alpine\n     commands:\n+      - echo \"The secret is $TOKEN_ENV\"\n+    environment:\n+      TOKEN_ENV:\n+        from_secret: secret_token\n```\n\nYou can use the same syntax to pass secrets to settings. For example, you can pass a secret named `secret_token` to the settings called `token`, which will then be available in the plugin as environment variable named `PLUGIN_TOKEN` (See [plugins](./51-plugins/20-creating-plugins.md#settings) for details).\n\n```diff\n steps:\n   - name: settings-secret-example\n     image: my-plugin\n+    settings:\n+      token:\n+        from_secret: secret_token\n```\n\n### Note about parameter pre-processing\n\nPlease note parameter expressions are subject to pre-processing. When using secrets in parameter expressions they should be escaped.\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n-      - echo ${docker_username}\n-      - echo ${DOCKER_PASSWORD}\n+      - echo $${docker_username}\n+      - echo $${DOCKER_PASSWORD}\n     secrets: [ docker_username, DOCKER_PASSWORD ]\n```\n\n### Use in Pull Requests events\n\nSecrets are not exposed to pull requests by default. You can override this behavior by creating the secret and enabling the `pull_request` event type, either in UI or by CLI, see below.\n\n:::note\nPlease be careful when exposing secrets to pull requests. If your repository is open source and accepts pull requests your secrets are not safe. A bad actor can submit a malicious pull request that exposes your secrets.\n:::\n\n## Image filter\n\nTo prevent abusing your secrets from malicious usage, you can limit a secret to a list of images. If enabled they are not available to any other plugin (steps without user-defined commands). If you or an attacker defines explicit commands, the secrets will not be available to the container to prevent leaking them.\n\n## Adding Secrets\n\nSecrets are added to the Woodpecker in the UI or with the CLI.\n\n### CLI Examples\n\nCreate the secret using default settings. The secret will be available to all images in your pipeline, and will be available to all push, tag, and deployment events (not pull request events).\n\n```bash\nwoodpecker-cli secret add \\\n  -repository octocat/hello-world \\\n  -name aws_access_key_id \\\n  -value <value>\n```\n\nCreate the secret and limit to a single image:\n\n```diff\n woodpecker-cli secret add \\\n   -repository octocat/hello-world \\\n+  -image plugins/s3 \\\n   -name aws_access_key_id \\\n   -value <value>\n```\n\nCreate the secrets and limit to a set of images:\n\n```diff\n woodpecker-cli secret add \\\n   -repository octocat/hello-world \\\n+  -image plugins/s3 \\\n+  -image peloton/woodpecker-ecs \\\n   -name aws_access_key_id \\\n   -value <value>\n```\n\nCreate the secret and enable for multiple hook events:\n\n```diff\n woodpecker-cli secret add \\\n   -repository octocat/hello-world \\\n   -image plugins/s3 \\\n+  -event pull_request \\\n+  -event push \\\n+  -event tag \\\n   -name aws_access_key_id \\\n   -value <value>\n```\n\nLoading secrets from file using curl `@` syntax. This is the recommended approach for loading secrets from file to preserve newlines:\n\n```diff\n woodpecker-cli secret add \\\n   -repository octocat/hello-world \\\n   -name ssh_key \\\n+  -value @/root/ssh/id_rsa\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/41-registries.md",
    "content": "# Registries\n\nWoodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries.\n\n## Images from private registries\n\nYou must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file.\n\nThese credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin.\n\nExample configuration using a private image:\n\n```diff\n steps:\n   - name: build\n+    image: gcr.io/custom/golang\n     commands:\n       - go build\n       - go test\n```\n\nWoodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers.\n\nExample registry hostnames:\n\n- Image `gcr.io/foo/bar` has hostname `gcr.io`\n- Image `foo/bar` has hostname `docker.io`\n- Image `qux.com:8000/foo/bar` has hostname `qux.com:8000`\n\nExample registry hostname matching logic:\n\n- Hostname `gcr.io` matches image `gcr.io/foo/bar`\n- Hostname `docker.io` matches `golang`\n- Hostname `docker.io` matches `library/golang`\n- Hostname `docker.io` matches `bradyrydzewski/golang`\n- Hostname `docker.io` matches `bradyrydzewski/golang:latest`\n\n:::note\nThe flow above doesn't work in Kubernetes. There is [workaround](../30-administration/22-backends/40-kubernetes.md#images-from-private-registries).\n:::\n\n## Global registry support\n\nTo make a private registry globally available, check the [server configuration docs](../30-administration/10-server-config.md#global-registry-setting).\n\n## GCR registry support\n\nFor specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file).\n\n## Local Images\n\n:::warning\nFor this, privileged rights are needed only available to admins. In addition, this only works when using a single agent.\n:::\n\nIt's possible to build a local image by mounting the docker socket as a volume.\n\nWith a `Dockerfile` at the root of the project:\n\n```yaml\nsteps:\n  - name: build-image\n    image: docker\n    commands:\n      - docker build --rm -t local/project-image .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  - name: build-project\n    image: local/project-image\n    commands:\n      - ./build.sh\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/45-cron.md",
    "content": "# Cron\n\nTo configure cron jobs you need at least push access to the repository.\n\n## Add a new cron job\n\n1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job:\n\n   ```diff\n    steps:\n      - name: sync_locales\n        image: weblate_sync\n        settings:\n          url: example.com\n          token:\n            from_secret: weblate_token\n   +    when:\n   +      event: cron\n   +      cron: \"name of the cron job\" # if you only want to execute this step by a specific cron job\n   ```\n\n2. Create a new cron job in the repository settings:\n\n   ![cron settings](./cron-settings.png)\n\n   The supported schedule syntax can be found at <https://pkg.go.dev/github.com/robfig/cron?utm_source=godoc#hdr-CRON_Expression_Format>. If you need general understanding of the cron syntax <https://it-tools.tech/crontab-generator> is a good place to start and experiment.\n\n   Examples: `@every 5m`, `@daily`, `0 30 * * * *` ...\n\n   :::info\n   Woodpeckers cron syntax starts with seconds instead of minutes as used by most linux cron schedulers.\n\n   Example: \"At minute 30 every hour\" would be `0 30 * * * *` instead of `30 * * * *`\n   :::\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/50-environment.md",
    "content": "# Environment variables\n\nWoodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables:\n\n```diff\n steps:\n   - name: build\n     image: golang\n+    environment:\n+      CGO: 0\n+      GOOS: linux\n+      GOARCH: amd64\n     commands:\n       - go build\n       - go test\n```\n\nPlease note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section.\n\n```diff\n steps:\n   - name: build\n     image: golang\n-    environment:\n-      - PATH=$PATH:/go\n     commands:\n+      - export PATH=$PATH:/go\n       - go build\n       - go test\n```\n\n:::warning\n`${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped:\n:::\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n-      - export PATH=${PATH}:/go\n+      - export PATH=$${PATH}:/go\n       - go build\n       - go test\n```\n\n## Built-in environment variables\n\nThis is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime.\n\n| NAME                             | Description                                                                                                        |\n| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |\n| `CI`                             | CI environment name (value: `woodpecker`)                                                                          |\n|                                  | **Repository**                                                                                                     |\n| `CI_REPO`                        | repository full name `<owner>/<name>`                                                                              |\n| `CI_REPO_OWNER`                  | repository owner                                                                                                   |\n| `CI_REPO_NAME`                   | repository name                                                                                                    |\n| `CI_REPO_REMOTE_ID`              | repository remote ID, is the UID it has in the forge                                                               |\n| `CI_REPO_SCM`                    | repository SCM (git)                                                                                               |\n| `CI_REPO_URL`                    | repository web URL                                                                                                 |\n| `CI_REPO_CLONE_URL`              | repository clone URL                                                                                               |\n| `CI_REPO_CLONE_SSH_URL`          | repository SSH clone URL                                                                                           |\n| `CI_REPO_DEFAULT_BRANCH`         | repository default branch (main)                                                                                   |\n| `CI_REPO_PRIVATE`                | repository is private                                                                                              |\n| `CI_REPO_TRUSTED`                | repository is trusted                                                                                              |\n|                                  | **Current Commit**                                                                                                 |\n| `CI_COMMIT_SHA`                  | commit SHA                                                                                                         |\n| `CI_COMMIT_REF`                  | commit ref                                                                                                         |\n| `CI_COMMIT_REFSPEC`              | commit ref spec                                                                                                    |\n| `CI_COMMIT_BRANCH`               | commit branch (equals target branch for pull requests)                                                             |\n| `CI_COMMIT_SOURCE_BRANCH`        | commit source branch (empty if event is not `pull_request` or `pull_request_closed`)                               |\n| `CI_COMMIT_TARGET_BRANCH`        | commit target branch (empty if event is not `pull_request` or `pull_request_closed`)                               |\n| `CI_COMMIT_TAG`                  | commit tag name (empty if event is not `tag`)                                                                      |\n| `CI_COMMIT_PULL_REQUEST`         | commit pull request number (empty if event is not `pull_request` or `pull_request_closed`)                         |\n| `CI_COMMIT_PULL_REQUEST_LABELS`  | labels assigned to pull request (empty if event is not `pull_request` or `pull_request_closed`)                    |\n| `CI_COMMIT_MESSAGE`              | commit message                                                                                                     |\n| `CI_COMMIT_AUTHOR`               | commit author username                                                                                             |\n| `CI_COMMIT_AUTHOR_EMAIL`         | commit author email address                                                                                        |\n| `CI_COMMIT_AUTHOR_AVATAR`        | commit author avatar                                                                                               |\n| `CI_COMMIT_PRERELEASE`           | release is a pre-release (empty if event is not `release`)                                                         |\n|                                  | **Current pipeline**                                                                                               |\n| `CI_PIPELINE_NUMBER`             | pipeline number                                                                                                    |\n| `CI_PIPELINE_PARENT`             | number of parent pipeline                                                                                          |\n| `CI_PIPELINE_EVENT`              | pipeline event (see [pipeline events](../20-usage/15-terminology/index.md#pipeline-events))                        |\n| `CI_PIPELINE_URL`                | link to the web UI for the pipeline                                                                                |\n| `CI_PIPELINE_FORGE_URL`          | link to the forge's web UI for the commit(s) or tag that triggered the pipeline                                    |\n| `CI_PIPELINE_DEPLOY_TARGET`      | pipeline deploy target for `deployment` events (i.e. production)                                                   |\n| `CI_PIPELINE_DEPLOY_TASK`        | pipeline deploy task for `deployment` events (i.e. migration)                                                      |\n| `CI_PIPELINE_STATUS`             | pipeline status (success, failure)                                                                                 |\n| `CI_PIPELINE_CREATED`            | pipeline created UNIX timestamp                                                                                    |\n| `CI_PIPELINE_STARTED`            | pipeline started UNIX timestamp                                                                                    |\n| `CI_PIPELINE_FINISHED`           | pipeline finished UNIX timestamp                                                                                   |\n| `CI_PIPELINE_FILES`              | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched |\n|                                  | **Current workflow**                                                                                               |\n| `CI_WORKFLOW_NAME`               | workflow name                                                                                                      |\n|                                  | **Current step**                                                                                                   |\n| `CI_STEP_NAME`                   | step name                                                                                                          |\n| `CI_STEP_NUMBER`                 | step number                                                                                                        |\n| `CI_STEP_STATUS`                 | step status (success, failure)                                                                                     |\n| `CI_STEP_STARTED`                | step started UNIX timestamp                                                                                        |\n| `CI_STEP_FINISHED`               | step finished UNIX timestamp                                                                                       |\n| `CI_STEP_URL`                    | URL to step in UI                                                                                                  |\n|                                  | **Previous commit**                                                                                                |\n| `CI_PREV_COMMIT_SHA`             | previous commit SHA                                                                                                |\n| `CI_PREV_COMMIT_REF`             | previous commit ref                                                                                                |\n| `CI_PREV_COMMIT_REFSPEC`         | previous commit ref spec                                                                                           |\n| `CI_PREV_COMMIT_BRANCH`          | previous commit branch                                                                                             |\n| `CI_PREV_COMMIT_SOURCE_BRANCH`   | previous commit source branch                                                                                      |\n| `CI_PREV_COMMIT_TARGET_BRANCH`   | previous commit target branch                                                                                      |\n| `CI_PREV_COMMIT_URL`             | previous commit link in forge                                                                                      |\n| `CI_PREV_COMMIT_MESSAGE`         | previous commit message                                                                                            |\n| `CI_PREV_COMMIT_AUTHOR`          | previous commit author username                                                                                    |\n| `CI_PREV_COMMIT_AUTHOR_EMAIL`    | previous commit author email address                                                                               |\n| `CI_PREV_COMMIT_AUTHOR_AVATAR`   | previous commit author avatar                                                                                      |\n|                                  | **Previous pipeline**                                                                                              |\n| `CI_PREV_PIPELINE_NUMBER`        | previous pipeline number                                                                                           |\n| `CI_PREV_PIPELINE_PARENT`        | previous pipeline number of parent pipeline                                                                        |\n| `CI_PREV_PIPELINE_EVENT`         | previous pipeline event (see [pipeline events](../20-usage/15-terminology/index.md#pipeline-events))               |\n| `CI_PREV_PIPELINE_URL`           | previous pipeline link in CI                                                                                       |\n| `CI_PREV_PIPELINE_FORGE_URL`     | previous pipeline link to event in forge                                                                           |\n| `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events (ie production)                                            |\n| `CI_PREV_PIPELINE_DEPLOY_TASK`   | previous pipeline deploy task for `deployment` events (ie migration)                                               |\n| `CI_PREV_PIPELINE_STATUS`        | previous pipeline status (success, failure)                                                                        |\n| `CI_PREV_PIPELINE_CREATED`       | previous pipeline created UNIX timestamp                                                                           |\n| `CI_PREV_PIPELINE_STARTED`       | previous pipeline started UNIX timestamp                                                                           |\n| `CI_PREV_PIPELINE_FINISHED`      | previous pipeline finished UNIX timestamp                                                                          |\n|                                  | &emsp;                                                                                                             |\n| `CI_WORKSPACE`                   | Path of the workspace where source code gets cloned to                                                             |\n|                                  | **System**                                                                                                         |\n| `CI_SYSTEM_NAME`                 | name of the CI system: `woodpecker`                                                                                |\n| `CI_SYSTEM_URL`                  | link to CI system                                                                                                  |\n| `CI_SYSTEM_HOST`                 | hostname of CI server                                                                                              |\n| `CI_SYSTEM_VERSION`              | version of the server                                                                                              |\n|                                  | **Forge**                                                                                                          |\n| `CI_FORGE_TYPE`                  | name of forge (gitea, github, ...)                                                                                 |\n| `CI_FORGE_URL`                   | root URL of configured forge                                                                                       |\n|                                  | **Internal** - Please don't use!                                                                                   |\n| `CI_SCRIPT`                      | Internal script path. Used to call pipeline step commands.                                                         |\n| `CI_NETRC_USERNAME`              | Credentials for private repos to be able to clone data. (Only available for specific images)                       |\n| `CI_NETRC_PASSWORD`              | Credentials for private repos to be able to clone data. (Only available for specific images)                       |\n| `CI_NETRC_MACHINE`               | Credentials for private repos to be able to clone data. (Only available for specific images)                       |\n\n## Global environment variables\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nThese can be used, for example, to manage the image tag used by multiple projects.\n\n```ini\nWOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18\n```\n\n```diff\n steps:\n   - name: build\n-    image: golang:1.18\n+    image: golang:${GOLANG_VERSION}\n     commands:\n       - [...]\n```\n\n## String Substitution\n\nWoodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration.\n\nExample commit substitution:\n\n```diff\n steps:\n   - name: docker\n     image: plugins/docker\n     settings:\n+      tags: ${CI_COMMIT_SHA}\n```\n\nExample tag substitution:\n\n```diff\n steps:\n   - name: docker\n     image: plugins/docker\n     settings:\n+      tags: ${CI_COMMIT_TAG}\n```\n\n## String Operations\n\nWoodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values.\n\n| OPERATION          | DESCRIPTION                                      |\n| ------------------ | ------------------------------------------------ |\n| `${param}`         | parameter substitution                           |\n| `${param,}`        | parameter substitution with lowercase first char |\n| `${param,,}`       | parameter substitution with lowercase            |\n| `${param^}`        | parameter substitution with uppercase first char |\n| `${param^^}`       | parameter substitution with uppercase            |\n| `${param:pos}`     | parameter substitution with substring            |\n| `${param:pos:len}` | parameter substitution with substring and length |\n| `${param=default}` | parameter substitution with default              |\n| `${param##prefix}` | parameter substitution with prefix removal       |\n| `${param%%suffix}` | parameter substitution with suffix removal       |\n| `${param/old/new}` | parameter substitution with find and replace     |\n\nExample variable substitution with substring:\n\n```diff\n steps:\n   - name: docker\n     image: plugins/docker\n     settings:\n+      tags: ${CI_COMMIT_SHA:0:8}\n```\n\nExample variable substitution strips `v` prefix from `v.1.0.0`:\n\n```diff\n steps:\n   - name: docker\n     image: plugins/docker\n     settings:\n+      tags: ${CI_COMMIT_TAG##v}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/51-plugins/20-creating-plugins.md",
    "content": "# Creating plugins\n\nCreating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT.\n\n## Settings\n\nTo allow users to configure the behavior of your plugin, you should use `settings:`.\n\nThese are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix.\nUsing a setting like `url` results in an env var named `PLUGIN_URL`.\n\nCharacters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`.\nCamelCase is not respected, `anInt` get `PLUGIN_ANINT`.\n\n### Basic settings\n\nUsing any basic YAML type (scalar) will be converted into a string:\n\n| Setting              | Environment value            |\n| -------------------- | ---------------------------- |\n| `some-bool: false`   | `PLUGIN_SOME_BOOL=\"false\"`   |\n| `some_String: hello` | `PLUGIN_SOME_STRING=\"hello\"` |\n| `anInt: 3`           | `PLUGIN_ANINT=\"3\"`           |\n\n### Complex settings\n\nIt's also possible to use complex settings like this:\n\n```yaml\nsteps:\n  - name: plugin\n    image: foo/plugin\n    settings:\n      complex:\n        abc: 2\n        list:\n          - 2\n          - 3\n```\n\nValues like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{\"abc\": \"2\", \"list\": [ \"2\", \"3\" ]}`.\n\n### Secrets\n\nSecrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#use-secrets-in-settings-and-environment).\n\n## Plugin library\n\nFor Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See <https://codeberg.org/woodpecker-plugins/go-plugin>.\n\n## Metadata\n\nIn your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins).\n\nSupported metadata:\n\n- `name`: The plugin's full name\n- `icon`: URL to your plugin's icon\n- `description`: A short description of what it's doing\n- `author`: Your name\n- `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin)\n- `containerImage`: name of the container image\n- `containerImageUrl`: link to the container image\n- `url`: homepage or repository of your plugin\n\nIf you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required.\n\n## Example plugin\n\nThis provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline.\n\n### What end users will see\n\nThe below example demonstrates how we might configure a webhook plugin in the YAML file:\n\n```yaml\nsteps:\n  - name: webhook\n    image: foo/webhook\n    settings:\n      url: https://example.com\n      method: post\n      body: |\n        hello world\n```\n\n### Write the logic\n\nCreate a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`.\n\n```bash\n#!/bin/sh\n\ncurl \\\n  -X ${PLUGIN_METHOD} \\\n  -d ${PLUGIN_BODY} \\\n  ${PLUGIN_URL}\n```\n\n### Package it\n\nCreate a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint.\n\n```dockerfile\n# please pin the version, e.g. alpine:3.19\nFROM alpine\nADD script.sh /bin/\nRUN chmod +x /bin/script.sh\nRUN apk -Uuv add curl ca-certificates\nENTRYPOINT /bin/script.sh\n```\n\nBuild and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community.\n\n```shell\ndocker build -t foo/webhook .\ndocker push foo/webhook\n```\n\nExecute your plugin locally from the command line to verify it is working:\n\n```shell\ndocker run --rm \\\n  -e PLUGIN_METHOD=post \\\n  -e PLUGIN_URL=https://example.com \\\n  -e PLUGIN_BODY=\"hello world\" \\\n  foo/webhook\n```\n\n## Best practices\n\n- Build your plugin for different architectures to allow many users to use them.\n  At least, you should support `amd64` and `arm64`.\n- Provide binaries for users using the `local` backend.\n  These should also be built for different OS/architectures.\n- Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible.\n- Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names.\n- Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)).\n- Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/51-plugins/51-overview.md",
    "content": "# Plugins\n\nPlugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more.\n\nThey are automatically pulled from the default container registry the agent's have configured.\n\n```dockerfile title=\"Dockerfile\"\nFROM laszlocloud/kubectl\nCOPY deploy /usr/local/deploy\nENTRYPOINT [\"/usr/local/deploy\"]\n```\n\n```bash title=\"deploy\"\nkubectl apply -f $PLUGIN_TEMPLATE\n```\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: deploy-to-k8s\n    image: laszlocloud/my-k8s-plugin\n    settings:\n      template: config/k8s/service.yaml\n```\n\nExample pipeline using the Docker and Slack plugins:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  - name: publish\n    image: plugins/docker\n    settings:\n      repo: foo/bar\n      tags: latest\n\n  - name: notify\n    image: plugins/slack\n    settings:\n      channel: dev\n```\n\n## Plugin Isolation\n\nPlugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree.\nWhile normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author.\n\nThat's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically\nadjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands`\nor `entrypoint` which will fail. Using `secrets` or `environment` is possible, but in this case, the plugin is internally not treated as plugin\nanymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition.\n\n## Finding Plugins\n\nFor official plugins, you can use the Woodpecker plugin index:\n\n- [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins)\n\n:::tip\nThere are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking.\n\n- [Drone Plugins](http://plugins.drone.io)\n- [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/)\n\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/51-plugins/_category_.yaml",
    "content": "label: 'Plugins'\n# position: 2\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/60-services.md",
    "content": "# Services\n\nWoodpecker provides a services section in the YAML file used for defining service containers.\nThe below configuration composes database and cache containers.\n\nServices are accessed using custom hostnames.\nIn the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`.\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n\n  - name: cache\n    image: redis\n```\n\nYou can define a port and a protocol explicitly:\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    ports:\n      - 3306\n\n  - name: wireguard\n    image: wg\n    ports:\n      - 51820/udp\n```\n\n## Configuration\n\nService containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more.\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    environment:\n+      - MYSQL_DATABASE=test\n+      - MYSQL_ALLOW_EMPTY_PASSWORD=yes\n\n   - name: cache\n     image: redis\n```\n\n## Detachment\n\nService and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required.\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n\n   - name: database\n     image: redis\n+    detach: true\n\n   - name: test\n     image: golang\n     commands:\n       - go test\n```\n\nContainers from detached steps will terminate when the pipeline ends.\n\n## Initialization\n\nService containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff.\n\n```diff\n steps:\n   - name: test\n     image: golang\n     commands:\n+      - sleep 15\n       - go get\n       - go test\n\n services:\n   - name: database\n     image: mysql\n```\n\n## Complete Pipeline Example\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    environment:\n      - MYSQL_DATABASE=test\n      - MYSQL_ROOT_PASSWORD=example\nsteps:\n  - name: get-version\n    image: ubuntu\n    commands:\n      - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null\n      - sleep 30s # need to wait for mysql-server init\n      - echo 'SHOW VARIABLES LIKE \"version\"' | mysql -uroot -hdatabase test -pexample\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/70-volumes.md",
    "content": "# Volumes\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\n:::note\nVolumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     commands:\n       - docker build --rm -t octocat/hello-world .\n       - docker run --rm octocat/hello-world --test\n       - docker push octocat/hello-world\n       - docker rmi octocat/hello-world\n     volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nPlease note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error.\n\n```diff\n-volumes: [ ./certs:/etc/ssl/certs ]\n+volumes: [ /etc/ssl/certs:/etc/ssl/certs ]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/72-linter.md",
    "content": "# Linter\n\nWoodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines.\n\n![errors and warnings in UI](./linter-warnings-errors.png)\n\n## Running the linter from CLI\n\nYou can run the linter also manually from the CLI:\n\n```shell\nwoodpecker-cli lint <workflow files>\n```\n\n## Bad habit warnings\n\nWoodpecker warns you if your configuration contains some bad habits.\n\n### Event filter for all steps\n\nAll your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well.\n\nExamples of an **incorrect** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n  - event: tag\n```\n\nThis will trigger the warning because the first item (`branch: main`) does not filter with an event.\n\n```yaml\nsteps:\n  - name: test\n    when:\n      branch: main\n\n  - name: deploy\n    when:\n      event: tag\n```\n\nExamples of a **correct** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n    event: push\n  - event: tag\n```\n\n```yaml\nsteps:\n  - name: test\n    when:\n      event: [tag, push]\n\n  - name: deploy\n    when:\n      - event: tag\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/75-project-settings.md",
    "content": "# Project settings\n\nAs the owner of a project in Woodpecker you can change project related settings via the web interface.\n\n![project settings](./project-settings.png)\n\n## Pipeline path\n\nThe path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`.\n\n## Repository hooks\n\nYour Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting.\n\n## Allow pull requests\n\nEnables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests.\n\n## Allow deployments\n\nEnables a pipeline to be started with the `deploy` event from a successful pipeline.\n\n:::danger\nOnly activate this option if you trust all users who have push access to your repository.\nOtherwise, these users will be able to steal secrets that are only available for `deploy` events.\n:::\n\n## Require approval for\n\nTo prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `All pull requests`.\n\n## Trusted\n\nIf you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes.\n\n:::note\n\nOnly server admins can set this option. If you are not a server admin this option won't be shown in your project settings.\n\n:::\n\n## Only inject netrc credentials into trusted containers\n\nCloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.\n\n## Project visibility\n\nYou can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners.\n\n- `Public` Every user can see your project without being logged in.\n- `Internal` Only authenticated users of the Woodpecker instance can see this project.\n- `Private` Only you and other owners of the repository can see this project.\n\n## Timeout\n\nAfter this timeout a pipeline has to finish or will be treated as timed out.\n\n## Cancel previous pipelines\n\nBy enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/80-badges.md",
    "content": "# Status Badges\n\nWoodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code.\n\n## Badge endpoint\n\n```uri\n<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n```\n\nThe status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter.\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<branch>\n```\n\nPlease note status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/90-advanced-usage.md",
    "content": "# Advanced usage\n\n## Advanced YAML syntax\n\nYAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config:\n\n### Anchors & aliases\n\nYou can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config.\n\nTo convert this:\n\n```yaml\nsteps:\n  - name: test\n    image: golang:1.18\n    commands: go test ./...\n  - name: build\n    image: golang:1.18\n    commands: build\n```\n\nJust add a new section called **variables** like this:\n\n```diff\n+variables:\n+  - &golang_image 'golang:1.18'\n\n steps:\n   - name: test\n-    image: golang:1.18\n+    image: *golang_image\n     commands: go test ./...\n   - name: build\n-    image: golang:1.18\n+    image: *golang_image\n     commands: build\n```\n\n### Map merges and overwrites\n\n```yaml\nvariables:\n  - &base-plugin-settings\n    target: dist\n    recursive: false\n    try: true\n  - &special-setting\n    special: true\n  - &some-plugin codeberg.org/6543/docker-images/print_env\n\nsteps:\n  - name: develop\n    image: *some-plugin\n    settings:\n      <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map\n    when:\n      branch: develop\n\n  - name: main\n    image: *some-plugin\n    settings:\n      <<: *base-plugin-settings # merge one map and ...\n      try: false # ... overwrite original value\n      ongoing: false # ... adding a new value\n    when:\n      branch: main\n```\n\n### Sequence merges\n\n```yaml\nvariables:\n  pre_cmds: &pre_cmds\n    - echo start\n    - whoami\n  post_cmds: &post_cmds\n    - echo stop\n  hello_cmd: &hello_cmd\n    - echo hello\n\nsteps:\n  - name: step1\n    image: debian\n    commands:\n      - <<: *pre_cmds # prepend a sequence\n      - echo exec step now do dedicated things\n      - <<: *post_cmds # append a sequence\n  - name: step2\n    image: debian\n    commands:\n      - <<: [*pre_cmds, *hello_cmd] # prepend two sequences\n      - echo echo from second step\n      - <<: *post_cmds\n```\n\n### References\n\n- [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases)\n- [YAML Cheatsheet](https://learnxinyminutes.com/docs/yaml)\n\n## Persisting environment data between steps\n\nOne can create a file containing environment variables, and then source it in each step that needs them.\n\n```yaml\nsteps:\n  - name: init\n    image: bash\n    commands:\n      - echo \"FOO=hello\" >> envvars\n      - echo \"BAR=world\" >> envvars\n\n  - name: debug\n    image: bash\n    commands:\n      - source ./envvars\n      - echo $FOO\n```\n\n## Declaring global variables\n\nAs described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables:\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nNote that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps.\n\n## Docker in docker (dind) setup\n\n:::warning\nThis set up will only work on trusted repositories and for security reasons should only be used in private environments.\nSee [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\nThe snippet below shows how a step can communicate with the docker daemon via a `docker:dind` service.\n\n:::note\nIf your aim ist to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead.\n:::\n\nFirst we need to define a servie running a docker with the `dind` tag. This service must run in privileged mode:\n\n```yaml\nservices:\n  - name: docker\n    image: docker:27.4-dind\n    privileged: true\n    ports:\n      - 2376\n```\n\nNext we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (since Unauthenticated TCP connections have been deprecated [as of docker v26](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will ve removed in release v28).\n\nWe can achieve this by letting the daemon generate TLS certificates for us and share them with the client via a volume mount in the agent (`/opt/woodpeckerci/dind-certs` in the example below).\n\n```diff\nservices:\n  - name: docker\n    image: docker:27.4-dind\n    privileged: true\n+    environment:\n+      DOCKER_TLS_CERTDIR: /dind-certs\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n     ports:\n       - 2376\n```\n\nIn the step that needs access to the daemon we need to:\n\n1. Set the `DOCKER_*` environment variables shown below, setting up the connection with the daemon. These are standardized environment variables that should work with the docker client used by your framework of choice (e.g. [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) or similar).\n2. Mount the volume where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`)\n\nIn this example we test the connection with the vanilla docker client:\n\n```diff\nsteps:\n  - name: test\n    image: docker:27.4-cli\n+    environment:\n+      DOCKER_HOST: \"tcp://docker:2376\"\n+      DOCKER_CERT_PATH: \"/dind-certs/client\"27.4-cli\n+      DOCKER_TLS_VERIFY: \"1\"\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n```\n\nThis step should output version information of the client and the server if everything has been set correctly.\n\nComplete example:\n\n```yaml\nsteps:\n  - name: test\n    image: docker:27.4-cli\n    environment:\n      DOCKER_HOST: \"tcp://docker:2376\"\n      DOCKER_CERT_PATH: \"/dind-certs/client\"27.4-cli\n      DOCKER_TLS_VERIFY: \"1\"\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n\nservices:\n  - name: docker\n    image: docker:27.4-dind\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /dind-certs\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    ports:\n      - 2376\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/20-usage/_category_.yaml",
    "content": "label: 'Usage'\n# position: 2\ncollapsible: true\ncollapsed: false\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/00-getting-started.md",
    "content": "# Getting started\n\nA Woodpecker deployment consists of two parts:\n\n- A server which is the heart of Woodpecker and ships the web interface.\n- Next to one server, you can deploy any number of agents which will run the pipelines.\n\nEach agent is able to process one [workflow](../20-usage/15-terminology/index.md) by default. If you have 4 agents installed and connected to the Woodpecker server, your system will process four workflows (not pipelines) in parallel.\n\n:::tip\nYou can add more agents to increase the number of parallel workflows or set the agent's `WOODPECKER_MAX_WORKFLOWS=1` environment variable to increase the number of parallel workflows per agent.\n:::\n\n## Which version of Woodpecker should I use?\n\nWoodpecker is having two different kinds of releases: **stable** and **next**.\n\nFind more information about the different versions [here](/versions).\n\n## Hardware Requirements\n\nBelow are minimal resources requirements for Woodpecker components itself:\n\n| Component | Memory | CPU |\n| --------- | ------ | --- |\n| Server    | 200 MB | 1   |\n| Agent     | 32 MB  | 1   |\n\nNote, that those values do not include the operating system or workload (pipelines execution) resource consumption.\n\nIn addition you need at least some kind of database which requires additional resources depending on the selected database system.\n\n## Installation\n\nYou can install Woodpecker on multiple ways. If you are not sure which one to choose, we recommend using the [docker-compose](./05-deployment-methods/10-docker-compose.md) method for the beginning:\n\n- Using [docker-compose](./05-deployment-methods/10-docker-compose.md) with the official [container images](./05-deployment-methods/10-docker-compose.md#docker-images)\n- Using [Kubernetes](./05-deployment-methods/20-kubernetes.md) via the Woodpecker Helm chart\n- Using binaries, DEBs or RPMs you can download from [latest release](https://github.com/woodpecker-ci/woodpecker/releases/latest)\n- Or using a [third-party installation method](./05-deployment-methods/30-third-party.md)\n\n## Database\n\nBy default Woodpecker uses a SQLite database which requires zero installation or configuration. See the [database settings](./10-database.md) page if you want to use a different database system like MySQL or PostgreSQL.\n\n## Forge\n\nWhat would be a CI/CD system without any code? By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md) like GitHub or Gitea you can start running pipelines on events like pushes or pull requests. Woodpecker will also use your forge for authentication and to report back the status of your pipelines. See the [forge settings](./11-forges/11-overview.md) to connect it to Woodpecker.\n\n## Configuration\n\nCheck the [server configuration](./10-server-config.md) and [agent configuration](./15-agent-config.md) pages to see if you need to adjust any additional parts and after that you should be ready to start with [your first pipeline](../20-usage/10-intro.md).\n\n## Agent\n\nThe agent is the worker which executes the [workflows](../20-usage/15-terminology/index.md).\nWoodpecker agents can execute work using a [backend](../20-usage/15-terminology/index.md) like [docker](./22-backends/10-docker.md) or [kubernetes](./22-backends/40-kubernetes.md).\nBy default if you choose to deploy an agent using [docker-compose](./05-deployment-methods/10-docker-compose.md) the agent simply use docker for the backend as well.\nSo nothing to worry about here. If you still prefer to adjust the agent to your needs, check the [agent configuration](./15-agent-config.md) page.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/10-docker-compose.md",
    "content": "# docker-compose\n\nThe below [docker-compose](https://docs.docker.com/compose/) configuration can be used to start a Woodpecker server with a single agent.\n\nIt relies on a number of environment variables that you must set before running `docker-compose up`. The variables are described below.\n\n```yaml title=\"docker-compose.yaml\"\nversion: '3'\n\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:latest\n    ports:\n      - 8000:8000\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\n  woodpecker-agent:\n    image: woodpeckerci/woodpecker-agent:latest\n    command: agent\n    restart: always\n    depends_on:\n      - woodpecker-server\n    volumes:\n      - woodpecker-agent-config:/etc/woodpecker\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\nvolumes:\n  woodpecker-server-data:\n  woodpecker-agent-config:\n```\n\nWoodpecker needs to know its own address. You must therefore provide the public address of it in `<scheme>://<hostname>` format. Please omit trailing slashes:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_HOST=${WOODPECKER_HOST}\n```\n\nWoodpecker can also have its port's configured. It uses a separate port for gRPC and for HTTP. The agent performs gRPC calls and connects to the gRPC port.\nThey can be configured with `*_ADDR` variables:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR}\n+      - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR}\n```\n\nReverse proxying can also be [configured for gRPC](../40-advanced/10-proxy.md#caddy). If the agents are connecting over the internet, it should also be SSL encrypted. The agent then needs to be configured to be secure:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_SECURE=true # defaults to false\n+      - WOODPECKER_GRPC_VERIFY=true # default\n```\n\nAs agents run pipeline steps as docker containers they require access to the host machine's Docker daemon:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n+    volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nAgents require the server address for agent-to-server communication. The agent connects to the server's gRPC port:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER=woodpecker-server:9000\n```\n\nThe server and agents use a shared secret to authenticate communication. This should be a random string of your choosing and should be kept private. You can generate such string with `openssl rand -hex 32`:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Docker images\n\nImage variants:\n\n- The `latest` image is the latest stable release\n- The `vX.X.X` images are stable releases\n- The `vX.X` images are based on the current release branch (e.g. `release/v1.0`) and can be used to get bugfixes asap\n- The `next` images are based on the current `main` branch\n\n```bash\n# server\ndocker pull woodpeckerci/woodpecker-server:latest\ndocker pull woodpeckerci/woodpecker-server:latest-alpine\n\n# agent\ndocker pull woodpeckerci/woodpecker-agent:latest\ndocker pull woodpeckerci/woodpecker-agent:latest-alpine\n\n# cli\ndocker pull woodpeckerci/woodpecker-cli:latest\ndocker pull woodpeckerci/woodpecker-cli:latest-alpine\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/20-kubernetes.md",
    "content": "# Kubernetes\n\nWe recommended to deploy Woodpecker using the [Woodpecker helm chart](https://github.com/woodpecker-ci/helm).\nHave a look at the [`values.yaml`](https://github.com/woodpecker-ci/helm/blob/main/charts/woodpecker/values.yaml) config files for all available settings.\n\nThe chart contains two subcharts, `server` and `agent` which are automatically configured as needed.\nThe chart started off with two independent charts but was merged into one to simplify the deployment at start of 2023.\n\nA couple of backend-specific config env vars exists which are described in the [kubernetes backend docs](../22-backends/40-kubernetes.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/30-third-party.md",
    "content": "# Third-party installation methods\n\n:::info\nThese installation methods are not officially supported. If you experience issues with them, please open issues in the specific repositories.\n:::\n\n- [Using NixOS](./40-nixos.md) via the [NixOS module](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker)\n- [On Alpine Edge](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=)\n- [On Arch Linux](https://archlinux.org/packages/?q=woodpecker)\n- [On openSUSE](https://software.opensuse.org/package/woodpecker)\n- [Using YunoHost](https://apps.yunohost.org/app/woodpecker)\n- [On Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html)\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/40-nixos.md",
    "content": "# NixOS\n\n:::info\nNote that this module is not maintained by the Woodpecker developers.\nIf you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained.\n:::\n\nThe NixOS install is in theory quite similar to the binary install and supports multiple backends.\nIn practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken.\n\n## General Configuration\n\n```nix\n{ config\n, ...\n}:\nlet\n  domain = \"woodpecker.example.org\";\nin\n{\n  # This automatically sets up certificates via let's encrypt\n  security.acme.defaults.email = \"acme@example.com\";\n  security.acme.acceptTerms = true;\n  security.acme.certs.\"${domain}\" = { };\n\n  # Setting up a nginx proxy that handles tls for us\n  networking.firewall.allowedTCPPorts = [ 80 443 ];\n  services.nginx = {\n    enable = true;\n    recommendedTlsSettings = true;\n    recommendedOptimisation = true;\n    recommendedProxySettings = true;\n    virtualHosts.\"${domain}\" = {\n      enableACME = true;\n      forceSSL = true;\n      locations.\"/\" = {\n        proxyPass = \"http://localhost:3007\";\n      };\n    };\n  };\n\n  services.woodpecker-server = {\n    enable = true;\n    environment = {\n      WOODPECKER_HOST = \"https://${domain}\";\n      WOODPECKER_SERVER_ADDR = \":3007\";\n      WOODPECKER_OPEN = \"true\";\n    };\n    # You can pass a file with env vars to the system it could look like:\n    # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX\n    environmentFile = \"/path/to/my/secrets/file\";\n  };\n\n  # This sets up a woodpecker agent\n  services.woodpecker-agents.agents.\"docker\" = {\n    enable = true;\n    # We need this to talk to the podman socket\n    extraGroups = [ \"podman\" ];\n    environment = {\n      WOODPECKER_SERVER = \"localhost:9000\";\n      WOODPECKER_MAX_WORKFLOWS = \"4\";\n      DOCKER_HOST = \"unix:///run/podman/podman.sock\";\n      WOODPECKER_BACKEND = \"docker\";\n    };\n    # Same as with woodpecker-server\n    environmentFile = [ \"/var/lib/secrets/woodpecker.env\" ];\n  };\n\n  # Here we setup podman and enable dns\n  virtualisation.podman = {\n    enable = true;\n    defaultNetwork.settings = {\n      dns_enabled = true;\n    };\n  };\n  # This is needed for podman to be able to talk over dns\n  networking.firewall.interfaces.\"podman0\" = {\n    allowedUDPPorts = [ 53 ];\n    allowedTCPPorts = [ 53 ];\n  };\n}\n```\n\nAll configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker)\n\n## Tips and tricks\n\nThere are some resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](../../92-awesome.md) page, like using the runners nix-store in the pipeline.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/_category_.yaml",
    "content": "label: 'Deployment methods'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/10-database.md",
    "content": "# Databases\n\nThe default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or Postgres database.\n\n## Configure SQLite\n\nBy default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database.\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n+    volumes:\n+      - woodpecker-server-data:/var/lib/woodpecker/\n```\n\n## Configure MySQL/MariaDB\n\nThe below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples.\nThe minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=mysql\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n```\n\n## Configure Postgres\n\nThe below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples.\nPlease use Postgres versions equal or higher than **11**.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=postgres\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable\n```\n\n## Database Creation\n\nWoodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`.\n\n## Database Migration\n\nWoodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes.\n\n## Database Backups\n\nWoodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice.\n\n## Database Archiving\n\nWoodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/10-server-config.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Server configuration\n\n## User registration\n\nWoodpecker does not have its own user registry; users are provided from your [forge](./11-forges/11-overview.md) (using OAuth2).\n\nRegistration is closed by default (`WOODPECKER_OPEN=false`). If registration is open (`WOODPECKER_OPEN=true`) then every user with an account at the configured forge can login to Woodpecker.\n\nTo open registration:\n\n```ini\nWOODPECKER_OPEN=true\n```\n\nYou can **also restrict** registration, by keep registration closed and:\n\n- **adding** new **users manually** via the CLI: `woodpecker-cli user add`\n- allowing specific **admin users** via the `WOODPECKER_ADMIN` setting\n- by open registration and **filter by organization** membership through the `WOODPECKER_ORGS` setting\n\n### Close registration, but allow specific admin users\n\n```ini\nWOODPECKER_OPEN=false\nWOODPECKER_ADMIN=johnsmith,janedoe\n```\n\n### Only allow registration of users, who are members of approved organizations\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ORGS=dolores,dogpatch\n```\n\n## Administrators\n\nAdministrators should also be enumerated in your configuration.\n\n```ini\nWOODPECKER_ADMIN=johnsmith,janedoe\n```\n\n## Filtering repositories\n\nWoodpecker operates with the user's OAuth permission. Due to the coarse permission handling of GitHub, you may end up syncing more repos into Woodpecker than preferred.\n\nUse the `WOODPECKER_REPO_OWNERS` variable to filter which GitHub user's repos should be synced only. You typically want to put here your company's GitHub name.\n\n```ini\nWOODPECKER_REPO_OWNERS=mycompany,mycompanyossgithubuser\n```\n\n## Global registry setting\n\nIf you want to make available a specific private registry to all pipelines, use the `WOODPECKER_DOCKER_CONFIG` server configuration.\nPoint it to your server's docker config.\n\n```ini\nWOODPECKER_DOCKER_CONFIG=/root/.docker/config.json\n```\n\n## Handling sensitive data in docker-compose and docker-swarm\n\nTo handle sensitive data in docker-compose or docker-swarm configurations there are several options:\n\nFor docker-compose you can use a `.env` file next to your compose configuration to store the secrets outside of the compose file. While this separates configuration from secrets it is still not very secure.\n\nAlternatively use docker-secrets. As it may be difficult to use docker secrets for environment variables Woodpecker allows to read sensible data from files by providing a `*_FILE` option of all sensible configuration variables. Woodpecker will try to read the value directly from this file. Keep in mind that when the original environment variable gets specified at the same time it will override the value read from the file.\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret\n+    secrets:\n+      - woodpecker-agent-secret\n+\n+ secrets:\n+   woodpecker-agent-secret:\n+     external: true\n```\n\nStore a value to a docker secret like this:\n\n```bash\necho \"my_agent_secret_key\" | docker secret create woodpecker-agent-secret -\n```\n\nor generate a random one like this:\n\n```bash\nopenssl rand -hex 32 | docker secret create woodpecker-agent-secret -\n```\n\n## Custom JavaScript and CSS\n\nWoodpecker supports custom JS and CSS files.\nThese files must be present in the server's filesystem.\nThey can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment.\nThe configuration variables are independent of each other, which means it can be just one file present, or both.\n\n```ini\nWOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css\nWOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js\n```\n\nThe examples below show how to place a banner message in the top navigation bar of Woodpecker.\n\n### `woodpecker.css`\n\n```css\n.banner-message {\n  position: absolute;\n  width: 280px;\n  height: 40px;\n  margin-left: 240px;\n  margin-top: 5px;\n  padding-top: 5px;\n  font-weight: bold;\n  background: red no-repeat;\n  text-align: center;\n}\n```\n\n### `woodpecker.js`\n\n```javascript\n// place/copy a minified version of jQuery or ZeptoJS here ...\n!(function () {\n  'use strict';\n  function e() {} /*...*/\n})();\n\n$().ready(function () {\n  $('.app nav img').first().htmlAfter(\"<div class='banner-message'>This is a demo banner message :)</div>\");\n});\n```\n\n## All server configuration options\n\nThe following list describes all available server configuration options.\n\n### `WOODPECKER_LOG_LEVEL`\n\n> Default: empty\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n### `WOODPECKER_LOG_FILE`\n\n> Default: `stderr`\n\nOutput destination for logs.\n'stdout' and 'stderr' can be used as special keywords.\n\n### `WOODPECKER_LOG_XORM`\n\n> Default: `false`\n\nEnable XORM logs.\n\n### `WOODPECKER_LOG_XORM_SQL`\n\n> Default: `false`\n\nEnable XORM SQL command logs.\n\n### `WOODPECKER_DEBUG_PRETTY`\n\n> Default: `false`\n\nEnable pretty-printed debug output.\n\n### `WOODPECKER_DEBUG_NOCOLOR`\n\n> Default: `true`\n\nDisable colored debug output.\n\n### `WOODPECKER_HOST`\n\n> Default: empty\n\nServer fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix.\n\nExamples:\n\n- `WOODPECKER_HOST=http://woodpecker.example.org`\n- `WOODPECKER_HOST=http://example.org/woodpecker`\n- `WOODPECKER_HOST=http://example.org:1234/woodpecker`\n\n### `WOODPECKER_WEBHOOK_HOST`\n\n> Default: value from `WOODPECKER_HOST` config env\n\nServer fully qualified URL of the Webhook-facing hostname and path prefix.\n\nExample: `WOODPECKER_WEBHOOK_HOST=http://woodpecker-server.cicd.svc.cluster.local:8000`\n\n### `WOODPECKER_SERVER_ADDR`\n\n> Default: `:8000`\n\nConfigures the HTTP listener port.\n\n### `WOODPECKER_SERVER_ADDR_TLS`\n\n> Default: `:443`\n\nConfigures the HTTPS listener port when SSL is enabled.\n\n### `WOODPECKER_SERVER_CERT`\n\n> Default: empty\n\nPath to an SSL certificate used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_CERT=/path/to/cert.pem`\n\n### `WOODPECKER_SERVER_KEY`\n\n> Default: empty\n\nPath to an SSL certificate key used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_KEY=/path/to/key.pem`\n\n### `WOODPECKER_CUSTOM_CSS_FILE`\n\n> Default: empty\n\nFile path for the server to serve a custom .CSS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css`\n\n### `WOODPECKER_CUSTOM_JS_FILE`\n\n> Default: empty\n\nFile path for the server to serve a custom .JS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js`\n\n### `WOODPECKER_LETS_ENCRYPT`\n\n> Default: `false`\n\nAutomatically generates an SSL certificate using Let's Encrypt, and configures the server to accept HTTPS requests.\n\n### `WOODPECKER_GRPC_ADDR`\n\n> Default: `:9000`\n\nConfigures the gRPC listener port.\n\n### `WOODPECKER_GRPC_SECRET`\n\n> Default: `secret`\n\nConfigures the gRPC JWT secret.\n\n### `WOODPECKER_GRPC_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GRPC_SECRET` from the specified filepath.\n\n### `WOODPECKER_METRICS_SERVER_ADDR`\n\n> Default: empty\n\nConfigures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely.\n\nExample: `:9001`\n\n### `WOODPECKER_ADMIN`\n\n> Default: empty\n\nComma-separated list of admin accounts.\n\nExample: `WOODPECKER_ADMIN=user1,user2`\n\n### `WOODPECKER_ORGS`\n\n> Default: empty\n\nComma-separated list of approved organizations.\n\nExample: `org1,org2`\n\n### `WOODPECKER_REPO_OWNERS`\n\n> Default: empty\n\nComma-separated list of syncable repo owners. ???\n\nExample: `user1,user2`\n\n### `WOODPECKER_OPEN`\n\n> Default: `false`\n\nEnable to allow user registration.\n\n### `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`\n\n> Default: `false`\n\nAlways use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies.\n\n### `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS`\n\n> Default: `pull_request, push`\n\nList of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\n\n### `WOODPECKER_DEFAULT_CLONE_IMAGE`\n\n> Default is defined in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go)\n\nThe default docker image to be used when cloning the repo\n\n### `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`\n\n> 60 (minutes)\n\nThe default time for a repo in minutes before a pipeline gets killed\n\n### `WOODPECKER_MAX_PIPELINE_TIMEOUT`\n\n> 120 (minutes)\n\nThe maximum time in minutes you can set in the repo settings before a pipeline gets killed\n\n### `WOODPECKER_SESSION_EXPIRES`\n\n> Default: `72h`\n\nConfigures the session expiration time.\nContext: when someone does log into Woodpecker, a temporary session token is created.\nAs long as the session is valid (until it expires or log-out),\na user can log into Woodpecker, without re-authentication.\n\n### `WOODPECKER_ESCALATE`\n\n> Defaults are defined in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go)\n\nDocker images to run in privileged mode. Only change if you are sure what you do!\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n<!--\n### `WOODPECKER_VOLUME`\n> Default: empty\n\nComma-separated list of Docker volumes that are mounted into every pipeline step.\n\nExample: `WOODPECKER_VOLUME=/path/on/host:/path/in/container:rw`|\n-->\n\n### `WOODPECKER_DOCKER_CONFIG`\n\n> Default: empty\n\nConfigures a specific private registry config for all pipelines.\n\nExample: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json`\n\n<!--\n### `WOODPECKER_ENVIRONMENT`\n> Default: empty\n\nTODO\n\n### `WOODPECKER_NETWORK`\n> Default: empty\n\nComma-separated list of Docker networks that are attached to every pipeline step.\n\nExample: `WOODPECKER_NETWORK=network1,network2`\n-->\n\n### `WOODPECKER_AGENT_SECRET`\n\n> Default: empty\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n### `WOODPECKER_AGENT_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath\n\n### `WOODPECKER_KEEPALIVE_MIN_TIME`\n\n> Default: empty\n\nServer-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\n\nExample: `WOODPECKER_KEEPALIVE_MIN_TIME=10s`\n\n### `WOODPECKER_DATABASE_DRIVER`\n\n> Default: `sqlite3`\n\nThe database driver name. Possible values are `sqlite3`, `mysql` or `postgres`.\n\n### `WOODPECKER_DATABASE_DATASOURCE`\n\n> Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container\n\nThe database connection string. The default value is the path of the embedded SQLite database file.\n\nExample:\n\n```bash\n# MySQL\n# https://github.com/go-sql-driver/mysql#dsn-data-source-name\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n\n# PostgreSQL\n# https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable\n```\n\n### `WOODPECKER_DATABASE_DATASOURCE_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath\n\n### `WOODPECKER_PROMETHEUS_AUTH_TOKEN`\n\n> Default: empty\n\nToken to secure the Prometheus metrics endpoint.\nMust be set to enable the endpoint.\n\n### `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath\n\n### `WOODPECKER_STATUS_CONTEXT`\n\n> Default: `ci/woodpecker`\n\nContext prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository.\n\n### `WOODPECKER_STATUS_CONTEXT_FORMAT`\n\n> Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}`\n\nTemplate for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language.\nSupported variables:\n\n- `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`)\n- `event`: the event which started the pipeline\n- `workflow`: the workflow's name\n- `owner`: the repo's owner\n- `repo`: the repo's name\n\n---\n\n### `WOODPECKER_LIMIT_MEM_SWAP`\n\n> Default: `0`\n\nThe maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`.\n\n### `WOODPECKER_LIMIT_MEM`\n\n> Default: `0`\n\nThe maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`.\n\n### `WOODPECKER_LIMIT_SHM_SIZE`\n\n> Default: `0`\n\nThe maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`.\n\n### `WOODPECKER_LIMIT_CPU_QUOTA`\n\n> Default: `0`\n\nThe number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`.\n\n### `WOODPECKER_LIMIT_CPU_SHARES`\n\n> Default: `0`\n\nThe relative weight vs. other containers.\n\n### `WOODPECKER_LIMIT_CPU_SET`\n\n> Default: empty\n\nComma-separated list to limit the specific CPUs or cores a pipeline container can use.\n\nExample: `WOODPECKER_LIMIT_CPU_SET=1,2`\n\n### `WOODPECKER_CONFIG_SERVICE_ENDPOINT`\n\n> Default: empty\n\nSpecify a configuration service endpoint, see [Configuration Extension](./40-advanced/100-external-configuration-api.md)\n\n### `WOODPECKER_FORGE_TIMEOUT`\n\n> Default: 3s\n\nSpecify timeout when fetching the Woodpecker configuration from forge. See <https://pkg.go.dev/time#ParseDuration> for syntax reference.\n\n### `WOODPECKER_FORGE_RETRY`\n\n> Default: 3\n\nSpecify how many retries of fetching the Woodpecker configuration from a forge are done before we fail.\n\n### `WOODPECKER_ENABLE_SWAGGER`\n\n> Default: true\n\nEnable the Swagger UI for API documentation.\n\n### `WOODPECKER_DISABLE_VERSION_CHECK`\n\n> Default: false\n\nDisable version check in admin web UI.\n\n### `WOODPECKER_LOG_STORE`\n\n> Default: `database`\n\nWhere to store logs. Possible values: `database` or `file`.\n\n### `WOODPECKER_LOG_STORE_FILE_PATH`\n\n> Default empty\n\nDirectory to store logs in if [`WOODPECKER_LOG_STORE`](#woodpecker_log_store) is `file`.\n\n---\n\n### `WOODPECKER_GITHUB_...`\n\nSee [GitHub configuration](./11-forges/20-github.md#configuration)\n\n### `WOODPECKER_GITEA_...`\n\nSee [Gitea configuration](./11-forges/30-gitea.md#configuration)\n\n### `WOODPECKER_BITBUCKET_...`\n\nSee [Bitbucket configuration](./11-forges/50-bitbucket.md#configuration)\n\n### `WOODPECKER_GITLAB_...`\n\nSee [GitLab configuration](./11-forges/40-gitlab.md#configuration)\n\n### `WOODPECKER_ADDON_FORGE`\n\nSee [addon forges](./11-forges/100-addon.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/100-addon.md",
    "content": "# Addon forges\n\nIf the forge you're using does not comply with [Woodpecker's requirements](../../92-development/02-core-ideas.md#forges) or your setup is too specific to be added to Woodpecker's core, you can write your own forge using an addon forge.\n\n:::warning\nAddon forges are still experimental. Their implementation can change and break at any time.\n:::\n\n:::danger\nYou need to trust the author of the addon forge you use. It can access authentication codes and other possibly sensitive information.\n:::\n\n## Usage\n\nTo use an addon forge, download the correct addon version. Then, you can add the following to your configuration:\n\n```ini\nWOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file\n```\n\nIn case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`.\n\n### Bug reports\n\nIf you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name.\n\n## List of addon forges\n\nIf you wrote or found an addon forge, please add it here so others can find it!\n\n_Be the first one to add your addon forge!_\n\n## Creating addon forges\n\nAddons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin).\n\n### Writing your code\n\nThis example will use the Go language.\n\nDirectly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/woodpecker/v2`) and use the interfaces and types defined there.\n\nIn the `main` function, just call `\"go.woodpecker-ci.org/woodpecker/v2/server/forge/addon\".Serve` with a `\"go.woodpecker-ci.org/woodpecker/v2/server/forge\".Forge` as argument.\nThis will take care of connecting the addon forge to the server.\n\n### Example structure\n\n```go\npackage main\n\nimport (\n  \"context\"\n  \"net/http\"\n\n  \"go.woodpecker-ci.org/woodpecker/v2/server/forge/addon\"\n  forgeTypes \"go.woodpecker-ci.org/woodpecker/v2/server/forge/types\"\n  \"go.woodpecker-ci.org/woodpecker/v2/server/model\"\n)\n\nfunc main() {\n  addon.Serve(config{})\n}\n\ntype config struct {\n}\n\n// `config` must implement `\"go.woodpecker-ci.org/woodpecker/v2/server/forge\".Forge`. You must directly use Woodpecker's packages - see imports above.\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/11-overview.md",
    "content": "# Forges\n\n## Supported features\n\n| Feature                                                       | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |\n| ------------------------------------------------------------- | :--------------------: | :------------------: | :----------------------: | :--------------------: | :--------------------------: | :------------------------------------------------: |\n| Event: Push                                                   |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |      :white_check_mark:      |                 :white_check_mark:                 |\n| Event: Tag                                                    |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |      :white_check_mark:      |                 :white_check_mark:                 |\n| Event: Pull-Request                                           |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |      :white_check_mark:      |                 :white_check_mark:                 |\n| Event: Release                                                |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |             :x:              |                        :x:                         |\n| Event: Deploy                                                 |   :white_check_mark:   |         :x:          |           :x:            |          :x:           |             :x:              |                        :x:                         |\n| [Multiple workflows](../../20-usage/25-workflows.md)          |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |      :white_check_mark:      |                 :white_check_mark:                 |\n| [when.path filter](../../20-usage/20-workflow-syntax.md#path) |   :white_check_mark:   |  :white_check_mark:  |    :white_check_mark:    |   :white_check_mark:   |             :x:              |                        :x:                         |\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/20-github.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitHub\n\nWoodpecker comes with built-in support for GitHub and GitHub Enterprise.\nTo use Woodpecker with GitHub the following environment variables should be set for the server component:\n\n```ini\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID\nWOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET\n```\n\nYou will get these values from GitHub when you register your OAuth application.\nTo do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App.\n\n:::warning\nDo not use a \"GitHub App\" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically)\n:::\n\n## App Settings\n\n- Name: An arbitrary name for your App\n- Homepage URL: The URL of your Woodpecker instance\n- Callback URL: `https://<your-woodpecker-instance>/authorize`\n- (optional) Upload the Woodpecker Logo: <https://avatars.githubusercontent.com/u/84780935?s=200&v=4>\n\n## Client Secret Creation\n\nAfter your App has been created, you can generate a client secret.\nUse this one for the `WOODPECKER_GITHUB_SECRET` environment variable.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_GITHUB`\n\n> Default: `false`\n\nEnables the GitHub driver.\n\n### `WOODPECKER_GITHUB_URL`\n\n> Default: `https://github.com`\n\nConfigures the GitHub server address.\n\n### `WOODPECKER_GITHUB_CLIENT`\n\n> Default: empty\n\nConfigures the GitHub OAuth client id to authorize access.\n\n### `WOODPECKER_GITHUB_CLIENT_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath.\n\n### `WOODPECKER_GITHUB_SECRET`\n\n> Default: empty\n\nConfigures the GitHub OAuth client secret. This is used to authorize access.\n\n### `WOODPECKER_GITHUB_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath.\n\n### `WOODPECKER_GITHUB_MERGE_REF`\n\n> Default: `true`\n\n### `WOODPECKER_GITHUB_SKIP_VERIFY`\n\n> Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n### `WOODPECKER_GITHUB_PUBLIC_ONLY`\n\n> Default: `false`\n\nConfigures the GitHub OAuth client to only obtain a token that can manage public repositories.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/30-gitea.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Gitea\n\nWoodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITEA=true\nWOODPECKER_GITEA_URL=YOUR_GITEA_URL\nWOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT\nWOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET\n```\n\n## Gitea on the same host with containers\n\nIf you have Gitea also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `gitea`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea\n```\n\n## Registration\n\nRegister your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea.<host>/user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook).\n\n![gitea oauth setup](gitea_oauth.gif)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_GITEA`\n\n> Default: `false`\n\nEnables the Gitea driver.\n\n### `WOODPECKER_GITEA_URL`\n\n> Default: `https://try.gitea.io`\n\nConfigures the Gitea server address.\n\n### `WOODPECKER_GITEA_CLIENT`\n\n> Default: empty\n\nConfigures the Gitea OAuth client id. This is used to authorize access.\n\n### `WOODPECKER_GITEA_CLIENT_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath\n\n### `WOODPECKER_GITEA_SECRET`\n\n> Default: empty\n\nConfigures the Gitea OAuth client secret. This is used to authorize access.\n\n### `WOODPECKER_GITEA_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITEA_SECRET` from the specified filepath\n\n### `WOODPECKER_GITEA_SKIP_VERIFY`\n\n> Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n## Advanced options\n\n### `WOODPECKER_DEV_GITEA_OAUTH_URL`\n\n> Default: value of `WOODPECKER_GITEA_URL`\n\nConfigures the user-facing Gitea server address. Should be used if `WOODPECKER_GITEA_URL` points to an internal URL used for API requests.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/35-forgejo.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Forgejo\n\n:::warning\nForgejo support is experimental.\n:::\n\nWoodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_FORGEJO=true\nWOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL\nWOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT\nWOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET\n```\n\n## Forgejo on the same host with containers\n\nIf you have Forgejo also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `forgejo`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo\n```\n\n## Registration\n\nRegister your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).\n\n![forgejo oauth setup](gitea_oauth.gif)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_FORGEJO`\n\n> Default: `false`\n\nEnables the Forgejo driver.\n\n### `WOODPECKER_FORGEJO_URL`\n\n> Default: `https://next.forgejo.org`\n\nConfigures the Forgejo server address.\n\n### `WOODPECKER_FORGEJO_CLIENT`\n\n> Default: empty\n\nConfigures the Forgejo OAuth client id. This is used to authorize access.\n\n### `WOODPECKER_FORGEJO_CLIENT_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath\n\n### `WOODPECKER_FORGEJO_SECRET`\n\n> Default: empty\n\nConfigures the Forgejo OAuth client secret. This is used to authorize access.\n\n### `WOODPECKER_FORGEJO_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath\n\n### `WOODPECKER_FORGEJO_SKIP_VERIFY`\n\n> Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/40-gitlab.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitLab\n\nWoodpecker comes with built-in support for the GitLab version 8.2 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITLAB=true\nWOODPECKER_GITLAB_URL=http://gitlab.mycompany.com\nWOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82\nWOODPECKER_GITLAB_SECRET=30f5064039e6b359e075\n```\n\n## Registration\n\nYou must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application.\n\nPlease use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application.\n\nIf you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_GITLAB`\n\n> Default: `false`\n\nEnables the GitLab driver.\n\n### `WOODPECKER_GITLAB_URL`\n\n> Default: `https://gitlab.com`\n\nConfigures the GitLab server address.\n\n### `WOODPECKER_GITLAB_CLIENT`\n\n> Default: empty\n\nConfigures the GitLab OAuth client id. This is used to authorize access.\n\n### `WOODPECKER_GITLAB_CLIENT_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath\n\n### `WOODPECKER_GITLAB_SECRET`\n\n> Default: empty\n\nConfigures the GitLab OAuth client secret. This is used to authorize access.\n\n### `WOODPECKER_GITLAB_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath\n\n### `WOODPECKER_GITLAB_SKIP_VERIFY`\n\n> Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/50-bitbucket.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket\n\nWoodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_BITBUCKET=true\nWOODPECKER_BITBUCKET_CLIENT=... # called \"Key\" in Bitbucket\nWOODPECKER_BITBUCKET_SECRET=...\n```\n\n## Registration\n\nYou must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`).\n\nPlease set a name and set the `Callback URL` like this:\n\n```uri\nhttps://<your-woodpecker-address>/authorize\n```\n\n![bitbucket oauth setup](bitbucket_oauth.png)\n\nPlease also be sure to check the following permissions:\n\n- Account: Email, Read\n- Workspace membership: Read\n- Projects: Read\n- Repositories: Read\n- Pull requests: Read\n- Webhooks: Read and Write\n\n![bitbucket permissions](bitbucket_permissions.png)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_BITBUCKET`\n\n> Default: `false`\n\nEnables the Bitbucket driver.\n\n### `WOODPECKER_BITBUCKET_CLIENT`\n\n> Default: empty\n\nConfigures the Bitbucket OAuth client key. This is used to authorize access.\n\n### `WOODPECKER_BITBUCKET_CLIENT_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath\n\n### `WOODPECKER_BITBUCKET_SECRET`\n\n> Default: empty\n\nConfigures the Bitbucket OAuth client secret. This is used to authorize access.\n\n### `WOODPECKER_BITBUCKET_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath\n\n## Missing Features\n\nPath filters for pull requests are not supported. We are interested in patches to include this functionality.\nIf you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/60-bitbucket_datacenter.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket Datacenter / Server\n\n:::warning\nWoodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash.\n:::\n\nTo enable Bitbucket Server you should configure the Woodpecker container using the following environment variables:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BITBUCKET_DC=true\n+      - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo\n+      - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy\n+      - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com\n\n   woodpecker-agent:\n     [...]\n```\n\n## Service Account\n\nWoodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories.\n\n## Registration\n\nWoodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose \"Application Links\" and then \"Create link\". Woodpecker should be listed as \"External Application\" and the direction should be set to \"Incomming\". Note the client id and client secret of the registration to be used in the configuration of Woodpecker.\n\nSee also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html).\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n### `WOODPECKER_BITBUCKET_DC`\n\n> Default: `false`\n\nEnables the Bitbucket Server driver.\n\n### `WOODPECKER_BITBUCKET_DC_URL`\n\n> Default: empty\n\nConfigures the Bitbucket Server address.\n\n### `WOODPECKER_BITBUCKET_DC_CLIENT_ID`\n\n> Default: empty\n\nConfigures your Bitbucket Server OAUth 2.0 client id.\n\n### `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET`\n\n> Default: empty\n\nConfigures your Bitbucket Server OAUth 2.0 client secret.\n\n### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME`\n\n> Default: empty\n\nThis username is used to authenticate and clone all private repositories.\n\n### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath\n\n### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD`\n\n> Default: empty\n\nThe password is used to authenticate and clone all private repositories.\n\n### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath\n\n### `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY`\n\n> Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/11-forges/_category_.yaml",
    "content": "label: 'Forges'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/15-agent-config.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Agent configuration\n\nAgents are configured by the command line or environment variables. At the minimum you need the following information:\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\n```\n\nThe following are automatically set and can be overridden:\n\n- `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname\n- `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1\n\n## Workflows per agent\n\nBy default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent.\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\nWOODPECKER_MAX_WORKFLOWS=4\n```\n\n## Agent registration\n\nWhen the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before.\n\nThere are two types of tokens to connect an agent to the server:\n\n### Using system token\n\nA _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents.\n\nIn that case registration process would be as following:\n\n1. The first time the agent communicates with the server, it is using the system token\n1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent\n1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`)\n1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server\n\n### Using agent token\n\nAn _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`.\n\nTo get an _agent token_ you have to register the agent manually in the server using the UI:\n\n1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent`\n   ![Agent creation](./new-agent-registration.png)\n   ![Agent created](./new-agent-created.png)\n1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET`\n1. The agent will connect to the server using the provided token and will update its status in the UI:\n   ![Agent connected](./new-agent-connected.png)\n\n## All agent configuration options\n\nHere is the full list of configuration options and their default variables.\n\n### `WOODPECKER_SERVER`\n\n> Default: `localhost:9000`\n\nConfigures gRPC address of the server.\n\n### `WOODPECKER_USERNAME`\n\n> Default: `x-oauth-basic`\n\nThe gRPC username.\n\n### `WOODPECKER_AGENT_SECRET`\n\n> Default: empty\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n### `WOODPECKER_AGENT_SECRET_FILE`\n\n> Default: empty\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`\n\n### `WOODPECKER_LOG_LEVEL`\n\n> Default: empty\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n### `WOODPECKER_DEBUG_PRETTY`\n\n> Default: `false`\n\nEnable pretty-printed debug output.\n\n### `WOODPECKER_DEBUG_NOCOLOR`\n\n> Default: `true`\n\nDisable colored debug output.\n\n### `WOODPECKER_HOSTNAME`\n\n> Default: empty\n\nConfigures the agent hostname.\n\n### `WOODPECKER_AGENT_CONFIG_FILE`\n\n> Default: `/etc/woodpecker/agent.conf`\n\nConfigures the path of the agent config file.\n\n### `WOODPECKER_MAX_WORKFLOWS`\n\n> Default: `1`\n\nConfigures the number of parallel workflows.\n\n### `WOODPECKER_FILTER_LABELS`\n\n> Default: empty\n\nConfigures labels to filter pipeline pick up. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. By default, agents provide three additional labels `platform=os/arch`, `hostname=my-agent` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../20-usage/20-workflow-syntax.md#labels).\n\n### `WOODPECKER_HEALTHCHECK`\n\n> Default: `true`\n\nEnable healthcheck endpoint.\n\n### `WOODPECKER_HEALTHCHECK_ADDR`\n\n> Default: `:3000`\n\nConfigures healthcheck endpoint address.\n\n### `WOODPECKER_KEEPALIVE_TIME`\n\n> Default: empty\n\nAfter a duration of this time of no activity, the agent pings the server to check if the transport is still alive.\n\n### `WOODPECKER_KEEPALIVE_TIMEOUT`\n\n> Default: `20s`\n\nAfter pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity.\n\n### `WOODPECKER_GRPC_SECURE`\n\n> Default: `false`\n\nConfigures if the connection to `WOODPECKER_SERVER` should be made using a secure transport.\n\n### `WOODPECKER_GRPC_VERIFY`\n\n> Default: `true`\n\nConfigures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`.\n\n### `WOODPECKER_BACKEND`\n\n> Default: `auto-detect`\n\nConfigures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`.\n\n### `WOODPECKER_BACKEND_DOCKER_*`\n\nSee [Docker backend configuration](./22-backends/10-docker.md#configuration)\n\n### `WOODPECKER_BACKEND_K8S_*`\n\nSee [Kubernetes backend configuration](./22-backends/40-kubernetes.md#configuration)\n\n### `WOODPECKER_BACKEND_LOCAL_*`\n\nSee [Local backend configuration](./22-backends/20-local.md#options)\n\n## Advanced Settings\n\n:::warning\nOnly change these If you know what you do.\n:::\n\n### `WOODPECKER_CONNECT_RETRY_COUNT`\n\n> Default: `5`\n\nConfigures number of times agent retries to connect to the server.\n\n### `WOODPECKER_CONNECT_RETRY_DELAY`\n\n> Default: `2s`\n\nConfigures delay between agent connection retries to the server.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/22-backends/10-docker.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Docker backend\n\nThis is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent.\n\n## Docker credentials\n\nWoodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server-config.md#woodpecker_docker_config).\n\nTo add your credential helper to the Woodpecker server container you could use the following code to build a custom image:\n\n```dockerfile\nFROM woodpeckerci/woodpecker-server:latest-alpine\n\nRUN apk add -U --no-cache docker-credential-ecr-login\n```\n\n## Podman support\n\nWhile the agent was developed with Docker/Moby, Podman can also be used by setting the environment variable `DOCKER_HOST` to point to the Podman socket. In order to work without workarounds, Podman 4.0 (or above) is required.\n\n## Image cleanup\n\nThe agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.\n\n:::danger\nThe following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation.\n:::\n\n### Remove all unused images\n\n```bash\ndocker image rm $(docker images --filter \"dangling=true\" -q --no-trunc)\n```\n\n### Remove Woodpecker volumes\n\n```bash\ndocker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true  -q)\n```\n\n## Configuration\n\n### `WOODPECKER_BACKEND_DOCKER_NETWORK`\n\n> Default: empty\n\nSet to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other!\n\n### `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6`\n\n> Default: `false`\n\nEnable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6.\n\n### `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n\n> Default: empty\n\nList of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA\ncertificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/22-backends/20-local.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Local backend\n\n:::danger\nThe local backend executes pipelines on the local system without any isolation.\n:::\n\n:::note\nCurrently we do not support [services](../../20-usage/60-services.md) for this backend.\n[Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095).\n:::\n\nSince the commands run directly in the same context as the agent (same user, same\nfilesystem), a malicious pipeline could be used to access the agent\nconfiguration especially the `WOODPECKER_AGENT_SECRET` variable.\n\nIt is recommended to use this backend only for private setup where the code and\npipeline can be trusted. It should not be used in a public instance where\nanyone can submit code or add new repositories. The agent should not run as a privileged user (root).\n\nThe local backend will use a random directory in `$TMPDIR` to store the cloned\ncode and execute commands.\n\nIn order to use this backend, you need to download (or build) the\n[agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine.\n\n## Usage\n\nTo enable the local backend, set the following:\n\n```ini\nWOODPECKER_BACKEND=local\n```\n\n### Shell\n\nThe `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is\nused to run the commands.\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: bash\n    commands: [...]\n```\n\n### Plugins\n\n```yaml\nsteps:\n  - name: build\n    image: /usr/bin/tree\n```\n\nIf no commands are provided, plugins are treated in the usual manner.\nIn the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path.\n\n### Options\n\n#### `WOODPECKER_BACKEND_LOCAL_TEMP_DIR`\n\n> Default: default temp directory\n\nDirectory to create folders for workflows.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/22-backends/40-kubernetes.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Kubernetes backend\n\nThe Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps.\n\n## Images from private registries\n\nIn order to pull private container images defined in your pipeline YAML you must provide [registry credentials in Kubernetes Secret](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\nAs the Secret is Agent-wide, it has to be placed in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE`.\nBesides, you need to provide the Secret name to Agent via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.\n\n## Job specific configuration\n\n### Resources\n\nThe Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory.\nWe recommend to add a `resources` definition to all steps to ensure efficient scheduling.\n\nHere is an example definition with an arbitrary `resources` definition below the `backend_options` section:\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        resources:\n          requests:\n            memory: 200Mi\n            cpu: 100m\n          limits:\n            memory: 400Mi\n            cpu: 1000m\n```\n\nYou can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis.\n\n### Runtime class\n\n`runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes.\n\n### Service account\n\n`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.\n\n### Node selector\n\n`nodeSelector` specifies the labels which are used to select the node on which the job will be executed.\n\nLabels defined here will be appended to a list which already contains `\"kubernetes.io/arch\"`.\nBy default `\"kubernetes.io/arch\"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`.\nWithout a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures.\n\nTo overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`.\nA practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture.\nIn this case, one must define an arbitrary key in the matrix section of the respective matrix element:\n\n```yaml\nmatrix:\n  include:\n    - NAME: runner1\n      ARCH: arm64\n```\n\nAnd then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var:\n\n```yaml\n[...]\n    backend_options:\n      kubernetes:\n        nodeSelector:\n          kubernetes.io/arch: \"${ARCH}\"\n```\n\nYou can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#woodpecker_backend_k8s_pod_node_selector) if you want to set the node selector per Agent\nor [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis.\n\n### Tolerations\n\nWhen you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations.\n\nExample pipeline configuration:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go get\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        serviceAccountName: 'my-service-account'\n        resources:\n          requests:\n            memory: 128Mi\n            cpu: 1000m\n          limits:\n            memory: 256Mi\n        nodeSelector:\n          beta.kubernetes.io/instance-type: p3.8xlarge\n        tolerations:\n          - key: 'key1'\n            operator: 'Equal'\n            value: 'value1'\n            effect: 'NoSchedule'\n            tolerationSeconds: 3600\n```\n\n### Volumes\n\nTo mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.\nAssuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a step:\n\n```yaml\nsteps:\n  - name: \"Restore Cache\"\n    image: meltwater/drone-cache\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    settings:\n      mount:\n        - \"woodpecker-cache\"\n    [...]\n```\n\n### Security context\n\nUse the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:\n\n```yaml\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo Hello world\n    backend_options:\n      kubernetes:\n        securityContext:\n          runAsUser: 999\n          runAsGroup: 999\n          privileged: true\n    [...]\n```\n\nNote that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object.\nBy default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the\nconfiguration shown above will result in something like the following Pod spec:\n\n```yaml\nkind: Pod\nspec:\n  securityContext:\n    runAsUser: 999\n    runAsGroup: 999\n  containers:\n    - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0\n      image: alpine\n      securityContext:\n        privileged: true\n  [...]\n```\n\nYou can also restrict a container's syscalls with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      seccompProfile:\n        type: Localhost\n        localhostProfile: profiles/audit.json\n```\n\nor restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      apparmorProfile:\n        type: Localhost\n        localhostProfile: k8s-apparmor-example-deny-write\n```\n\n:::note\nAppArmor syntax follows [KEP-24](https://github.com/kubernetes/enhancements/blob/fddcbb9cbf3df39ded03bad71228265ac6e5215f/keps/sig-node/24-apparmor/README.md).\n:::\n\n### Annotations and labels\n\nYou can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration:\n\n```yaml\nbackend_options:\n  kubernetes:\n    annotations:\n      workflow-group: alpha\n      io.kubernetes.cri-o.Devices: /dev/fuse\n    labels:\n      environment: ci\n      app.kubernetes.io/name: builder\n```\n\nIn order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent:\n[WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#woodpecker_backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#woodpecker_backend_k8s_pod_labels_allow_from_step).\n\n## Tips and tricks\n\n### CRI-O\n\nCRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration:\n\n```yaml\nworkspace:\n  base: '/woodpecker'\n  path: '/'\n```\n\nSee [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details.\n\n### `KUBERNETES_SERVICE_HOST` environment variable\n\nLike the below env vars used for configuration, this can be set in the environment fonfiguration of the agent. It configures the address of the Kubernetes API server to connect to.\n\nIf running the agent within Kubernetes, this will already be set and you don't have to add it manually.\n\n## Configuration\n\nThese env vars can be set in the `env:` sections of the agent.\n\n### `WOODPECKER_BACKEND_K8S_NAMESPACE`\n\n> Default: `woodpecker`\n\nThe namespace to create worker Pods in.\n\n### `WOODPECKER_BACKEND_K8S_VOLUME_SIZE`\n\n> Default: `10G`\n\nThe volume size of the pipeline volume.\n\n### `WOODPECKER_BACKEND_K8S_STORAGE_CLASS`\n\n> Default: empty\n\nThe storage class to use for the pipeline volume.\n\n### `WOODPECKER_BACKEND_K8S_STORAGE_RWX`\n\n> Default: `true`\n\nDetermines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead.\n\n### `WOODPECKER_BACKEND_K8S_POD_LABELS`\n\n> Default: empty\n\nAdditional labels to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-label\":\"test-value\"}`.\n\n### `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP`\n\n> Default: `false`\n\nDetermines if additional Pod labels can be defined from a step's backend options.\n\n### `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS`\n\n> Default: empty\n\nAdditional annotations to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-annotation\":\"test-value\"}`.\n\n### `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP`\n\n> Default: `false`\n\nDetermines if Pod annotations can be defined from a step's backend options.\n\n### `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR`\n\n> Default: empty\n\nAdditional node selector to apply to worker pods. Must be a YAML object, e.g. `{\"topology.kubernetes.io/region\":\"eu-central-1\"}`.\n\n### `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT`\n\n> Default: `false`\n\nDetermines if containers must be required to run as non-root users.\n\n### `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`\n\n> Default: empty\n\nSecret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/22-backends/50-custom-backends.md",
    "content": "# Custom backends\n\nIf none of our backends fits your usecases, you can write your own.\n\nTherefore, implement the interface `\"go.woodpecker-ci.org/woodpecker/woodpecker/v2/pipeline/backend/types\".Backend` and\nbuild a custom agent using your backend with this `main.go`:\n\n```go\npackage main\n\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v2/cmd/agent/core\"\n  backendTypes \"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types\"\n)\n\nfunc main() {\n  core.RunAgent([]backendTypes.Backend{\n    yourBackend,\n  })\n}\n```\n\nIt is also possible to use multiple backends, you can select with [`WOODPECKER_BACKEND`](../15-agent-config.md#woodpecker_backend) between them.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/22-backends/_category_.yaml",
    "content": "label: 'Backends'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/10-proxy.md",
    "content": "# Proxy\n\n## Apache\n\nThis guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration:\n\n```apacheconf\nProxyPreserveHost On\n\nRequestHeader set X-Forwarded-Proto \"https\"\n\nProxyPass / http://127.0.0.1:8000/\nProxyPassReverse / http://127.0.0.1:8000/\n```\n\nYou must have these Apache modules installed:\n\n- `proxy`\n- `proxy_http`\n\nYou must configure Apache to set `X-Forwarded-Proto` when using https.\n\n```diff\n ProxyPreserveHost On\n\n+RequestHeader set X-Forwarded-Proto \"https\"\n\n ProxyPass / http://127.0.0.1:8000/\n ProxyPassReverse / http://127.0.0.1:8000/\n```\n\n## Nginx\n\nThis guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide).\n\nExample configuration:\n\n```nginx\nserver {\n    listen 80;\n    server_name woodpecker.example.com;\n\n    location / {\n        proxy_set_header X-Forwarded-For $remote_addr;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Host $http_host;\n\n        proxy_pass http://127.0.0.1:8000;\n        proxy_redirect off;\n        proxy_http_version 1.1;\n        proxy_buffering off;\n\n        chunked_transfer_encoding off;\n    }\n}\n```\n\nYou must configure the proxy to set `X-Forwarded` proxy headers:\n\n```diff\n server {\n     listen 80;\n     server_name woodpecker.example.com;\n\n     location / {\n+        proxy_set_header X-Forwarded-For $remote_addr;\n+        proxy_set_header X-Forwarded-Proto $scheme;\n\n         proxy_pass http://127.0.0.1:8000;\n         proxy_redirect off;\n         proxy_http_version 1.1;\n         proxy_buffering off;\n\n         chunked_transfer_encoding off;\n     }\n }\n```\n\n## Caddy\n\nThis guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration:\n\n```caddy\n# expose WebUI and API\nwoodpecker.example.com {\n  reverse_proxy woodpecker-server:8000\n}\n\n# expose gRPC\nwoodpeckeragent.example.com {\n  reverse_proxy h2c://woodpecker-server:9000\n}\n```\n\n:::note\nAbove configuration shows how to create reverse-proxies for web and agent communication. If your agent uses SSL do not forget to enable [`WOODPECKER_GRPC_SECURE`](../15-agent-config.md#woodpecker_grpc_secure).\n:::\n\n## Tunnelmole\n\n[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.\n\nStart by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation).\n\nAfter the installation, run the following command to start tunnelmole:\n\n```bash\ntmole 8000\n```\n\nIt will start a tunnel and will give a response like this:\n\n```bash\n➜  ~ tmole 8000\nhttp://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\nhttps://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\n```\n\nSet `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server.\n\n## Ngrok\n\n[Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command:\n\n```bash\nngrok http 8000\n```\n\nSet `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server.\n\n## Traefik\n\nTo install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https.\n\n```yaml\nversion: '3.8'\n\nservices:\n  server:\n    image: woodpeckerci/woodpecker-server:latest\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=your_admin_user\n      # other settings ...\n\n    networks:\n      - dmz # externally defined network, so that traefik can connect to the server\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n\n    deploy:\n      labels:\n        - traefik.enable=true\n\n        # web server\n        - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000\n\n        - traefik.http.routers.woodpecker-secure.rule=Host(`cd.yourdomain.com`)\n        - traefik.http.routers.woodpecker-secure.tls=true\n        - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-secure.entrypoints=websecure\n        - traefik.http.routers.woodpecker-secure.service=woodpecker-service\n\n        - traefik.http.routers.woodpecker.rule=Host(`cd.yourdomain.com`)\n        - traefik.http.routers.woodpecker.entrypoints=web\n        - traefik.http.routers.woodpecker.service=woodpecker-service\n\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker\n\n        #  gRPC service\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c\n\n        - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.yourdomain.com`)\n        - traefik.http.routers.woodpecker-grpc-secure.tls=true\n        - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-grpc-secure.entrypoints=websecure\n        - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc\n\n        - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.yourdomain.com`)\n        - traefik.http.routers.woodpecker-grpc.entrypoints=web\n        - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc\n\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker\n\nvolumes:\n  woodpecker-server-data:\n    driver: local\n\nnetworks:\n  dmz:\n    external: true\n```\n\nYou should pass `WOODPECKER_GRPC_SECURE=true` and `WOODPECKER_GRPC_VERIFY=true` to your agent when using this configuration.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/100-external-configuration-api.md",
    "content": "# External Configuration API\n\nTo provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service.\nBefore the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration.\n\nEvery request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`.\n\nA simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service)\n\n:::warning\nYou need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks.\n:::\n\n## Config\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig\n```\n\n### Example request made by Woodpecker\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-testpipe\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"somefilename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"configs\": [\n    {\n      \"name\": \".woodpecker.yaml\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from Repo (.woodpecker.yaml)\\\"\\n\"\n    }\n  ]\n}\n```\n\n### Example response structure\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/20-ssl.md",
    "content": "# SSL\n\nWoodpecker supports two ways of enabling SSL communication. You can either use Let's Encrypt to get automated SSL support with\nrenewal or provide your own SSL certificates.\n\n## Let's Encrypt\n\nWoodpecker supports automated SSL configuration and updates using Let's Encrypt.\n\nYou can enable Let's Encrypt by making the following modifications to your server configuration:\n\n```ini\nWOODPECKER_LETS_ENCRYPT=true\nWOODPECKER_LETS_ENCRYPT_EMAIL=ssl-admin@example.tld\n```\n\nNote that Woodpecker uses the hostname from the `WOODPECKER_HOST` environment variable when requesting certificates. For example, if `WOODPECKER_HOST=https://example.com` is set the certificate is requested for `example.com`. To receive emails before certificates expire Let's Encrypt requires an email address. You can set it with `WOODPECKER_LETS_ENCRYPT_EMAIL=ssl-admin@example.tld`.\n\nThe SSL certificates are stored in `$HOME/.local/share/certmagic` for binary versions of Woodpecker and in `/var/lib/woodpecker` for the Container versions of it. You can set a custom path by setting `XDG_DATA_HOME` if required.\n\n> Once enabled you can visit the Woodpecker UI with http and the HTTPS address. HTTP will be redirected to HTTPS.\n\n### Certificate Cache\n\nWoodpecker writes the certificates to `/var/lib/woodpecker/certmagic/`.\n\n### Certificate Updates\n\nWoodpecker uses the official Go acme library which will handle certificate upgrades. There should be no addition configuration or management required.\n\n## SSL with own certificates\n\nWoodpecker supports SSL configuration by mounting certificates into your container.\n\n```ini\nWOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\nWOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n\n### Certificate Chain\n\nThe most common problem encountered is providing a certificate file without the intermediate chain.\n\n> LoadX509KeyPair reads and parses a public/private key pair from a pair of files. The files must contain PEM encoded data. The certificate file may contain intermediate certificates following the leaf certificate to form a certificate chain.\n\n### Certificate Errors\n\nSSL support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. If you receive certificate errors or warnings please examine your configuration more closely.\n\n### Running in containers\n\nUpdate your configuration to expose the following ports:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     ports:\n+      - 80:80\n+      - 443:443\n       - 9000:9000\n```\n\nUpdate your configuration to mount your certificate and key:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     volumes:\n+      - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt\n+      - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key\n```\n\nUpdate your configuration to provide the paths of your certificate and key:\n\n```diff title=\"docker-compose.yaml\"\n version: '3'\n\n services:\n   woodpecker-server:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\n+      - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/30-autoscaler.md",
    "content": "# Autoscaler\n\nIf your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler).\n\nPlease note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap).\n\n## Setup\n\n### docker-compose\n\nIf you are using docker-compose you can add the following to your `docker-compose.yaml` file:\n\n```yaml\nversion: '3'\n\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:next\n    [...]\n\n  woodpecker-autoscaler:\n    image: woodpeckerci/autoscaler:next\n    restart: always\n    depends_on:\n      - woodpecker-server\n    environment:\n      - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url\n      - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user\n      - WOODPECKER_MIN_AGENTS=0\n      - WOODPECKER_MAX_AGENTS=3\n      - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time\n      - WOODEPCKER_GRPC_ADDR=https://grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents\n      - WOODEPCKER_GRPC_SECURE=true\n      - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents\n      - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below\n      - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/40-advanced.md",
    "content": "# Adavanced options\n\nWhy should we be happy with a default setup? We should not! Woodpecker offers a lot of advanced options to configure it to your needs.\n\n## Behind a proxy\n\nSee the [proxy guide](./10-proxy.md) if you want to see a setup behind Apache, Nginx, Caddy or ngrok.\n\nIn the case you need to use Woodpecker with a URL path prefix (like: <https://example.org/woodpecker/>), add the root path to [`WOODPECKER_HOST`](../10-server-config.md#woodpecker_host).\n\n## SSL\n\nWoodpecker supports SSL configuration by using Let's encrypt or by using own certificates. See the [SSL guide](./20-ssl.md).\n\n## Metrics\n\nA [Prometheus endpoint](./90-prometheus.md) is exposed by Woodpecker to collect metrics.\n\n## Autoscaling\n\nThe [autoscaler](./30-autoscaler.md) can be used to deploy new agents to a cloud provider based on the current workload your server is experiencing.\n\n## Configuration service\n\nSometime the normal yaml configuration compiler isn't enough. You can use the [configuration service](./100-external-configuration-api.md) to process your configuration files by your own.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/90-prometheus.md",
    "content": "# Prometheus\n\nWoodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above.\n\n```yaml\nglobal:\n  scrape_interval: 60s\n\nscrape_configs:\n  - job_name: 'woodpecker'\n    bearer_token: dummyToken...\n\n    static_configs:\n      - targets: ['woodpecker.domain.com']\n```\n\n## Authorization\n\nAn administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token: dummyToken...\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\nAs an alternative, the token can also be read from a file:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token_file: /etc/secrets/woodpecker-monitoring-token\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\n## Metric Reference\n\nList of Prometheus metrics specific to Woodpecker:\n\n```yaml\n# HELP woodpecker_pipeline_count Pipeline count.\n# TYPE woodpecker_pipeline_count counter\nwoodpecker_pipeline_count{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\nwoodpecker_pipeline_count{branch=\"mkdocs\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\n# HELP woodpecker_pipeline_time Build time.\n# TYPE woodpecker_pipeline_time gauge\nwoodpecker_pipeline_time{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 116\nwoodpecker_pipeline_time{branch=\"mkdocs\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 155\n# HELP woodpecker_pipeline_total_count Total number of builds.\n# TYPE woodpecker_pipeline_total_count gauge\nwoodpecker_pipeline_total_count 1025\n# HELP woodpecker_pending_steps Total number of pending pipeline steps.\n# TYPE woodpecker_pending_steps gauge\nwoodpecker_pending_steps 0\n# HELP woodpecker_repo_count Total number of repos.\n# TYPE woodpecker_repo_count gauge\nwoodpecker_repo_count 9\n# HELP woodpecker_running_steps Total number of running pipeline steps.\n# TYPE woodpecker_running_steps gauge\nwoodpecker_running_steps 0\n# HELP woodpecker_user_count Total number of users.\n# TYPE woodpecker_user_count gauge\nwoodpecker_user_count 1\n# HELP woodpecker_waiting_steps Total number of pipeline waiting on deps.\n# TYPE woodpecker_waiting_steps gauge\nwoodpecker_waiting_steps 0\n# HELP woodpecker_worker_count Total number of workers.\n# TYPE woodpecker_worker_count gauge\nwoodpecker_worker_count 4\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/40-advanced/_category_.yaml",
    "content": "label: 'Advanced'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'advanced'\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/30-administration/_category_.yaml",
    "content": "label: 'Administration'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/40-cli.md",
    "content": "# CLI\n\n# NAME\n\nwoodpecker-cli - command line utility\n\n# SYNOPSIS\n\nwoodpecker-cli\n\n```\n[--config|-c]=[value]\n[--disable-update-check]\n[--log-file]=[value]\n[--log-level]=[value]\n[--nocolor]\n[--pretty]\n[--server|-s]=[value]\n[--token|-t]=[value]\n```\n\n# DESCRIPTION\n\nWoodpecker command line utility\n\n**Usage**:\n\n```\nwoodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]\n```\n\n# GLOBAL OPTIONS\n\n**--config, -c**=\"\": path to config file\n\n**--disable-update-check**: disable update check\n\n**--log-file**=\"\": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr)\n\n**--log-level**=\"\": set logging level (default: info)\n\n**--nocolor**: disable colored debug output, only has effect if pretty output is set too\n\n**--pretty**: enable pretty-printed debug output\n\n**--server, -s**=\"\": server address\n\n**--token, -t**=\"\": server auth token\n\n# COMMANDS\n\n## admin\n\nadminister server settings\n\n### registry\n\nmanage global registries\n\n#### add\n\nadds a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### info\n\ndisplay registry info\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### ls\n\nlist registries\n\n## org\n\nmanage organizations\n\n### registry\n\nmanage organization registries\n\n#### add\n\nadds a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### info\n\ndisplay registry info\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist registries\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n## repo\n\nmanage repositories\n\n### ls\n\nlist all repos\n\n**--format**=\"\": format output (default: \u001b[33m{{ .FullName }}\u001b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}))\n\n**--org**=\"\": filter by organization\n\n### info\n\nshow repository details\n\n**--format**=\"\": format output (default: Owner: {{ .Owner }}\nRepo: {{ .Name }}\nURL: {{ .ForgeURL }}\nConfig path: {{ .Config }}\nVisibility: {{ .Visibility }}\nPrivate: {{ .IsSCMPrivate }}\nTrusted: {{ .IsTrusted }}\nGated: {{ .IsGated }}\nRequire approval for: {{ .RequireApproval }}\nClone url: {{ .Clone }}\nAllow pull-requests: {{ .AllowPullRequests }}\n)\n\n### add\n\nadd a repository\n\n### update\n\nupdate a repository\n\n**--config**=\"\": repository configuration path (e.g. .woodpecker.yml)\n\n**--gated**: [deprecated] repository is gated\n\n**--pipeline-counter**=\"\": repository starting pipeline number (default: 0)\n\n**--require-approval**=\"\": repository requires approval for\n\n**--timeout**=\"\": repository timeout (default: 0s)\n\n**--trusted**: repository is trusted\n\n**--unsafe**: validate updating the pipeline-counter is unsafe\n\n**--visibility**=\"\": repository visibility\n\n### rm\n\nremove a repository\n\n### repair\n\nrepair repository webhooks\n\n### chown\n\nassume ownership of a repository\n\n### sync\n\nsynchronize the repository list\n\n**--format**=\"\": format output (default: \u001b[33m{{ .FullName }}\u001b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}))\n\n### registry\n\nmanage registries\n\n#### add\n\nadds a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n#### info\n\ndisplay registry info\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist registries\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n## pipeline\n\nmanage pipelines\n\n### ls\n\nshow pipeline history\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter\n\n**--limit**=\"\": limit the list size (default: 25)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers\n\n**--status**=\"\": status filter\n\n### last\n\nshow latest pipeline details\n\n**--branch**=\"\": branch name (default: main)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers\n\n### logs\n\nshow pipeline logs\n\n### info\n\nshow pipeline details\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers\n\n### stop\n\nstop a pipeline\n\n### start\n\nstart a pipeline\n\n**--param, -p**=\"\": custom parameters to be injected into the step environment. Format: KEY=value (default: [])\n\n### approve\n\napprove a pipeline\n\n### decline\n\ndecline a pipeline\n\n### queue\n\nshow pipeline queue\n\n**--format**=\"\": format output (default: \u001b[33m{{ .FullName }} #{{ .Number }} \u001b[0m\nStatus: {{ .Status }}\nEvent: {{ .Event }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\nMessage: {{ .Message }}\n)\n\n### ps\n\nshow pipeline steps\n\n**--format**=\"\": format output (default: \u001b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\u001b[0m\nStep: {{ .step.Name }}\nStarted: {{ .step.Started }}\nStopped: {{ .step.Stopped }}\nType: {{ .step.Type }}\nState: {{ .step.State }}\n)\n\n### create\n\ncreate new pipeline\n\n**--branch**=\"\": branch to create pipeline from\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers\n\n**--var**=\"\": key=value (default: [])\n\n## log\n\nmanage logs\n\n### purge\n\npurge a log\n\n## deploy\n\ntrigger a pipeline with the 'deployment' event\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter (default: push)\n\n**--format**=\"\": format output (default: Number: {{ .Number }}\nStatus: {{ .Status }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nMessage: {{ .Message }}\nAuthor: {{ .Author }}\nTarget: {{ .Deploy }}\n)\n\n**--param, -p**=\"\": custom parameters to be injected into the step environment. Format: KEY=value (default: [])\n\n**--status**=\"\": status filter (default: success)\n\n## exec\n\nexecute a local pipeline\n\n**--backend-docker-api-version**=\"\": the version of the API to reach, leave empty for latest.\n\n**--backend-docker-cert**=\"\": path to load the TLS certificates for connecting to docker server\n\n**--backend-docker-host**=\"\": path to docker socket or url to the docker server\n\n**--backend-docker-ipv6**: backend docker enable IPV6\n\n**--backend-docker-network**=\"\": backend docker network\n\n**--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server\n\n**--backend-docker-volumes**=\"\": backend docker volumes (comma separated)\n\n**--backend-engine**=\"\": backend engine to run pipelines on (default: auto-detect)\n\n**--backend-http-proxy**=\"\": if set, pass the environment variable down as \"HTTP_PROXY\" to steps\n\n**--backend-https-proxy**=\"\": if set, pass the environment variable down as \"HTTPS_PROXY\" to steps\n\n**--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps\n\n**--backend-k8s-namespace**=\"\": backend k8s namespace (default: woodpecker)\n\n**--backend-k8s-pod-annotations**=\"\": backend k8s additional Agent-wide worker pod annotations\n\n**--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options\n\n**--backend-k8s-pod-image-pull-secret-names**=\"\": backend k8s pull secret names for private registries (default: [regcred])\n\n**--backend-k8s-pod-labels**=\"\": backend k8s additional Agent-wide worker pod labels\n\n**--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options\n\n**--backend-k8s-pod-node-selector**=\"\": backend k8s Agent-wide worker pod node selector\n\n**--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option\n\n**--backend-k8s-storage-class**=\"\": backend k8s storage class\n\n**--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true)\n\n**--backend-k8s-volume-size**=\"\": backend k8s volume size (default 10G) (default: 10G)\n\n**--backend-local-temp-dir**=\"\": set a different temp dir to clone workflows into (default: /tmp/nix-shell.OgDG7Z)\n\n**--backend-no-proxy**=\"\": if set, pass the environment variable down as \"NO_PROXY\" to steps\n\n**--commit-author-avatar**=\"\":\n\n**--commit-author-email**=\"\":\n\n**--commit-author-name**=\"\":\n\n**--commit-branch**=\"\":\n\n**--commit-message**=\"\":\n\n**--commit-ref**=\"\":\n\n**--commit-refspec**=\"\":\n\n**--commit-sha**=\"\":\n\n**--env**=\"\": (default: [])\n\n**--forge-type**=\"\":\n\n**--forge-url**=\"\":\n\n**--local**: run from local directory\n\n**--netrc-machine**=\"\":\n\n**--netrc-password**=\"\":\n\n**--netrc-username**=\"\":\n\n**--network**=\"\": external networks (default: [])\n\n**--pipeline-created**=\"\": (default: 0)\n\n**--pipeline-deploy-task**=\"\":\n\n**--pipeline-deploy-to**=\"\":\n\n**--pipeline-event**=\"\": (default: manual)\n\n**--pipeline-finished**=\"\": (default: 0)\n\n**--pipeline-number**=\"\": (default: 0)\n\n**--pipeline-parent**=\"\": (default: 0)\n\n**--pipeline-started**=\"\": (default: 0)\n\n**--pipeline-status**=\"\":\n\n**--pipeline-url**=\"\":\n\n**--prev-commit-author-avatar**=\"\":\n\n**--prev-commit-author-email**=\"\":\n\n**--prev-commit-author-name**=\"\":\n\n**--prev-commit-branch**=\"\":\n\n**--prev-commit-message**=\"\":\n\n**--prev-commit-ref**=\"\":\n\n**--prev-commit-refspec**=\"\":\n\n**--prev-commit-sha**=\"\":\n\n**--prev-pipeline-created**=\"\": (default: 0)\n\n**--prev-pipeline-event**=\"\":\n\n**--prev-pipeline-finished**=\"\": (default: 0)\n\n**--prev-pipeline-number**=\"\": (default: 0)\n\n**--prev-pipeline-started**=\"\": (default: 0)\n\n**--prev-pipeline-status**=\"\":\n\n**--prev-pipeline-url**=\"\":\n\n**--privileged**=\"\": privileged plugins (default: [plugins/docker plugins/gcr plugins/ecr woodpeckerci/plugin-docker-buildx codeberg.org/woodpecker-plugins/docker-buildx])\n\n**--repo**=\"\": full repo name\n\n**--repo-clone-ssh-url**=\"\":\n\n**--repo-clone-url**=\"\":\n\n**--repo-path**=\"\": path to local repository\n\n**--repo-private**=\"\":\n\n**--repo-remote-id**=\"\":\n\n**--repo-trusted**:\n\n**--repo-url**=\"\":\n\n**--step-name**=\"\": (default: 0)\n\n**--system-name**=\"\": (default: woodpecker)\n\n**--system-platform**=\"\":\n\n**--system-url**=\"\": (default: https://github.com/woodpecker-ci/woodpecker)\n\n**--timeout**=\"\": pipeline timeout (default: 1h0m0s)\n\n**--volumes**=\"\": pipeline volumes (default: [])\n\n**--workflow-name**=\"\": (default: 0)\n\n**--workflow-number**=\"\": (default: 0)\n\n**--workspace-base**=\"\": (default: /woodpecker)\n\n**--workspace-path**=\"\": (default: src)\n\n## info\n\nshow information about the current user\n\n## registry\n\nmanage registries\n\n### add\n\nadds a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n### info\n\ndisplay registry info\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### ls\n\nlist registries\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n## secret\n\nmanage secrets\n\n### add\n\nadds a secret\n\n**--event**=\"\": secret limited to these events (default: [])\n\n**--global**: global secret\n\n**--image**=\"\": secret limited to these images (default: [])\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n### rm\n\nremove a secret\n\n**--global**: global secret\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### update\n\nupdate a secret\n\n**--event**=\"\": secret limited to these events (default: [])\n\n**--global**: global secret\n\n**--image**=\"\": secret limited to these images (default: [])\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n### info\n\ndisplay secret info\n\n**--global**: global secret\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### ls\n\nlist secrets\n\n**--global**: global secret\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n## user\n\nmanage users\n\n### ls\n\nlist all users\n\n**--format**=\"\": format output (default: {{ .Login }})\n\n### info\n\nshow user details\n\n**--format**=\"\": format output (default: User: {{ .Login }}\nEmail: {{ .Email }})\n\n### add\n\nadds a user\n\n### rm\n\nremove a user\n\n## lint\n\nlint a pipeline configuration file\n\n## log-level\n\nget the logging level of the server, or set it with [level]\n\n## cron\n\nmanage cron jobs\n\n### add\n\nadd a cron job\n\n**--branch**=\"\": cron branch\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n### rm\n\nremove a cron job\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### update\n\nupdate a cron job\n\n**--branch**=\"\": cron branch\n\n**--id**=\"\": cron id\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n### info\n\ndisplay info about a cron job\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n### ls\n\nlist cron jobs\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n## setup\n\nsetup the woodpecker-cli for the first time\n\n**--server**=\"\": The URL of the woodpecker server\n\n**--token**=\"\": The token to authenticate with the woodpecker server\n\n## update\n\nupdate the woodpecker-cli to the latest version\n\n**--force**: force update even if the latest version is already installed\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/50-about.md",
    "content": "# About\n\nWoodpecker has been originally forked from Drone 0.8 as the Drone CI license was changed after the 0.8 release from Apache 2.0 to a proprietary license. Woodpecker is based on this latest freely available version.\n\n## History\n\nWoodpecker was originally forked by [@laszlocph](https://github.com/laszlocph) in 2019.\n\nA few important time points:\n\n- [`2fbaa56`](https://github.com/woodpecker-ci/woodpecker/commit/2fbaa56eee0f4be7a3ca4be03dbd00c1bf5d1274) is the first commit of the fork, made on Apr 3, 2019.\n- The first release [v0.8.91](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.91) was published on Apr 6, 2019.\n- On Aug 27, 2019, the project was renamed to \"Woodpecker\" ([`630c383`](https://github.com/woodpecker-ci/woodpecker/commit/630c383181b10c4ec375e500c812c4b76b3c52b8)).\n- The first release under the name \"Woodpecker\" was published on Sep 9, 2019 ([v0.8.104](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.104)).\n\n## Differences to Drone\n\nWoodpecker is a community-focused software that still stay free and open source forever, while Drone is managed by [Harness](https://harness.io/) and published under [Polyform Small Business](https://polyformproject.org/licenses/small-business/1.0.0/) license.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/91-migrations.md",
    "content": "# Migrations\n\nSome versions need some changes to the server configuration or the pipeline configuration files.\n\n<!--\n## 3.0.0\n\n- Update all webhooks by pressing the \"Repair all\" button in the admin settings as the webhook token claims have changed\n\n-->\n\n## `next`\n\n- Deprecated `gated` repo settings option, use `require-approval`\n- Deprecated `steps.[name].group` in favor of `steps.[name].depends_on` (see [workflow syntax](./20-usage/20-workflow-syntax.md#depends_on) to learn how to set dependencies)\n- Removed `WOODPECKER_ROOT_PATH` and `WOODPECKER_ROOT_URL` config variables. Use `WOODPECKER_HOST` with a path instead\n- Pipelines without a config file will now be skipped instead of failing\n- Deprecated `includes` and `excludes` support from **event** filter\n- Deprecated uppercasing all secret env vars, instead, the value of the `secrets` property is used. [Read more](./20-usage/40-secrets.md#use-secrets-in-commands)\n- Deprecated alternative names for secrets, use `environment` with `from_secret`\n- Deprecated slice definition for env vars\n- Deprecated `environment` filter, use `when.evaluate`\n- Use `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` instead of `WOODPECKER_DEV_GITEA_OAUTH_URL` or `WOODPECKER_DEV_OAUTH_HOST`\n- Deprecated `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST`\n\n## 2.0.0\n\n- Dropped deprecated `CI_BUILD_*`, `CI_PREV_BUILD_*`, `CI_JOB_*`, `*_LINK`, `CI_SYSTEM_ARCH`, `CI_REPO_REMOTE` built-in environment variables\n- Deprecated `platform:` filter in favor of `labels:`, [read more](./20-usage/20-workflow-syntax.md#filter-by-platform)\n- Secrets `event` property was renamed to `events` and `image` to `images` as both are lists. The new property `events` / `images` has to be used in the api. The old properties `event` and `image` were removed.\n- The secrets `plugin_only` option was removed. Secrets with images are now always only available for plugins using listed by the `images` property. Existing secrets with a list of `images` will now only be available to the listed images if they are used as a plugin.\n- Removed `build` alias for `pipeline` command in CLI\n- Removed `ssh` backend. Use an agent directly on the SSH machine using the `local` backend.\n- Removed `/hook` and `/stream` API paths in favor of `/api/(hook|stream)`. You may need to use the \"Repair repository\" button in the repo settings or \"Repair all\" in the admin settings to recreate the forge hook.\n- Removed `WOODPECKER_DOCS` config variable\n- Renamed `link` to `url` (including all API fields)\n- Deprecated `CI_COMMIT_URL` env var, use `CI_PIPELINE_FORGE_URL`\n\n## 1.0.0\n\n- The signature used to verify extension calls (like those used for the [config-extension](./30-administration/40-advanced/100-external-configuration-api.md)) done by the Woodpecker server switched from using a shared-secret HMac to an ed25519 key-pair. Read more about it at the [config-extensions](./30-administration/40-advanced/100-external-configuration-api.md) documentation.\n- Refactored support for old agent filter labels and expressions. Learn how to use the new [filter](./20-usage/20-workflow-syntax.md#labels)\n- Renamed step environment variable `CI_SYSTEM_ARCH` to `CI_SYSTEM_PLATFORM`. Same applies for the cli exec variable.\n- Renamed environment variables `CI_BUILD_*` and `CI_PREV_BUILD_*` to `CI_PIPELINE_*` and `CI_PREV_PIPELINE_*`, old ones are still available but deprecated\n- Renamed environment variables `CI_JOB_*` to `CI_STEP_*`, old ones are still available but deprecated\n- Renamed environment variable `CI_REPO_REMOTE` to `CI_REPO_CLONE_URL`, old is still available but deprecated\n- Renamed environment variable `*_LINK` to `*_URL`, old ones are still available but deprecated\n- Renamed API endpoints for pipelines (`<owner>/<repo>/builds/<buildId>` -> `<owner>/<repo>/pipelines/<pipelineId>`), old ones are still available but deprecated\n- Updated Prometheus gauge `build_*` to `pipeline_*`\n- Updated Prometheus gauge `*_job_*` to `*_step_*`\n- Renamed config env `WOODPECKER_MAX_PROCS` to `WOODPECKER_MAX_WORKFLOWS` (still available as fallback)\n- The pipelines are now also read from `.yaml` files, the new default order is `.woodpecker/*.yml` and `.woodpecker/*.yaml` (without any prioritization) -> `.woodpecker.yml` -> `.woodpecker.yaml`\n- Dropped support for [Coding](https://coding.net/), [Gogs](https://gogs.io) and Bitbucket Server (Stash).\n- `/api/queue/resume` & `/api/queue/pause` endpoint methods were changed from `GET` to `POST`\n- rename `pipeline:` key in your workflow config to `steps:`\n- If you want to migrate old logs to the new format, watch the error messages on start. If there are none we are good to go, else you have to plan a migration that can take hours. Set `WOODPECKER_MIGRATIONS_ALLOW_LONG` to true and let it run.\n- Using `repo-id` in favor of `owner/repo` combination\n  - :warning: The api endpoints `/api/repos/{owner}/{repo}/...` were replaced by new endpoints using the repos id `/api/repos/{repo-id}`\n  - To find the id of a repo use the `/api/repos/lookup/{repo-full-name-with-slashes}` endpoint.\n  - The existing badge endpoint `/api/badges/{owner}/{repo}` will still work, but whenever possible try to use the new endpoint using the `repo-id`: `/api/badges/{repo-id}`.\n  - The UI urls for a repository changed from `/repos/{owner}/{repo}/...` to `/repos/{repo-id}/...`. You will be redirected automatically when using the old url.\n  - The woodpecker-go api-client is now using the `repo-id` instead of `owner/repo` for all functions\n- Using `org-id` in favour of `owner` name\n  - :warning: The api endpoints `/api/orgs/{owner}/...` were replaced by new endpoints using the orgs id `/api/repos/{org-id}`\n  - To find the id of orgs use the `/api/orgs/lookup/{org_full_name}` endpoint.\n  - The UI urls for a organization changed from `/org/{owner}/...` to `/orgs/{org-id}/...`. You will be redirected automatically when using the old url.\n  - The woodpecker-go api-client is now using the `org-id` instead of `org name` for all functions\n- The `command:` field has been removed from steps. If you were using it, please check if the entrypoint of the image you used is a shell.\n  - If it is a shell, simply rename `command:` to `commands:`.\n  - If it's not, you need to prepend the entrypoint before and also rename it (e.g., `commands: <entrypoint> <cmd>`).\n\n## 0.15.0\n\n- Default value for custom pipeline path is now empty / un-set which results in following resolution:\n\n  `.woodpecker/*.yml` -> `.woodpecker.yml` -> `.drone.yml`\n\n  Only projects created after updating will have an empty value by default. Existing projects will stick to the current pipeline path which is `.drone.yml` in most cases.\n\n  Read more about it at the [Project Settings](./20-usage/75-project-settings.md#pipeline-path)\n\n- From version `0.15.0` ongoing there will be three types of docker images: `latest`, `next` and `x.x.x` with an alpine variant for each type like `latest-alpine`.\n  If you used `latest` before to try pre-release features you should switch to `next` after this release.\n\n- Dropped support for `DRONE_*` environment variables. The according `WOODPECKER_*` variables must be used instead.\n  Additionally some alternative namings have been removed to simplify maintenance:\n  - `WOODPECKER_AGENT_SECRET` replaces `WOODPECKER_SECRET`, `DRONE_SECRET`, `WOODPECKER_PASSWORD`, `DRONE_PASSWORD` and `DRONE_AGENT_SECRET`.\n  - `WOODPECKER_HOST` replaces `DRONE_HOST` and `DRONE_SERVER_HOST`.\n  - `WOODPECKER_DATABASE_DRIVER` replaces `DRONE_DATABASE_DRIVER` and `DATABASE_DRIVER`.\n  - `WOODPECKER_DATABASE_DATASOURCE` replaces `DRONE_DATABASE_DATASOURCE` and `DATABASE_CONFIG`.\n\n- Dropped support for `DRONE_*` environment variables in pipeline steps. Pipeline meta-data can be accessed with `CI_*` variables.\n  - `CI_*` prefix replaces `DRONE_*`\n  - `CI` value is now `woodpecker`\n  - `DRONE=true` has been removed\n  - Some variables got deprecated and will be removed in future versions. Please migrate to the new names. Same applies for `DRONE_` of them.\n    - CI_ARCH => use CI_SYSTEM_ARCH\n    - CI_COMMIT => CI_COMMIT_SHA\n    - CI_TAG => CI_COMMIT_TAG\n    - CI_PULL_REQUEST => CI_COMMIT_PULL_REQUEST\n    - CI_REMOTE_URL => use CI_REPO_REMOTE\n    - CI_REPO_BRANCH => use CI_REPO_DEFAULT_BRANCH\n    - CI_PARENT_BUILD_NUMBER => use CI_BUILD_PARENT\n    - CI_BUILD_TARGET => use CI_BUILD_DEPLOY_TARGET\n    - CI_DEPLOY_TO => use CI_BUILD_DEPLOY_TARGET\n    - CI_COMMIT_AUTHOR_NAME => use CI_COMMIT_AUTHOR\n    - CI_PREV_COMMIT_AUTHOR_NAME => use CI_PREV_COMMIT_AUTHOR\n    - CI_SYSTEM => use CI_SYSTEM_NAME\n    - CI_BRANCH => use CI_COMMIT_BRANCH\n    - CI_SOURCE_BRANCH => use CI_COMMIT_SOURCE_BRANCH\n    - CI_TARGET_BRANCH => use CI_COMMIT_TARGET_BRANCH\n\n  For all available variables and their descriptions have a look at [built-in-environment-variables](./20-usage/50-environment.md#built-in-environment-variables).\n\n- Prometheus metrics have been changed from `drone_*` to `woodpecker_*`\n\n- Base path has moved from `/var/lib/drone` to `/var/lib/woodpecker`\n\n- Default workspace base path has moved from `/drone` to `/woodpecker`\n\n- Default SQLite database location has changed:\n  - `/var/lib/drone/drone.sqlite` -> `/var/lib/woodpecker/woodpecker.sqlite`\n  - `drone.sqlite` -> `woodpecker.sqlite`\n\n- Plugin Settings moved into `settings` section:\n\n  ```diff\n   steps:\n   something:\n     image: my/plugin\n  -  setting1: foo\n  -  setting2: bar\n  +  settings:\n  +    setting1: foo\n  +    setting2: bar\n  ```\n\n- `WOODPECKER_DEBUG` option for server and agent got removed in favor of `WOODPECKER_LOG_LEVEL=debug`\n\n- Remove unused server flags which can safely be removed from your server config: `WOODPECKER_QUIC`, `WOODPECKER_GITHUB_SCOPE`, `WOODPECKER_GITHUB_GIT_USERNAME`, `WOODPECKER_GITHUB_GIT_PASSWORD`, `WOODPECKER_GITHUB_PRIVATE_MODE`, `WOODPECKER_GITEA_GIT_USERNAME`, `WOODPECKER_GITEA_GIT_PASSWORD`, `WOODPECKER_GITEA_PRIVATE_MODE`, `WOODPECKER_GITLAB_GIT_USERNAME`, `WOODPECKER_GITLAB_GIT_PASSWORD`, `WOODPECKER_GITLAB_PRIVATE_MODE`\n\n- Dropped support for manually setting the agents platform with `WOODPECKER_PLATFORM`. The platform is now automatically detected.\n\n- Use `WOODPECKER_STATUS_CONTEXT` instead of the deprecated options `WOODPECKER_GITHUB_CONTEXT` and `WOODPECKER_GITEA_CONTEXT`.\n\n## 0.14.0\n\nNo breaking changes\n\n## From Drone\n\n:::warning\nMigration from Drone is only possible if you were running Drone <= v0.8.\n:::\n\n1. Make sure you are already running Drone v0.8\n2. Upgrade to Woodpecker v0.14.4, migration will be done during startup\n3. Upgrade to the latest Woodpecker version. Pay attention to the breaking changes listed above.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-awesome.md",
    "content": "# Awesome Woodpecker\n\nA curated list of awesome things related to Woodpecker CI.\n\nIf you have some missing resources, please feel free to [open a pull-request](https://github.com/woodpecker-ci/woodpecker/edit/main/docs/docs/92-awesome.md) and add them.\n\n## Official Resources\n\n- [Woodpecker CI pipeline configs](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) - Complex setup containing different kind of pipelines\n  - [Golang tests](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/test.yaml)\n  - [Typescript, eslint & Vue](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/web.yaml)\n  - [Docusaurus & publishing to GitHub Pages](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docs.yaml)\n  - [Docker container building](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docker.yaml)\n\n## Projects using Woodpecker\n\n- [Woodpecker CI](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) itself\n- [All official plugins](https://github.com/woodpecker-ci?q=plugin&type=all)\n- [dessalines/thumb-key](https://github.com/dessalines/thumb-key/blob/main/.woodpecker.yml) - Android Jetpack compose linting and building\n- [Vieter](https://git.rustybever.be/vieter-v/vieter) - Archlinux/Pacman repository server & automated package build system\n  - [Rieter](https://git.rustybever.be/Chewing_Bever/rieter) - Rewrite of the Vieter project in Rust\n- [Alex](https://git.rustybever.be/Chewing_Bever/alex) - Minecraft server wrapper designed to automate backups & complement Docker installations\n\n## Tools\n\n- [Convert Drone CI pipelines to Woodpecker CI](https://codeberg.org/lafriks/woodpecker-pipeline-transform)\n- [Ansible NAS](https://github.com/davestephens/ansible-nas/) - a homelab Ansible playbook that can set up Woodpecker CI and Gitea\n- [picus](https://github.com/windsource/picus) - Picus connects to a Woodpecker CI server and creates an agent in the cloud when there are pending workflows.\n- [Hetzner cloud](https://www.hetzner.com/cloud) based [Woodpecker compatible autoscaler](https://git.ljoonal.xyz/ljoonal/hetzner-ci-autoscaler) - Creates and destroys VPS instances based on the count of pending & running jobs.\n- [woodpecker-lint](https://git.schmidl.dev/schtobia/woodpecker-lint) - A repository for linting a Woodpecker config file via pre-commit hook\n- [Grafana Dashboard](https://github.com/Janik-Haag/woodpecker-grafana-dashboard) - A dashboard visualizing information exposed by the Woodpecker prometheus endpoint.\n- [woodpecker-autoscaler](https://github.com/Lerentis/woodpecker-autoscaler) - Yet another Woodpecker autoscaler currently targeting [Hetzner cloud](https://www.hetzner.com/cloud) that works in parallel to other autoscaler implementations.\n\n## Configuration Services\n\n- [Dynamic Pipelines for Nix Flakes](https://github.com/pinpox/woodpecker-flake-pipeliner) - Define pipelines as Nix Flake outputs\n\n## Pipelines\n\n- [Collection of pipeline examples](https://codeberg.org/Codeberg-CI/examples)\n\n## Posts & tutorials\n\n- [Setup Gitea with Woodpecker CI](https://containers.fan/posts/setup-gitea-with-woodpecker-ci/)\n- [Step-by-step guide to modern, secure and Open-source CI setup](https://devforth.io/blog/step-by-step-guide-to-modern-secure-ci-setup/)\n- [Using Woodpecker CI for my static sites](https://jan.wildeboer.net/2022/07/Woodpecker-CI-Jekyll/)\n- [Woodpecker CI @ Codeberg](https://www.sarkasti.eu/articles/post/woodpecker/)\n- [Deploy Docker/Compose using Woodpecker CI](https://hinty.io/vverenko/deploy-docker-compose-using-woodpecker-ci/)\n- [Installing Woodpecker CI in your personal homelab](https://pwa.io/articles/installing-woodpecker-in-your-homelab/)\n- [Locally Cached Nix CI with Woodpecker](https://blog.kotatsu.dev/posts/2023-04-21-woodpecker-nix-caching/)\n- [How to run Cypress auto-tests on Woodpecker CI and report results to Slack](https://devforth.io/blog/how-to-run-cypress-auto-tests-on-woodpecker-ci-and-report-results-to-slack/)\n- [Quest For CICD - WoodpeckerCI](https://omaramin.me/posts/woodpecker/)\n- [Getting started with Woodpecker CI](https://systeemkabouter.eu/getting-started-with-woodpecker-ci.html)\n- [Installing gitea and woodpecker using binary packages](https://neelex.com/2023/03/26/Installing-gitea-using-binary-packages/)\n- [Deploying mdbook to codeberg pages using woodpecker CI](https://www.markpitblado.me/blog/deploying-mdbook-to-codeberg-pages-using-woodpecker-ci/)\n- [Deploy a Fly app with Woodpecker CI](https://joeroe.io/2024/01/09/deploy-fly-woodpecker-ci.html)\n- [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/)\n\n## Videos\n\n- [Replace Ansible Semaphore with Woodpecker CI](https://www.youtube.com/watch?v=d610YPvCB0E)\n- [\"unexpected EOF\" error when trying to pair Woodpecker CI served through the Caddy with Gitea](https://www.youtube.com/watch?v=n7Hyvt71Np0)\n- [CICD Environment in Docker Swarm behind Caddy Server - Part 2 Woodpeckerci](https://www.youtube.com/watch?v=rkbw_k7JvS0)\n\n## Plugins\n\nWe have a separate [index](/plugins) for plugins.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/01-getting-started.md",
    "content": "# Getting started\n\nYou can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea).\n\n## Gitpod\n\nIf you want to start development or updating docs as easy as possible, you can use our preconfigured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing:\n\n- An IDE in the browser or bridged to your local VS-Code or Jetbrains\n- A preconfigured [Gitea](https://github.com/go-gitea/gitea) instance as forge\n- A preconfigured Woodpecker server\n- A single preconfigured Woodpecker agent node\n- Our docs preview server\n\nStart Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`.\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker)\n\n## Preparation for local development\n\n### Install Go\n\nInstall Golang (>=1.20) as described by [this guide](https://go.dev/doc/install).\n\n### Install make\n\n> GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (<https://www.gnu.org/software/make/>).\n\nInstall make on:\n\n- Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/)\n- [Windows](https://stackoverflow.com/a/32127632/8461267)\n- Mac OS: `brew install make`\n\n### Install Node.js & `pnpm`\n\nInstall [Node.js (>=14)](https://nodejs.org/en/download/) if you want to build Woodpecker's UI or documentation.\n\nFor dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.\n[This guide](https://pnpm.io/installation) describes the installation of `pnpm`.\n\n### Install `pre-commit` (optional)\n\nWoodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code.\nTo apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage).\n\n### Create a `.env` file with your development configuration\n\nSimilar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it.\n\nA common config for debugging would look like this:\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ADMIN=your-username\n\n# if you want to test webhooks with an online forge like GitHub this address needs to be accessible from public server\nWOODPECKER_HOST=http://your-dev-address.com\n\n# github (sample for a forge config - see /docs/administration/forge/overview for other forges)\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=<redacted>\nWOODPECKER_GITHUB_SECRET=<redacted>\n\n# agent\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system\nWOODPECKER_MAX_WORKFLOWS=1\n\n# enable if you want to develop the UI\n# WOODPECKER_DEV_WWW_PROXY=http://localhost:8010\n\n# used so you can login without using a public address\nWOODPECKER_DEV_OAUTH_HOST=http://localhost:8000\n\n# disable health-checks while debugging (normally not needed while developing)\nWOODPECKER_HEALTHCHECK=false\n\n# WOODPECKER_LOG_LEVEL=debug\n# WOODPECKER_LOG_LEVEL=trace\n```\n\n### Setup OAuth\n\nCreate an OAuth app for your forge as described in the [forges documentation](../30-administration/11-forges/11-overview.md). If you set `WOODPECKER_DEV_OAUTH_HOST=http://localhost:8000` you can use that address with the path as explained for the specific forge to login without the need for a public address. For example for GitHub you would use `http://localhost:8000/authorize` as authorization callback URL.\n\n## Developing with VS Code\n\nYou can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it.\n\nTo launch all needed services for local development, you can use \"Woodpecker CI\" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it.\n\nAs a starting guide for programming Go with VS Code, you can use this video guide:\n[![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80)\n\n### Debugging Woodpecker\n\nThe Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points.\n\n![Woodpecker debugging with VS Code](./vscode-debug.png)\n\n## Testing & linting code\n\nTo test or lint parts of Woodpecker, you can run one of the following commands:\n\n```bash\n# test server code\nmake test-server\n\n# test agent code\nmake test-agent\n\n# test cli code\nmake test-cli\n\n# test datastore / database related code like migrations of the server\nmake test-server-datastore\n\n# lint go code\nmake lint\n\n# lint UI code\nmake lint-frontend\n\n# test UI code\nmake test-frontend\n```\n\nIf you want to test a specific Go file, you can also use:\n\n```bash\ngo test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v2/<path-to-the-package-or-file-to-test>\n```\n\nOr you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands:\n\n![Run test via VS-Code](./vscode-run-test.png)\n\n## Run applications from terminal\n\nIf you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor.\n\n```bash title=\"start server\"\ngo run ./cmd/server\n```\n\n```bash title=\"start agent\"\ngo run ./cmd/agent\n```\n\n```bash title=\"execute cli command\"\ngo run ./cmd/cli [command]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/02-core-ideas.md",
    "content": "# Core ideas\n\n- A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂).\n- If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle).\n- What is used most often should be default.\n- Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md).\n\n## Addons and extensions\n\nIf you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an\n[addon forge](../30-administration/11-forges/100-addon.md), [extension](../30-administration/40-advanced/100-external-configuration-api.md) or an\n[external custom backend](../30-administration/22-backends/50-custom-backends.md), please check these points:\n\n- Is your change very specific to your setup and unlikely to be used by anyone else?\n- Does your change violate the [guidelines](#guidelines)?\n\nBoth should be false when you open a pull request to get your change into the core repository.\n\n### Guidelines\n\n#### Forges\n\nA new forge must support these features:\n\n- OAuth2\n- Webhooks\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/03-ui.md",
    "content": "# UI Development\n\nTo develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api.\n\n## Setup\n\nThe UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed).\n\nTesting UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files.\n\n![UI Proxy architecture](./ui-proxy.svg)\n\nStart the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.\nAfter starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000).\n\n## Tools and frameworks\n\nThe following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing.\n\n- [Vue 3](https://v3.vuejs.org/)\n  - use `setup` and composition api\n  - place (re-usable) components in `web/src/components/`\n  - views should have a route in `web/src/router.ts` and are located in `web/src/views/`\n- [Windicss](https://windicss.org/) (similar to Tailwind)\n  - use Windicss classes where possible\n  - if needed extend the Windicss config to use new classes\n- [Vite](https://vitejs.dev/) (similar to Webpack)\n- [Typescript](https://www.typescriptlang.org/)\n  - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:)\n- [eslint](https://eslint.org/)\n- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file\n  - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)\n\n## Messages and Translations\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.\nYou must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet)\n\nFor more information about translations see [Translations](./08-translations.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/04-docs.md",
    "content": "# Documentation\n\nThe documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/).\n\nIf you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands:\n\n```bash\ncd docs/\n\npnpm install\n\n# build plugins used by the docs\npnpm build:woodpecker-plugins\n\n# start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually\npnpm start\n\n# or build the docs to deploy it to some static page hosting\npnpm build\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/05-architecture.md",
    "content": "# Architecture\n\n## Package architecture\n\n![Woodpecker architecture](./woodpecker-architecture.png)\n\n## System architecture\n\n### main package hierarchy\n\n| package            | meaning                                                        | imports                               |\n| ------------------ | -------------------------------------------------------------- | ------------------------------------- |\n| `cmd/**`           | parse command-line args & environment to stat server/cli/agent | all other                             |\n| `agent/**`         | code only agent (remote worker) will need                      | `pipeline`, `shared`                  |\n| `cli/**`           | code only cli tool does need                                   | `pipeline`, `shared`, `woodpecker-go` |\n| `server/**`        | code only server will need                                     | `pipeline`, `shared`                  |\n| `shared/**`        | code shared for all three main tools (go help utils)           | only std and external libs            |\n| `woodpecker-go/**` | go client for server rest api                                  | std                                   |\n\n### Server\n\n| package              | meaning                                                                             | imports                                                                                                                                                                                |\n| -------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `server/api/**`      | handle web requests from `server/router`                                            | `pipeline`, `../badges`, `../ccmenue`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) |\n| `server/badges/**`   | generate svg badges for pipelines                                                   | `../model`                                                                                                                                                                             |\n| `server/ccmenu/**`   | generate xml ccmenu for pipelines                                                   | `../model`                                                                                                                                                                             |\n| `server/grpc/**`     | gRPC server agents can connect to                                                   | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store`                                                                            |\n| `server/logging/**`  | logging lib for gPRC server to stream logs while running                            | std                                                                                                                                                                                    |\n| `server/model/**`    | structs for store (db) and api (json)                                               | std                                                                                                                                                                                    |\n| `server/plugins/**`  | plugins for server                                                                  | `../model`, `../forge`                                                                                                                                                                 |\n| `server/pipeline/**` | orchestrate pipelines                                                               | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins`                                                                                                  |\n| `server/pubsub/**`   | pubsub lib for server to push changes to the WebUI                                  | std                                                                                                                                                                                    |\n| `server/queue/**`    | queue lib for server where agents pull new pipelines from via gRPC                  | `server/model`                                                                                                                                                                         |\n| `server/forge/**`    | forge lib for server to connect and handle forge specific stuff                     | `shared`, `server/model`                                                                                                                                                               |\n| `server/router/**`   | handle requests to REST API (and all middleware) and serve UI and WebUI config      | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web`                                                                                                                       |\n| `server/store/**`    | handle database                                                                     | `server/model`                                                                                                                                                                         |\n| `server/shared/**`   | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) |                                                                                                                                                                                        |\n| `server/web/**`      | server SPA                                                                          |                                                                                                                                                                                        |\n\n- `../` = `server/`\n\n### Agent\n\nTODO\n\n### CLI\n\nTODO\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/06-conventions.md",
    "content": "# Conventions\n\n## Database naming\n\nDatabase tables are named plural, columns don't have any prefix.\n\nExample: Table name `agent`, columns `id`, `name`.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/07-guides.md",
    "content": "# Guides\n\n## ORM\n\nWoodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection.\n\n## Add a new migration\n\nWoodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`.\n\n:::info\nAdding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created.\n:::\n\n:::warning\nYou should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager.\n:::\n\nTo automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start.\n\n## Constants of official images\n\nAll official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/08-translations.md",
    "content": "# Translations\n\nTo translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.**\n\n<a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n  <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/09-swagger.md",
    "content": "# Swagger, API Spec and Code Generation\n\nWoodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically\ngenerate Swagger v2 API specifications and a nice looking Web UI from the source code.\nAlso, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger)\nand then being using on the community's website documentation.\n\nIt's paramount important to keep the gin handler function's godoc documentation up-to-date,\nto always have accurate API documentation.\nWhenever you change, add or enhance an API endpoint, please update the godocs.\n\nYou don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools.\n\n## Gin-Handler API documentation guideline\n\nHere's a typical example of how annotations for Swagger documentation look like...\n\n```go title=\"server/api/user.go\"\n// @Summary  Get a user\n// @Description Returns a user with the specified login name. Requires admin rights.\n// @Router   /users/{login} [get]\n// @Produce  json\n// @Success  200 {object} User\n// @Tags   Users\n// @Param   Authorization header string true \"Insert your personal access token\" default(Bearer <personal access token>)\n// @Param   login   path string true \"the user's login name\"\n// @Param   foobar  query   string false \"optional foobar parameter\"\n// @Param   page    query int  false \"for response pagination, page offset number\" default(1)\n// @Param   perPage query int  false \"for response pagination, max items per page\" default(50)\n```\n\n```go title=\"server/model/user.go\"\ntype User struct {\n  ID int64 `json:\"id\" xorm:\"pk autoincr 'user_id'\"`\n// ...\n} // @name User\n```\n\nThese guidelines aim to have consistent wording in the swagger doc:\n\n- first word after `@Summary` and `@Summary` are always uppercase\n- `@Summary` has no `.` (dot) at the end of the line\n- model structs shall use custom short names, to ease life for API consumers, using `@name`\n- `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be renderend in Swagger\n- when pagination is used, `@Parame page` and `@Parame perPage` must be added manually\n- `@Param Authorization` is almost always present, there are just a few un-protected endpoints\n\nThere are many examples in the `server/api` package, which you can use a blueprint.\nMore enhanced information you can find here <https://github.com/swaggo/swag/blob/master/README.md#declarative-comments-format>\n\n### Manual code generation\n\n```bash title=\"generate the server's Go code containing the Swagger\"\nmake generate-swagger\n```\n\n```bash title=\"update the Markdown in the ./docs folder\"\nmake generate-docs\n```\n\n```bash title=\"auto-format swagger related godoc\"\ngo run github.com/swaggo/swag/cmd/swag@latest fmt -g server/api/z.go\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/09-testing.md",
    "content": "# Testing\n\n## Backend\n\n### Unit Tests\n\n[We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test)\nwith [`\"github.com/stretchr/testify/assert\"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing.\n\n### Integration Tests\n\n### Dummy backend\n\nThere is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave.\nTo enable it you need to build the agent or cli with the `test` build tag.\n\nAn example pipeline config would be:\n\n```yaml\nwhen:\n  event: manual\n\nsteps:\n  - name: echo\n    image: dummy\n    commands: echo \"hello woodpecker\"\n    environment:\n      SLEEP: '1s'\n\nservices:\n  echo:\n    image: dummy\n    commands: echo \"i am a sevice\"\n```\n\nThis could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`:\n\n```none\n9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: service\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: commands\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n```\n\nThere are also environment variables to alter step behaviour:\n\n- `SLEEP: 10` will let the step wait 10 seconds\n- `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands`\n- `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled)\n- `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs\n- `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0\n- `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains\n\nYou can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`.\n"
  },
  {
    "path": "docs/versioned_docs/version-2.8/92-development/_category_.yaml",
    "content": "label: 'Development'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/10-intro/index.md",
    "content": "# Welcome to Woodpecker\n\nWoodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics.\n\n## Have you ever heard of CI/CD or pipelines?\n\nDon't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of\nchecks, tests and routines along the way. A typical pipeline might include the following steps:\n\n1. Running tests\n2. Building your application\n3. Deploying your application\n\n[Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd)\n\n## Do you know containers?\n\nIf you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/).\n\n## Already have access to a Woodpecker instance?\n\nThen you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md).\n\n## Want to start from scratch and deploy your own Woodpecker instance?\n\nWoodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/10-intro.md",
    "content": "# Your first pipeline\n\nLet's get started and create your first pipeline.\n\n## 1. Repository Activation\n\nTo activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click.\n\n![new repository list](repo-new.png)\n\nTo enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something\nthat is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.).\n\n## 2. Define first workflow\n\nAfter enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository:\n\n```yaml title=\".woodpecker/my-first-workflow.yaml\"\nwhen:\n  - event: push\n    branch: main\n\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"This is the build step\"\n      - echo \"binary-data-123\" > executable\n  - name: a-test-step\n    image: golang:1.16\n    commands:\n      - echo \"Testing ...\"\n      - ./executable\n```\n\n**So what did we do here?**\n\n1. We defined your first workflow file `my-first-workflow.yaml`.\n2. This workflow will be executed when a push event happens on the `main` branch,\n   because we added a filter using the `when` section:\n\n   ```diff\n   + when:\n   +   - event: push\n   +     branch: main\n\n   ...\n   ```\n\n3. We defined two steps: `build` and `a-test-step`\n\nThe steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`.\n\nIn the `build` step we use the `debian` image and build a \"binary file\" called `executable`.\n\nIn the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it.\n\nYou can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to:\n\n```diff\n steps:\n   - name: build\n-    image: debian\n+    image: my-company/image-with-aws_cli\n     commands:\n       - aws help\n```\n\n## 3. Push the file and trigger first pipeline\n\nIf you push this file to your repository now, Woodpecker will already execute your first pipeline.\n\nYou can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository.\n\n![pipeline view](./pipeline.png)\n\nAs you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps.\n\nThis for example allows the first step to build your application using your source code and as the second step will receive\nthe same workspace it can use the previously built binary and test it.\n\n## 4. Use a plugin for reusable tasks\n\nSometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md).\n\nIf you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline:\n\n```yaml\nsteps:\n  # ...\n  - name: upload\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      access_key: a50d28f4dd477bc184fbd10b376de753\n      secret_key:\n        from_secret: aws_secret_key\n      source: public/**/*\n      target: /target/location\n```\n\nTo configure a plugin you can use the `settings` section.\n\nSometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md).\n\nSimilar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed.\n\nLearn more about [plugins](./51-plugins/51-overview.md).\n\nAs you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/100-troubleshooting.md",
    "content": "# Troubleshooting\n\n## How to debug clone issues\n\n(And what to do with an error message like `fatal: could not read Username for 'https://<url>': No such device or address`)\n\nThis error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`:\n\n```ini\nWOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true\n```\n\nIf that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container \"hang\":\n\n```yaml\nskip_clone: true\n\nsteps:\n  build:\n    image: debian:stable-backports\n    commands:\n      - apt update\n      - apt install -y inetutils-ping wget\n      - ping -c 4 git.example.com\n      - wget git.example.com\n      - sleep 9999999\n```\n\nGet the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf  bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline:\n\n```bash\ngit init\ngit remote add origin https://git.example.com/username/repo.git\ngit fetch --no-tags origin +refs/heads/branch:\n```\n\n(replace the url AND the branch with the correct values, use your username and password as log in values)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/15-terminology/architecture.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 226,\n      \"versionNonce\": 1002880859,\n      \"isDeleted\": false,\n      \"id\": \"UczUX5VuNnCB1rVvUJVfm\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.098092529257,\n      \"y\": 320.8758615860986,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 472.8823858375721,\n      \"height\": 183.19688715994928,\n      \"seed\": 917720693,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 161,\n      \"versionNonce\": 286006267,\n      \"isDeleted\": false,\n      \"id\": \"sKPZmBSWUdAYfBs4ByItH\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 539.5451038202509,\n      \"y\": 345.2419383247636,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 82.46875,\n      \"height\": 32.199999999999996,\n      \"seed\": 1485551573,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Server\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Server\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 333,\n      \"versionNonce\": 448586907,\n      \"isDeleted\": false,\n      \"id\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 649.8080506852966,\n      \"y\": 427.60908869342575,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 136,\n      \"height\": 60,\n      \"seed\": 1783625013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"r90dckf8trHemYzEwCgCW\"\n        },\n        {\n          \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 298,\n      \"versionNonce\": 1244067771,\n      \"isDeleted\": false,\n      \"id\": \"r90dckf8trHemYzEwCgCW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 703.8080506852966,\n      \"y\": 441.5090886934257,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 28,\n      \"height\": 32.199999999999996,\n      \"seed\": 660965013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113383,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"UI\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"originalText\": \"UI\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 105,\n      \"versionNonce\": 265992667,\n      \"isDeleted\": false,\n      \"id\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 800.9240766836483,\n      \"y\": 421.4987043996123,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135.3671503686619,\n      \"height\": 62.2689029398432,\n      \"seed\": 1115810805,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"svsVhxCbatcLj7lQLch0P\"\n        },\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 83,\n      \"versionNonce\": 1706870395,\n      \"isDeleted\": false,\n      \"id\": \"svsVhxCbatcLj7lQLch0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 828.1594096804793,\n      \"y\": 436.53315586953386,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.896484375,\n      \"height\": 32.199999999999996,\n      \"seed\": 2074781013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"GRPC\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"originalText\": \"GRPC\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 270,\n      \"versionNonce\": 418660123,\n      \"isDeleted\": false,\n      \"id\": \"hSrrwwnm9y7R-_CnJtaK1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.567103519039,\n      \"y\": 556.4146894573112,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 1983197877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 154,\n      \"versionNonce\": 871605179,\n      \"isDeleted\": false,\n      \"id\": \"8tsYgVssKnBd_Zw1QuqNz\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1298.4367898442752,\n      \"y\": 566.567242947784,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 96.5234375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1321669653,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent 1\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent 1\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 1323136091,\n      \"isDeleted\": false,\n      \"id\": \"eeugZg73_yD_6uLBBgmcX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 404.5001910129067,\n      \"y\": 707.1233710221009,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 210.068359375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1901447541,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"User => Browser\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"User => Browser\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"ellipse\",\n      \"version\": 106,\n      \"versionNonce\": 1501835515,\n      \"isDeleted\": false,\n      \"id\": \"mlDhl4OOc-H1tNgh77AAW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 482.5857164810477,\n      \"y\": 602.4394551739279,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 46.024748503793035,\n      \"height\": 44.21988070606176,\n      \"seed\": 791073493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"line\",\n      \"version\": 166,\n      \"versionNonce\": 627726747,\n      \"isDeleted\": false,\n      \"id\": \"ADEXzdYAhvj-_wVRftTIg\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 459.12202200277807,\n      \"y\": 697.1964604319912,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.31792517362464,\n      \"height\": 31.585599568061298,\n      \"seed\": 349155381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          42.415150610916044,\n          -28.87829787146393\n        ],\n        [\n          80.31792517362464,\n          2.7073016965973693\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 231,\n      \"versionNonce\": 801271355,\n      \"isDeleted\": false,\n      \"id\": \"xmz4J-rxLIjfUQ4q19PjD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 516.8788931508789,\n      \"y\": 870.4664542146543,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#fff4e6\",\n      \"width\": 385.34512717560705,\n      \"height\": 60.464035142111264,\n      \"seed\": 3531157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 93,\n      \"versionNonce\": 728690395,\n      \"isDeleted\": false,\n      \"id\": \"gSbpry_947XArfI7b6AAL\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 636.1468430141358,\n      \"y\": 878.5884970070326,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 132.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1989076725,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Autoscaler\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Autoscaler\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 118,\n      \"versionNonce\": 1258445691,\n      \"isDeleted\": false,\n      \"id\": \"WVy0mdTGbUx08RuxdQUH8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 523.3741602213286,\n      \"y\": 907.372811672524,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 369.1484375,\n      \"height\": 18.4,\n      \"seed\": 979386453,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Starts agents based on amount of pending pipelines\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Starts agents based on amount of pending pipelines\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 373,\n      \"versionNonce\": 1254044699,\n      \"isDeleted\": false,\n      \"id\": \"0Y1RcqzVFBFqh-wy-APMI\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1232.1955835481922,\n      \"y\": 605.8737363119278,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 292.6171875,\n      \"height\": 18.4,\n      \"seed\": 561999285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Executes pending workflows of a pipeline\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Executes pending workflows of a pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 630,\n      \"versionNonce\": 983038139,\n      \"isDeleted\": false,\n      \"id\": \"lGumbhMs3xx1vU2632hli\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 505.62283787078286,\n      \"y\": 383.42044095379515,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.015625,\n      \"height\": 36.8,\n      \"seed\": 722595605,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Central unit of a \\nWoodpecker instance \",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Central unit of a \\nWoodpecker instance \",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 131,\n      \"versionNonce\": 137308507,\n      \"isDeleted\": false,\n      \"id\": \"PbSQXehWVLYcQGXYFpd-B\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 971.7123256059622,\n      \"y\": 171.06951064323448,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 274.3443117379593,\n      \"height\": 74.90311522655017,\n      \"seed\": 1435321461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 96,\n      \"versionNonce\": 1222067707,\n      \"isDeleted\": false,\n      \"id\": \"2P2tz29C_2sUzVNSpaG17\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.5206131439782,\n      \"y\": 183.12082907329545,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 73.14453125,\n      \"height\": 32.199999999999996,\n      \"seed\": 884403669,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Forge\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Forge\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 141,\n      \"versionNonce\": 1133694619,\n      \"isDeleted\": false,\n      \"id\": \"0eYhFYPuRanZ7wkR2OlHO\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 986.864582863368,\n      \"y\": 225.1223531590797,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 247.234375,\n      \"height\": 18.4,\n      \"seed\": 1201957685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 55,\n      \"versionNonce\": 991183675,\n      \"isDeleted\": false,\n      \"id\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 820.419424341303,\n      \"y\": 340.29123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 247151765,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"bcUL-u4zkLA9CLG2YdaeN\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 38,\n      \"versionNonce\": 2008949723,\n      \"isDeleted\": false,\n      \"id\": \"bcUL-u4zkLA9CLG2YdaeN\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 831.853994653803,\n      \"y\": 358.79123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 94.130859375,\n      \"height\": 23,\n      \"seed\": 1638982133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Webhooks\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"originalText\": \"Webhooks\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 93,\n      \"versionNonce\": 295891067,\n      \"isDeleted\": false,\n      \"id\": \"Bphhue86mMXHN4klGamM3\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 697.3018309300141,\n      \"y\": 339.607928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 92986197,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"0YxY2hEPyDWFqR8_-f6bn\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 87,\n      \"versionNonce\": 2055547163,\n      \"isDeleted\": false,\n      \"id\": \"0YxY2hEPyDWFqR8_-f6bn\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 727.4522215550141,\n      \"y\": 358.107928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 56.69921875,\n      \"height\": 23,\n      \"seed\": 43952309,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"OAuth\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Bphhue86mMXHN4klGamM3\",\n      \"originalText\": \"OAuth\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 284,\n      \"versionNonce\": 1205292475,\n      \"isDeleted\": false,\n      \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1205.6453201409104,\n      \"y\": 250.4849674923464,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 272.1094712799886,\n      \"height\": 94.31865813977868,\n      \"seed\": 982632981,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"0eYhFYPuRanZ7wkR2OlHO\",\n        \"focus\": -0.8418551162334328,\n        \"gap\": 6.962614333266799\n      },\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          -69.68740859223726,\n          65.87860410965993\n        ],\n        [\n          -272.1094712799886,\n          94.31865813977868\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 53,\n      \"versionNonce\": 1803962459,\n      \"isDeleted\": false,\n      \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1050.575099048673,\n      \"y\": 297.96357160200637,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 170.765625,\n      \"height\": 36.8,\n      \"seed\": 1046069109,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"sends events like push, \\ntag, ...\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"originalText\": \"sends events like push, tag, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 487,\n      \"versionNonce\": 335895291,\n      \"isDeleted\": false,\n      \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 792.0835609101814,\n      \"y\": 316.38601649373913,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 176.92139414789008,\n      \"height\": 122.73778943055902,\n      \"seed\": 1681656021,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yvJTQ64RU50N6-hxEQlkl\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"UczUX5VuNnCB1rVvUJVfm\",\n        \"focus\": -0.03867359238356983,\n        \"gap\": 4.489845092359474\n      },\n      \"endBinding\": {\n        \"elementId\": \"PbSQXehWVLYcQGXYFpd-B\",\n        \"focus\": 0.7798878042817562,\n        \"gap\": 2.707370547890605\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          60.422360349016344,\n          -71.97786730696657\n        ],\n        [\n          176.92139414789008,\n          -122.73778943055902\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 62,\n      \"versionNonce\": 301106427,\n      \"isDeleted\": false,\n      \"id\": \"yvJTQ64RU50N6-hxEQlkl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 773.7910775091977,\n      \"y\": 226.00814918677256,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 157.4296875,\n      \"height\": 36.8,\n      \"seed\": 500049461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"allows users to login \\nusing existing account\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"originalText\": \"allows users to login using existing account\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 393,\n      \"versionNonce\": 598459861,\n      \"isDeleted\": false,\n      \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 936.9267543177084,\n      \"y\": 458.95033086418084,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 215.17788326846676,\n      \"height\": 93.99151368376693,\n      \"seed\": 234198933,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"rFf6NIofw6UBOyAFwg0Kn\"\n        }\n      ],\n      \"updated\": 1697530127259,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.30339107267010673,\n        \"gap\": 1\n      },\n      \"endBinding\": {\n        \"elementId\": \"hSrrwwnm9y7R-_CnJtaK1\",\n        \"focus\": -0.14057158065513534,\n        \"gap\": 3.4728449093634026\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          130.0760301643047,\n          42.90930518030268\n        ],\n        [\n          215.17788326846676,\n          93.99151368376693\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 8,\n      \"versionNonce\": 1693330843,\n      \"isDeleted\": false,\n      \"id\": \"rFf6NIofw6UBOyAFwg0Kn\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 997.4942845557462,\n      \"y\": 473.9409015069133,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 1592253685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 270,\n      \"versionNonce\": 1855882619,\n      \"isDeleted\": false,\n      \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 886.0581619083632,\n      \"y\": 485.67004123832135,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 174.09447592006472,\n      \"height\": 326.4905563076211,\n      \"seed\": 1479177813,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"apyMCAv2GIN_yzHXwX4tY\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.1341191028023529,\n        \"gap\": 1.9024338988657519\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"focus\": -0.7088661407505865,\n        \"gap\": 4.060573862784622\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          44.14165353942735,\n          196.18483635907205\n        ],\n        [\n          174.09447592006472,\n          326.4905563076211\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 66,\n      \"versionNonce\": 2007745083,\n      \"isDeleted\": false,\n      \"id\": \"apyMCAv2GIN_yzHXwX4tY\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 849.4927841977906,\n      \"y\": 663.4548775973934,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 882041781,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 347,\n      \"versionNonce\": 1353818811,\n      \"isDeleted\": false,\n      \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 534.9278465333664,\n      \"y\": 595.2199151317081,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 113.88020415193023,\n      \"height\": 119.81968366814112,\n      \"seed\": 944153877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"_A8uznhnpXuQBYzjP-iVx\",\n        \"focus\": 0.5397285671082249,\n        \"gap\": 1\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          113.88020415193023,\n          -119.81968366814112\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 61,\n      \"versionNonce\": 1099141979,\n      \"isDeleted\": false,\n      \"id\": \"j56ZKRwmXk72nHrZzLz_1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1081.8110514012087,\n      \"y\": 652.5253283508498,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 566.7373014532342,\n      \"height\": 68.58600908319681,\n      \"seed\": 112933493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 82,\n      \"versionNonce\": 1879994363,\n      \"isDeleted\": false,\n      \"id\": \"cAVYXfBRnfuGAv7QTQVow\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1300.6584159706863,\n      \"y\": 658.8425033454967,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 77.83203125,\n      \"height\": 23,\n      \"seed\": 951460821,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Backend\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Backend\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 376,- add some images explaining the architecture & terminology with\npipeline -> workflow -> step\n- combine advanced config usage\n- rename pipeline syntax to workflow syntax (and most references to\npipeline steps etc as well)\n- update agent registration part\n- add bug note to secrets encryption setting\n- remove usage from readme to point to up-to-date docs page\n- typos\n- closes #1408\n\n---------\n      \"angle\": 0,\n      \"x\": 1094.1972977313717,\n      \"y\": 681.8988272758752,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530.9453125,\n      \"height\": 55.199999999999996,\n      \"seed\": 843899189,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 50\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 384,\n      \"versionNonce\": 1778969915,\n      \"isDeleted\": false,\n      \"id\": \"pxF49EKDNO6IZq_34i7bY\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1064.2132116912126,\n      \"y\": 754.5018564383092,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 954528405,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 154,\n      \"versionNonce\": 1988988379,\n      \"isDeleted\": false,\n      \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 904.0288881242177,\n      \"y\": 882.4966027880746,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.83070714434325,\n      \"height\": 32.735025983189644,\n      \"seed\": 1228134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yNxAOEPZu_Jl7mnI01OXs\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"xmz4J-rxLIjfUQ4q19PjD\",\n        \"gap\": 1.8048677977312764,\n        \"focus\": 0.31250963573550006\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"gap\": 1.353616422651612,\n        \"focus\": 0.36496042109885213\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          158.83070714434325,\n          -32.735025983189644\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 25,\n      \"versionNonce\": 1393410779,\n      \"isDeleted\": false,\n      \"id\": \"yNxAOEPZu_Jl7mnI01OXs\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 963.8856479463893,\n      \"y\": 856.9290897964797,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 39.1171875,\n      \"height\": 18.4,\n      \"seed\": 759107925,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113387,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"starts\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"originalText\": \"starts\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 187,\n      \"versionNonce\": 671224603,\n      \"isDeleted\": false,\n      \"id\": \"sSj4Pda-fo-BBYM_dzml6\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1296.0854928322988,\n      \"y\": 776.6118140041631,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 104.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1381768885,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/15-terminology/index.md",
    "content": "# Terminology\n\n## Glossary\n\n- **Woodpecker CI**: The project name around Woodpecker.\n- **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code.\n- **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration.\n- **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC.\n- **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines.\n- **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events.\n- **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker).\n- **Steps**: Individual commands, actions or tasks within a [workflow][Workflow].\n- **Code**: Refers to the files tracked by the version control system used by the [forge][Forge].\n- **Repos**: Short for repositories, these are storage locations where code is stored.\n- **[Forge][Forge]**: The hosting platform or service where the repositories are hosted.\n- **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps.\n- **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI.\n- **Commit**: A defined state of the code, usually associated with a version control system like Git.\n- **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix.\n- **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow].\n- **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings.\n- **Container**: A lightweight and isolated environment where commands are executed.\n- **YAML File**: A file format used to define and configure [workflows][Workflow].\n- **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel.\n- **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge].\n- **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions.\n- **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue.\n\n## Woodpecker architecture\n\n![Woodpecker architecture](architecture.svg)\n\n## Pipeline, workflow & step\n\n![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg)\n\n## Conventions\n\nSometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker:\n\n- Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()`\n- Use the term **pipelines** instead of the previous **builds**\n- Use the term **steps** instead of the previous **jobs**\n- Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users\n\n<!-- References -->\n\n[Event]: ../20-workflow-syntax.md#event\n[Pipeline]: ../20-workflow-syntax.md\n[Workflow]: ../25-workflows.md\n[Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md\n[Plugin]: ../51-plugins/51-overview.md\n[Workspace]: ../20-workflow-syntax.md#workspace\n[Matrix]: ../30-matrix-workflows.md\n[Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md\n[Local]: ../../30-administration/10-configuration/11-backends/30-local.md\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/15-terminology/pipeline-workflow-step.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 97,\n      \"versionNonce\": 257762037,\n      \"isDeleted\": false,\n      \"id\": \"Y3hYdpX9r1qWfyHWs7AXT\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 393.622323134362,\n      \"y\": 336.02197155458475,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 366.3936710429598,\n      \"height\": 499.95605689083004,\n      \"seed\": 875444373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 67,\n      \"versionNonce\": 369556565,\n      \"isDeleted\": false,\n      \"id\": \"g1Eb010Kx_KFryVqNYWBQ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 520.0116988873679,\n      \"y\": 363.32095846456355,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 99.626953125,\n      \"height\": 32.199999999999996,\n      \"seed\": 1466195445,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Pipeline\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 314,\n      \"versionNonce\": 1983028731,\n      \"isDeleted\": false,\n      \"id\": \"9o-DNP0YdlIGVz1kEm_hW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 407.1590381712276,\n      \"y\": 410.9252244837219,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1869535061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 156,\n      \"versionNonce\": 1495247317,\n      \"isDeleted\": false,\n      \"id\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 490.3194993196821,\n      \"y\": 473.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 111355061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"ya0JzDo-4oscHIq87TZ_D\"\n        },\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 156,\n      \"versionNonce\": 1469425461,\n      \"isDeleted\": false,\n      \"id\": \"ya0JzDo-4oscHIq87TZ_D\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 566.0118821321821,\n      \"y\": 478.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1084671509,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 236,\n      \"versionNonce\": 1535319541,\n      \"isDeleted\": false,\n      \"id\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 491.2218643672577,\n      \"y\": 519.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 812596085,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"FRby8A9aUiKvHpM5mCdDN\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 231,\n      \"versionNonce\": 28677973,\n      \"isDeleted\": false,\n      \"id\": \"FRby8A9aUiKvHpM5mCdDN\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 583.0324112422577,\n      \"y\": 524.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1849820373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 291,\n      \"versionNonce\": 571598005,\n      \"isDeleted\": false,\n      \"id\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 489.6426911083554,\n      \"y\": 567.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1840554549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"UOwxmKIS0W62CFt_ffEy4\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 4032021,\n      \"isDeleted\": false,\n      \"id\": \"UOwxmKIS0W62CFt_ffEy4\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 581.4532379833554,\n      \"y\": 572.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 330077077,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 296,\n      \"versionNonce\": 1539516059,\n      \"isDeleted\": false,\n      \"id\": \"9laL3864YWOna6NQlVDqq\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 630.0635849044402,\n      \"y\": 383.14314287821776,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 294.3024370154917,\n      \"height\": 36.656016722015465,\n      \"seed\": 207575285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -1.000156025347643,\n        \"gap\": 27.782081605504118\n      },\n      \"endBinding\": {\n        \"elementId\": \"vS2PNUbmeBe3EPxl-dID8\",\n        \"focus\": 0.7761987167055517,\n        \"gap\": 8.978940924346716\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          294.3024370154917,\n          -36.656016722015465\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 249,\n      \"versionNonce\": 2076402229,\n      \"isDeleted\": false,\n      \"id\": \"vS2PNUbmeBe3EPxl-dID8\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 933.3449628442786,\n      \"y\": 336.02200598023114,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 301.298828125,\n      \"height\": 46,\n      \"seed\": 1632793173,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 751,\n      \"versionNonce\": 1371044827,\n      \"isDeleted\": false,\n      \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 751.1619011845514,\n      \"y\": 440.8355079324799,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 160.46519124360202,\n      \"height\": 2.2452348338335923,\n      \"seed\": 1331388341,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -0.6591700594229558,\n        \"gap\": 3.8807513696519322\n      },\n      \"endBinding\": {\n        \"elementId\": \"wfFvnFZuh0npL9hh0ez7o\",\n        \"focus\": 0.7652411053273549,\n        \"gap\": 20.75618622779257\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          160.46519124360202,\n          -2.2452348338335923\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 819540565,\n      \"isDeleted\": false,\n      \"id\": \"TbejdIYo_qNDw15yLP2IB\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 406.0812257713851,\n      \"y\": 626.8305540252475,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1553965333,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 466,\n      \"versionNonce\": 663477,\n      \"isDeleted\": false,\n      \"id\": \"wfFvnFZuh0npL9hh0ez7o\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 932.383278655946,\n      \"y\": 424.0107569968011,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 481.2890625,\n      \"height\": 115,\n      \"seed\": 781497973,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 110\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 464,\n      \"versionNonce\": 734626075,\n      \"isDeleted\": false,\n      \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 741.0645380446722,\n      \"y\": 492.31283255558515,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 178.4459423531871,\n      \"height\": 83.08707392565111,\n      \"seed\": 536879061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"q4TKpiq2KAwPaz19GdhtK\",\n        \"focus\": -0.7697471991854113,\n        \"gap\": 3.7450387249900814\n      },\n      \"endBinding\": {\n        \"elementId\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n        \"focus\": -0.7822252364700005,\n        \"gap\": 8.360835317635974\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          178.4459423531871,\n          83.08707392565111\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 327,\n      \"versionNonce\": 371646421,\n      \"isDeleted\": false,\n      \"id\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 927.8713157154953,\n      \"y\": 563.2132686484658,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 491.357421875,\n      \"height\": 46,\n      \"seed\": 385310005,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 91,\n      \"versionNonce\": 1180085909,\n      \"isDeleted\": false,\n      \"id\": \"0tGx2VdJLNf7W6HD76dtO\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 427.6895298601876,\n      \"y\": 432.3583566254258,\n      \"strokeColor\": \"#9c36b5\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 143.876953125,\n      \"height\": 23,\n      \"seed\": 450883221,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"build\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"build\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 338,\n      \"versionNonce\": 957223925,\n      \"isDeleted\": false,\n      \"id\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.7251825950889,\n      \"y\": 685.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 711939061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"8EqaPnZX2CgLaF08UNZZg\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 340,\n      \"versionNonce\": 510774613,\n      \"isDeleted\": false,\n      \"id\": \"8EqaPnZX2CgLaF08UNZZg\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 563.4175654075889,\n      \"y\": 690.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1370164565,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 421,\n      \"versionNonce\": 97999541,\n      \"isDeleted\": false,\n      \"id\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 488.62754764266447,\n      \"y\": 731.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 2145950389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"DX10t075MMDu7BLtuUaij\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 417,\n      \"versionNonce\": 2011446293,\n      \"isDeleted\": false,\n      \"id\": \"DX10t075MMDu7BLtuUaij\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 580.4380945176645,\n      \"y\": 736.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 500005909,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 475,\n      \"versionNonce\": 1284370805,\n      \"isDeleted\": false,\n      \"id\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.04837438376217,\n      \"y\": 779.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1666134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"-xogFSFcP-Vv5cuOSFm8T\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 476,\n      \"versionNonce\": 1092221653,\n      \"isDeleted\": false,\n      \"id\": \"-xogFSFcP-Vv5cuOSFm8T\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 578.8589212587622,\n      \"y\": 784.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1840462549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 125,\n      \"versionNonce\": 1310578741,\n      \"isDeleted\": false,\n      \"id\": \"N1a9yL7Pts16hUKY9-vhw\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 424.78852030984035,\n      \"y\": 646.2446482189896,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 133.857421875,\n      \"height\": 23,\n      \"seed\": 361699381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"test\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"test\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 184,\n      \"versionNonce\": 2127603131,\n      \"isDeleted\": false,\n      \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 737.454940151797,\n      \"y\": 535.9141784615474,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 190.41665096887027,\n      \"height\": 112.96427727851824,\n      \"seed\": 80234901,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.8392895251910331,\n        \"gap\": 2.0300115262207328\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          190.41665096887027,\n          112.96427727851824\n        ]\n      ]\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 327,\n      \"versionNonce\": 780710651,\n      \"isDeleted\": false,\n      \"id\": \"379hO6Dc5rygB38JgDbVo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 738.8084877231549,\n      \"y\": 591.3526691276127,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 186.8066399682357,\n      \"height\": 57.68023784868956,\n      \"seed\": 211046133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"2WwuMWX7YawqK0i1rDPJo\",\n        \"focus\": -0.5776522830934517,\n        \"gap\": 2.1657966147995467\n      },\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.7269489945238884,\n        \"gap\": 4.286474955497397\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          186.8066399682357,\n          57.68023784868956\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 285,\n      \"versionNonce\": 1165977685,\n      \"isDeleted\": false,\n      \"id\": \"0TjxOfERekC91N3yciQIq\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 929.901602646888,\n      \"y\": 632.4760859429873,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 518.076171875,\n      \"height\": 46,\n      \"seed\": 997763157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/20-workflow-syntax.md",
    "content": "# Workflow syntax\n\nThe Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status.\n\n:::note\nAn exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run.\n:::\n\n:::note\nWe support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility.\nRead more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3)\n:::\n\nExample steps:\n\n```yaml\nsteps:\n  - name: backend\n    image: golang\n    commands:\n      - go build\n      - go test\n  - name: frontend\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\nIn the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary.\n\nThe name is optional, if not added the steps will be numerated.\n\nAnother way to name a step is by using dictionaries:\n\n```yaml\nsteps:\n  backend:\n    image: golang\n    commands:\n      - go build\n      - go test\n  frontend:\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\n## Skip Commits\n\nWoodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive.\n\n```bash\ngit commit -m \"updated README [CI SKIP]\"\n```\n\n## Steps\n\nEvery step of your workflow executes commands inside a specified container.<br>\nThe defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).<br>\nThe associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\n### File changes are incremental\n\n- Woodpecker clones the source code in the beginning of the workflow\n- Changes to files are persisted through steps as the same volume is mounted to all steps\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"test content\" > myfile\n  - name: a-test-step\n    image: debian\n    commands:\n      - cat myfile\n```\n\n### `image`\n\nWoodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers.\n\nWhen using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands.\n\n```diff\n steps:\n   - name: build\n+    image: golang:1.6\n     commands:\n       - go build\n       - go test\n\n   - name: prettier\n+    image: woodpeckerci/plugin-prettier\n\n services:\n   - name: database\n+    image: mysql\n```\n\nWoodpecker supports any valid Docker image from any Docker registry:\n\n```yaml\nimage: golang\nimage: golang:1.7\nimage: library/golang:1.7\nimage: index.docker.io/library/golang\nimage: index.docker.io/library/golang:1.7\n```\n\nLearn more how you can use images from [different registries](./41-registries.md).\n\n### `pull`\n\nBy default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present.\n\nTo always pull the latest image when updates are available, use the `pull` option:\n\n```diff\n steps:\n   - name: build\n     image: golang:latest\n+    pull: true\n```\n\n### `commands`\n\nCommands of every step are executed serially as if you would enter them into your local shell.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\nThere is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script:\n\n```bash\n#!/bin/sh\nset -e\n\ngo build\ngo test\n```\n\nThe above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed:\n\n```bash\ndocker run --entrypoint=build.sh golang\n```\n\n:::note\nOnly build steps can define commands. You cannot use commands with plugins or services.\n:::\n\n### `entrypoint`\n\nAllows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `[\"/bin/sh\", \"-c\"]`).\n\nIf you define [`commands`](#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\nYou can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`.\n\n### `environment`\n\nWoodpecker provides the ability to pass environment variables to individual steps.\n\nFor more details, check the [environment docs](./50-environment.md).\n\n### `failure`\n\nSome of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n       - go build\n       - go test\n+    failure: ignore\n```\n\n### `when` - Conditional Execution\n\nWoodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true.\nA condition can be a check like:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - event: pull_request\n+        repo: test/test\n+      - event: push\n+        branch: main\n```\n\nThe `prettier` step is executed if one of these conditions is met:\n\n1. The pipeline is executed from a pull request in the repo `test/test`\n2. The pipeline is executed from a push to `main`\n\n#### `repo`\n\nExample conditional execution by repository:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - repo: test/test\n```\n\n#### `branch`\n\n:::note\nBranch conditions are not applied to tags.\n:::\n\nExample conditional execution by branch:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - branch: main\n```\n\n> The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only.\n\nExecute a step if the branch is `main` or `develop`:\n\n```yaml\nwhen:\n  - branch: [main, develop]\n```\n\nExecute a step if the branch starts with `prefix/*`:\n\n```yaml\nwhen:\n  - branch: prefix/*\n```\n\nThe branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples:\n\n- `*\\\\/*` to match patterns with exactly 1 `/`\n- `*\\\\/**` to match patters with at least 1 `/`\n- `*` to match patterns without `/`\n- `**` to match everything\n\nExecute a step using custom include and exclude logic:\n\n```yaml\nwhen:\n  - branch:\n      include: [main, release/*]\n      exclude: [release/1.0.0, release/1.1.*]\n```\n\n#### `event`\n\nThe available events are:\n\n- `push`: triggered when a commit is pushed to a branch.\n- `pull_request`: triggered when a pull request is opened or a new commit is pushed to it.\n- `pull_request_closed`: triggered when a pull request is closed or merged.\n- `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...).\n- `tag`: triggered when a tag is pushed.\n- `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).)\n- `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.)\n- `cron`: triggered when a cron job is executed.\n- `manual`: triggered when a user manually triggers a pipeline.\n\nExecute a step if the build event is a `tag`:\n\n```yaml\nwhen:\n  - event: tag\n```\n\nExecute a step if the pipeline event is a `push` to a specified branch:\n\n```diff\nwhen:\n  - event: push\n+   branch: main\n```\n\nExecute a step for multiple events:\n\n```yaml\nwhen:\n  - event: [push, tag, deployment]\n```\n\n#### `cron`\n\nThis filter **only** applies to cron events and filters based on the name of a cron job.\n\nMake sure to have a `event: cron` condition in the `when`-filters as well.\n\n```yaml\nwhen:\n  - event: cron\n    cron: sync_* # name of your cron job\n```\n\n[Read more about cron](./45-cron.md)\n\n#### `ref`\n\nThe `ref` filter compares the git reference against which the workflow is executed.\nThis allows you to filter, for example, tags that must start with **v**:\n\n```yaml\nwhen:\n  - event: tag\n    ref: refs/tags/v*\n```\n\n#### `status`\n\nThere are use cases for executing steps on failure, such as sending notifications for failed workflow/pipeline. Use the status constraint to execute steps even when the workflow fails:\n\n```diff\n steps:\n   - name: notify\n     image: alpine\n+    when:\n+      - status: [ success, failure ]\n```\n\n#### `platform`\n\n:::note\nThis condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch.\n:::\n\nExecute a step for a specific platform:\n\n```yaml\nwhen:\n  - platform: linux/amd64\n```\n\nExecute a step for a specific platform using wildcards:\n\n```yaml\nwhen:\n  - platform: [linux/*, windows/amd64]\n```\n\n#### `matrix`\n\nExecute a step for a single matrix permutation:\n\n```yaml\nwhen:\n  - matrix:\n      GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n```\n\n#### `instance`\n\nExecute a step only on a certain Woodpecker instance matching the specified hostname:\n\n```yaml\nwhen:\n  - instance: stage.woodpecker.company.com\n```\n\n#### `path`\n\n:::info\nPath conditions are applied only to **push** and **pull_request** events.\n:::\n\nExecute a step only on a pipeline with certain files being changed:\n\n```yaml\nwhen:\n  - path: 'src/*'\n```\n\nYou can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`.\n\nFor pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases.\n\n```yaml\nwhen:\n  - path:\n      include: ['.woodpecker/*.yaml', '*.ini']\n      exclude: ['*.md', 'docs/**']\n      ignore_message: '[ALL]'\n      on_empty: true\n```\n\n:::info\nPassing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting.\n:::\n\n#### `evaluate`\n\nExecute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression.\n\nThe expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library.\n\nRun on pushes to the default branch for the repository `owner/repo`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'\n```\n\nRun on commits created by user `woodpecker-ci`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n```\n\nSkip all commits containing `please ignore me` in the commit message:\n\n```yaml\nwhen:\n  - evaluate: 'not (CI_COMMIT_MESSAGE contains \"please ignore me\")'\n```\n\nRun on pull requests with the label `deploy`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"deploy\"'\n```\n\nSkip step only if `SKIP=true`, run otherwise or if undefined:\n\n```yaml\nwhen:\n  - evaluate: 'SKIP != \"true\"'\n```\n\n### `depends_on`\n\nNormally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`:\n\n```diff\n steps:\n   - name: build # build will be executed immediately\n     image: golang\n     commands:\n       - go build\n\n   - name: deploy\n     image: woodpeckerci/plugin-s3\n     settings:\n       bucket: my-bucket-name\n       source: some-file-name\n       target: /target/some-file\n+    depends_on: [build, test] # deploy will be executed after build and test finished\n\n   - name: test # test will be executed immediately as no dependencies are set\n     image: golang\n     commands:\n       - go test\n```\n\n:::note\nYou can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified.\n\n```yaml\nsteps:\n  - name: check code format\n    image: mstruebing/editorconfig-checker\n    depends_on: [] # enable parallel steps\n  ...\n```\n\n:::\n\n### `volumes`\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\nFor more details check the [volumes docs](./70-volumes.md).\n\n### `detach`\n\nWoodpecker gives the ability to detach steps to run them in background until the workflow finishes.\n\nFor more details check the [service docs](./60-services.md#detachment).\n\n### `directory`\n\nUsing `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run.\n\n### `backend_options`\n\nWith `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes.\n\nFurther details can be found in the documentation of the used backend:\n\n- [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration)\n- [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration)\n\n## `services`\n\nWoodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow.\n\nFor more details check the [services docs](./60-services.md).\n\n## `workspace`\n\nThe workspace defines the shared volume and working directory shared by all workflow steps.\nThe default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`).\nSo an example would be `/woodpecker/src/github.com/octocat/hello-world`.\n\nThe workspace can be customized using the workspace block in the YAML file:\n\n```diff\n+workspace:\n+  base: /go\n+  path: src/github.com/octocat/hello-world\n\n steps:\n   - name: build\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n```\n\n:::note\nPlugins will always have the workspace base at `/woodpecker`\n:::\n\nThe base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps.\n\n```diff\n workspace:\n+  base: /go\n   path: src/github.com/octocat/hello-world\n\n steps:\n   - name: deps\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n   - name: build\n     image: node:latest\n     commands:\n       - go build\n```\n\nThis would be equivalent to the following docker commands:\n\n```bash\ndocker volume create my-named-volume\n\ndocker run --volume=my-named-volume:/go golang:latest\ndocker run --volume=my-named-volume:/go node:latest\n```\n\nThe path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path.\n\n```diff\n workspace:\n   base: /go\n+  path: src/github.com/octocat/hello-world\n```\n\n```bash\ngit clone https://github.com/octocat/hello-world \\\n  /go/src/github.com/octocat/hello-world\n```\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `matrix`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations.\n\nFor more details check the [matrix build docs](./30-matrix-workflows.md).\n\n## `labels`\n\nYou can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent.\n\nTo specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo.\n\nWorkflow labels with an empty value are ignored.\nBy default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`.\n\n:::warning\nLabels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition.\n:::\n\nYou can add additional labels as a key value map:\n\n```diff\n+labels:\n+  location: europe # only agents with `location=europe` or `location=*` will be used\n+  weather: sun\n+  hostname: \"\" # this label will be ignored as it is empty\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\n### Filter by platform\n\nTo configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key.\nHave a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`.\n\nExample:\n\nAssuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`.\n\n```diff\n+labels:\n+  platform: linux/arm64\n\n steps:\n   [...]\n```\n\n## `variables`\n\nWoodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration.\n\nFor more details and examples check the [Advanced usage docs](./90-advanced-usage.md)\n\n## `clone`\n\nWoodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step.\n\nYou can manually configure the clone step in your workflow to customize it:\n\n```diff\n+clone:\n+  git:\n+    image: woodpeckerci/plugin-git\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\nExample configuration to override the depth:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n+    settings:\n+      partial: false\n+      depth: 50\n```\n\nExample configuration to use a custom clone plugin:\n\n```diff\n clone:\n   - name: git\n+    image: octocat/custom-git-plugin\n```\n\n### Git Submodules\n\nTo use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`:\n\n```diff\n [submodule \"my-module\"]\n path = my-module\n-url = git@github.com:octocat/my-module.git\n+url = https://github.com/octocat/my-module.git\n```\n\nTo use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n     settings:\n       recursive: true\n+      submodule_override:\n+        my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  ...\n```\n\n## `skip_clone`\n\n:::warning\nThe default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`.\n:::\n\nBy default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using:\n\n```yaml\nskip_clone: true\n```\n\n## `when` - Global workflow conditions\n\nWoodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue.\n\nFor more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution).\n\nExample conditional execution by branch:\n\n```diff\n+when:\n+  branch: main\n+\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n```\n\nThe workflow now triggers on `main`, but also if the target branch of a pull request is `main`.\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `depends_on`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword.\n\n## `runs_on`\n\nWorkflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example.\n\n## Advanced network options for steps\n\n:::warning\nOnly allowed if 'Trusted Network' option is enabled in repo settings by an admin.\n:::\n\n### `dns`\n\nIf the backend engine understands to change the DNS server and lookup domain,\nthis options will be used to alter the default DNS config to a custom one for a specific step.\n\n```yaml\nsteps:\n  - name: build\n    image: plugin/abc\n    dns: 1.2.3.4\n    dns_search: 'internal.company'\n```\n\n## Privileged mode\n\nWoodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities.\n\n:::info\nPrivileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     environment:\n       - DOCKER_HOST=tcp://docker:2375\n     commands:\n       - docker --tls=false ps\n\n services:\n   - name: docker\n     image: docker:dind\n     commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false\n+    privileged: true\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/25-workflows.md",
    "content": "# Workflows\n\nA pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps.\n\nIn case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow.\n\nBy placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored.\n\nYou can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md).\n\n## Benefits of using workflows\n\n- faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote\n- better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying\n- utilizing more agents to speed up the execution of the whole pipeline\n\n## Example workflow definition\n\n:::warning\nPlease note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow.\nIf you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket).\n:::\n\n```bash\n.woodpecker/\n├── build.yaml\n├── deploy.yaml\n├── lint.yaml\n└── test.yaml\n```\n\n```yaml title=\".woodpecker/build.yaml\"\nsteps:\n  - name: build\n    image: debian:stable-slim\n    commands:\n      - echo building\n      - sleep 5\n```\n\n```yaml title=\".woodpecker/deploy.yaml\"\nsteps:\n  - name: deploy\n    image: debian:stable-slim\n    commands:\n      - echo deploying\n\ndepends_on:\n  - lint\n  - build\n  - test\n```\n\n```yaml title=\".woodpecker/test.yaml\"\nsteps:\n  - name: test\n    image: debian:stable-slim\n    commands:\n      - echo testing\n      - sleep 5\n\ndepends_on:\n  - build\n```\n\n```yaml title=\".woodpecker/lint.yaml\"\nsteps:\n  - name: lint\n    image: debian:stable-slim\n    commands:\n      - echo linting\n      - sleep 5\n```\n\n## Status lines\n\nEach workflow will report its own status back to your forge.\n\n## Flow control\n\nThe workflows run in parallel on separate agents and share nothing.\n\nDependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully.\n\nThe name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`.\n\n```diff\n steps:\n   - name: deploy\n     image: debian:stable-slim\n     commands:\n       - echo deploying\n\n+depends_on:\n+  - lint\n+  - build\n+  - test\n```\n\nWorkflows that need to run even on failures should set the `runs_on` tag.\n\n```diff\n steps:\n   - name: notify\n     image: debian:stable-slim\n     commands:\n       - echo notifying\n\n depends_on:\n   - deploy\n\n+runs_on: [ success, failure ]\n```\n\n:::info\nSome workflows don't need the source code, like creating a notification on failure.\nRead more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone)\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/30-matrix-workflows.md",
    "content": "# Matrix workflows\n\nWoodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations.\n\n:::warning\nWoodpecker currently supports a maximum of **27 matrix axes** per workflow.\nIf your matrix exceeds this number, any additional axes will be silently ignored.\n:::\n\nExample matrix definition:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  REDIS_VERSION:\n    - 2.6\n    - 2.8\n    - 3.0\n```\n\nExample matrix definition containing only specific combinations:\n\n```yaml\nmatrix:\n  include:\n    - GO_VERSION: 1.4\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.6\n      REDIS_VERSION: 3.0\n```\n\n## Interpolation\n\nMatrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:8\n    - mysql:5\n    - mariadb:10.1\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go get\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: ${DATABASE}\n```\n\nExample YAML file after injecting the matrix parameters:\n\n```diff\n steps:\n   - name: build\n-    image: golang:${GO_VERSION}\n+    image: golang:1.4\n     commands:\n       - go get\n       - go build\n       - go test\n+    environment:\n+      - GO_VERSION=1.4\n+      - DATABASE=mysql:8\n\n services:\n   - name: database\n-    image: ${DATABASE}\n+    image: mysql:8\n```\n\n## Examples\n\n### Example matrix pipeline based on Docker image tag\n\n```yaml\nmatrix:\n  TAG:\n    - 1.7\n    - 1.8\n    - latest\n\nsteps:\n  - name: build\n    image: golang:${TAG}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline based on container image\n\n```yaml\nmatrix:\n  IMAGE:\n    - golang:1.7\n    - golang:1.8\n    - golang:latest\n\nsteps:\n  - name: build\n    image: ${IMAGE}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline using multiple platforms\n\n```yaml\nmatrix:\n  platform:\n    - linux/amd64\n    - linux/arm64\n\nlabels:\n  platform: ${platform}\n\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n\n  - name: test-arm-only\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n      - echo \"Arm is cool!\"\n    when:\n      platform: linux/arm*\n```\n\n:::note\nIf you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector).\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/40-secrets.md",
    "content": "# Secrets\n\nWoodpecker provides the ability to store named variables in a central secret store.\nThese secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`.\n\nThere are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins):\n\n1. **Repository secrets**: Available for all pipelines of a repository.\n1. **Organization secrets**: Available for all pipelines of an organization.\n1. **Global secrets**: Can only be set by instance administrators.\n   Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution.\n\nIn addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources.\n\n:::warning\nWoodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs.\n:::\n\n## Usage\n\nYou can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax.\n\nThe following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`:\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n     commands:\n+      - echo \"The secret is $TOKEN_ENV\"\n+    environment:\n+      TOKEN_ENV:\n+        from_secret: secret_token\n```\n\nThe same syntax can be used to pass secrets to (plugin) settings.\nA secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details).\n`PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution.\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n+    settings:\n+      TOKEN:\n+        from_secret: secret_token\n```\n\n### Escape secrets\n\nPlease note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts.\nIf secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing.\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n-      - echo ${TOKEN_ENV}\n+      - echo $${TOKEN_ENV}\n     environment:\n       TOKEN_ENV:\n         from_secret: secret_token\n```\n\n### Events filter\n\nBy default, secrets are not exposed to pull requests.\nHowever, you can change this behavior by creating the secret and enabling the `pull_request` event type.\nThis can be configured either via the UI or via the CLI.\n\n:::warning\nBe careful when exposing secrets for pull requests.\nIf your repository is public and accepts pull requests from everyone, your secrets may be at risk.\nMalicious actors could take advantage of this to expose your secrets or transfer them to an external location.\n:::\n\n### Plugins filter\n\nTo prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins.\nIf enabled, they are not available to any other plugins.\nPlugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets.\n\n:::tip\nIf you specify a tag, the filter will take it into account.\nHowever, if the same image appears several times in the list, the least privileged entry will take precedence.\nFor example, an image without a tag will allow all tags, even if it contains another entry with a tag attached.\n:::\n\n![plugins filter](./secrets-plugins-filter.png)\n\n## CLI\n\nIn addition to the UI, secrets can also be managed using the CLI.\n\nCreate the secret with the default settings.\nThe secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events).\n\n```bash\nwoodpecker-cli repo secret add \\\n  --repository octocat/hello-world \\\n  --name aws_access_key_id \\\n  --value <value>\n```\n\nCreate the secret and limit it to a single image:\n\n```diff\n woodpecker-cli secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secrets and limit it to a set of images:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n+  --image woodpeckerci/plugin-docker-buildx \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secret and enable it for multiple hook events:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n   --image woodpeckerci/plugin-s3 \\\n+  --event pull_request \\\n+  --event push \\\n+  --event tag \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nSecrets can be loaded from a file using the syntax `@`.\nThis method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example):\n\n```diff\n woodpecker-cli repo secret add \\\n   -repository octocat/hello-world \\\n   -name ssh_key \\\n+  -value @/root/ssh/id_rsa\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/41-registries.md",
    "content": "# Registries\n\nWoodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries.\n\n## Images from private registries\n\nYou must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file.\n\nThese credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin.\n\nExample configuration using a private image:\n\n```diff\n steps:\n   - name: build\n+    image: gcr.io/custom/golang\n     commands:\n       - go build\n       - go test\n```\n\nWoodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers.\n\nExample registry hostnames:\n\n- Image `gcr.io/foo/bar` has hostname `gcr.io`\n- Image `foo/bar` has hostname `docker.io`\n- Image `qux.com:8000/foo/bar` has hostname `qux.com:8000`\n\nExample registry hostname matching logic:\n\n- Hostname `gcr.io` matches image `gcr.io/foo/bar`\n- Hostname `docker.io` matches `golang`\n- Hostname `docker.io` matches `library/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang:latest`\n\n## Global registry support\n\nTo make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config).\n\n## GCR registry support\n\nFor specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file).\n\n## Local Images\n\n:::warning\nFor this, privileged rights are needed only available to admins. In addition, this only works when using a single agent.\n:::\n\nIt's possible to build a local image by mounting the docker socket as a volume.\n\nWith a `Dockerfile` at the root of the project:\n\n```yaml\nsteps:\n  - name: build-image\n    image: docker\n    commands:\n      - docker build --rm -t local/project-image .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  - name: build-project\n    image: local/project-image\n    commands:\n      - ./build.sh\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/45-cron.md",
    "content": "# Cron\n\nTo configure cron jobs you need at least push access to the repository.\n\n## Add a new cron job\n\n1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job:\n\n   ```diff\n    steps:\n      - name: sync_locales\n        image: weblate_sync\n        settings:\n          url: example.com\n          token:\n            from_secret: weblate_token\n   +    when:\n   +      event: cron\n   +      cron: \"name of the cron job\" # if you only want to execute this step by a specific cron job\n   ```\n\n2. Create a new cron job in the repository settings:\n\n   ![cron settings](./cron-settings.png)\n\n   The supported schedule syntax can be found at <https://pkg.go.dev/github.com/gdgvda/cron#hdr-CRON_Expression_Format>. If you need general understanding of the cron syntax <https://it-tools.tech/crontab-generator> is a good place to start and experiment.\n\n   Examples: `@every 5m`, `@daily`, `30 * * * *` ...\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/50-environment.md",
    "content": "# Environment variables\n\nWoodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables:\n\n```diff\n steps:\n   - name: build\n     image: golang\n+    environment:\n+      CGO: 0\n+      GOOS: linux\n+      GOARCH: amd64\n     commands:\n       - go build\n       - go test\n```\n\nPlease note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section.\n\n```diff\n steps:\n   - name: build\n     image: golang\n-    environment:\n-      - PATH=$PATH:/go\n     commands:\n+      - export PATH=$PATH:/go\n       - go build\n       - go test\n```\n\n:::warning\n`${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped:\n:::\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n-      - export PATH=${PATH}:/go\n+      - export PATH=$${PATH}:/go\n       - go build\n       - go test\n```\n\n## Built-in environment variables\n\nThis is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime.\n\n| NAME                               | Description                                                                                                        | Example                                                                                                    |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |\n| `CI`                               | CI environment name                                                                                                | `woodpecker`                                                                                               |\n|                                    | **Repository**                                                                                                     |                                                                                                            |\n| `CI_REPO`                          | repository full name `<owner>/<name>`                                                                              | `john-doe/my-repo`                                                                                         |\n| `CI_REPO_OWNER`                    | repository owner                                                                                                   | `john-doe`                                                                                                 |\n| `CI_REPO_NAME`                     | repository name                                                                                                    | `my-repo`                                                                                                  |\n| `CI_REPO_REMOTE_ID`                | repository remote ID, is the UID it has in the forge                                                               | `82`                                                                                                       |\n| `CI_REPO_URL`                      | repository web URL                                                                                                 | `https://git.example.com/john-doe/my-repo`                                                                 |\n| `CI_REPO_CLONE_URL`                | repository clone URL                                                                                               | `https://git.example.com/john-doe/my-repo.git`                                                             |\n| `CI_REPO_CLONE_SSH_URL`            | repository SSH clone URL                                                                                           | `git@git.example.com:john-doe/my-repo.git`                                                                 |\n| `CI_REPO_DEFAULT_BRANCH`           | repository default branch                                                                                          | `main`                                                                                                     |\n| `CI_REPO_PRIVATE`                  | repository is private                                                                                              | `true`                                                                                                     |\n| `CI_REPO_TRUSTED_NETWORK`          | repository has trusted network access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_VOLUMES`          | repository has trusted volumes access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_SECURITY`         | repository has trusted security access                                                                             | `false`                                                                                                    |\n|                                    | **Current Commit**                                                                                                 |                                                                                                            |\n| `CI_COMMIT_SHA`                    | commit SHA                                                                                                         | `eba09b46064473a1d345da7abf28b477468e8dbd`                                                                 |\n| `CI_COMMIT_REF`                    | commit ref                                                                                                         | `refs/heads/main`                                                                                          |\n| `CI_COMMIT_REFSPEC`                | commit ref spec                                                                                                    | `issue-branch:main`                                                                                        |\n| `CI_COMMIT_BRANCH`                 | commit branch (equals target branch for pull requests)                                                             | `main`                                                                                                     |\n| `CI_COMMIT_SOURCE_BRANCH`          | commit source branch (set only for pull request events)                                                            | `issue-branch`                                                                                             |\n| `CI_COMMIT_TARGET_BRANCH`          | commit target branch (set only for pull request events)                                                            | `main`                                                                                                     |\n| `CI_COMMIT_TAG`                    | commit tag name (empty if event is not `tag`)                                                                      | `v1.10.3`                                                                                                  |\n| `CI_COMMIT_PULL_REQUEST`           | commit pull request number (set only for pull request events)                                                      | `1`                                                                                                        |\n| `CI_COMMIT_PULL_REQUEST_LABELS`    | labels assigned to pull request (set only for pull request events)                                                 | `server`                                                                                                   |\n| `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events)                  | `summer-sprint`                                                                                            |\n| `CI_COMMIT_MESSAGE`                | commit message                                                                                                     | `Initial commit`                                                                                           |\n| `CI_COMMIT_AUTHOR`                 | commit author username                                                                                             | `john-doe`                                                                                                 |\n| `CI_COMMIT_AUTHOR_EMAIL`           | commit author email address                                                                                        | `john-doe@example.com`                                                                                     |\n| `CI_COMMIT_PRERELEASE`             | release is a pre-release (empty if event is not `release`)                                                         | `false`                                                                                                    |\n|                                    | **Current pipeline**                                                                                               |                                                                                                            |\n| `CI_PIPELINE_NUMBER`               | pipeline number                                                                                                    | `8`                                                                                                        |\n| `CI_PIPELINE_PARENT`               | number of parent pipeline                                                                                          | `0`                                                                                                        |\n| `CI_PIPELINE_EVENT`                | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                            | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PIPELINE_EVENT_REASON`         | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change              | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PIPELINE_URL`                  | link to the web UI for the pipeline                                                                                | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n| `CI_PIPELINE_FORGE_URL`            | link to the forge's web UI for the commit(s) or tag that triggered the pipeline                                    | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd`                 |\n| `CI_PIPELINE_DEPLOY_TARGET`        | pipeline deploy target for `deployment` events                                                                     | `production`                                                                                               |\n| `CI_PIPELINE_DEPLOY_TASK`          | pipeline deploy task for `deployment` events                                                                       | `migration`                                                                                                |\n| `CI_PIPELINE_CREATED`              | pipeline created UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_STARTED`              | pipeline started UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_FILES`                | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[\".woodpecker.yml\",\"README.md\"]`                                                                    |\n| `CI_PIPELINE_AUTHOR`               | pipeline author username                                                                                           | `octocat`                                                                                                  |\n| `CI_PIPELINE_AVATAR`               | pipeline author avatar                                                                                             | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | **Current workflow**                                                                                               |                                                                                                            |\n| `CI_WORKFLOW_NAME`                 | workflow name                                                                                                      | `release`                                                                                                  |\n|                                    | **Current step**                                                                                                   |                                                                                                            |\n| `CI_STEP_NAME`                     | step name                                                                                                          | `build package`                                                                                            |\n| `CI_STEP_NUMBER`                   | step number                                                                                                        | `0`                                                                                                        |\n| `CI_STEP_STARTED`                  | step started UNIX timestamp                                                                                        | `1722617519`                                                                                               |\n| `CI_STEP_URL`                      | URL to step in UI                                                                                                  | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n|                                    | **Previous commit**                                                                                                |                                                                                                            |\n| `CI_PREV_COMMIT_SHA`               | previous commit SHA                                                                                                | `15784117e4e103f36cba75a9e29da48046eb82c4`                                                                 |\n| `CI_PREV_COMMIT_REF`               | previous commit ref                                                                                                | `refs/heads/main`                                                                                          |\n| `CI_PREV_COMMIT_REFSPEC`           | previous commit ref spec                                                                                           | `issue-branch:main`                                                                                        |\n| `CI_PREV_COMMIT_BRANCH`            | previous commit branch                                                                                             | `main`                                                                                                     |\n| `CI_PREV_COMMIT_SOURCE_BRANCH`     | previous commit source branch (set only for pull request events)                                                   | `issue-branch`                                                                                             |\n| `CI_PREV_COMMIT_TARGET_BRANCH`     | previous commit target branch (set only for pull request events)                                                   | `main`                                                                                                     |\n| `CI_PREV_COMMIT_URL`               | previous commit link in forge                                                                                      | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_COMMIT_MESSAGE`           | previous commit message                                                                                            | `test`                                                                                                     |\n| `CI_PREV_COMMIT_AUTHOR`            | previous commit author username                                                                                    | `john-doe`                                                                                                 |\n| `CI_PREV_COMMIT_AUTHOR_EMAIL`      | previous commit author email address                                                                               | `john-doe@example.com`                                                                                     |\n|                                    | **Previous pipeline**                                                                                              |                                                                                                            |\n| `CI_PREV_PIPELINE_NUMBER`          | previous pipeline number                                                                                           | `7`                                                                                                        |\n| `CI_PREV_PIPELINE_PARENT`          | previous pipeline number of parent pipeline                                                                        | `0`                                                                                                        |\n| `CI_PREV_PIPELINE_EVENT`           | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                   | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PREV_PIPELINE_EVENT_REASON`    | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change         | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PREV_PIPELINE_URL`             | previous pipeline link in CI                                                                                       | `https://ci.example.com/repos/7/pipeline/7`                                                                |\n| `CI_PREV_PIPELINE_FORGE_URL`       | previous pipeline link to event in forge                                                                           | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_PIPELINE_DEPLOY_TARGET`   | previous pipeline deploy target for `deployment` events                                                            | `production`                                                                                               |\n| `CI_PREV_PIPELINE_DEPLOY_TASK`     | previous pipeline deploy task for `deployment` events                                                              | `migration`                                                                                                |\n| `CI_PREV_PIPELINE_STATUS`          | previous pipeline status                                                                                           | `success`, `failure`                                                                                       |\n| `CI_PREV_PIPELINE_CREATED`         | previous pipeline created UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_STARTED`         | previous pipeline started UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_FINISHED`        | previous pipeline finished UNIX timestamp                                                                          | `1722610383`                                                                                               |\n| `CI_PREV_PIPELINE_AUTHOR`          | previous pipeline author username                                                                                  | `octocat`                                                                                                  |\n| `CI_PREV_PIPELINE_AVATAR`          | previous pipeline author avatar                                                                                    | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | &emsp;                                                                                                             |                                                                                                            |\n| `CI_WORKSPACE`                     | Path of the workspace where source code gets cloned to                                                             | `/woodpecker/src/git.example.com/john-doe/my-repo`                                                         |\n|                                    | **System**                                                                                                         |                                                                                                            |\n| `CI_SYSTEM_NAME`                   | name of the CI system                                                                                              | `woodpecker`                                                                                               |\n| `CI_SYSTEM_URL`                    | link to CI system                                                                                                  | `https://ci.example.com`                                                                                   |\n| `CI_SYSTEM_HOST`                   | hostname of CI server                                                                                              | `ci.example.com`                                                                                           |\n| `CI_SYSTEM_VERSION`                | version of the server                                                                                              | `2.7.0`                                                                                                    |\n|                                    | **Forge**                                                                                                          |                                                                                                            |\n| `CI_FORGE_TYPE`                    | name of forge                                                                                                      | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab`                                   |\n| `CI_FORGE_URL`                     | root URL of configured forge                                                                                       | `https://git.example.com`                                                                                  |\n|                                    | **Internal** - Please don't use!                                                                                   |                                                                                                            |\n| `CI_SCRIPT`                        | Internal script path. Used to call pipeline step commands.                                                         |                                                                                                            |\n| `CI_NETRC_USERNAME`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_PASSWORD`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_MACHINE`                 | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n\n## Global environment variables\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nThese can be used, for example, to manage the image tag used by multiple projects.\n\n```ini\nWOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18\n```\n\n```diff\n steps:\n   - name: build\n-    image: golang:1.18\n+    image: golang:${GOLANG_VERSION}\n     commands:\n       - [...]\n```\n\n## String Substitution\n\nWoodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration.\n\nExample commit substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA}\n```\n\nExample tag substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG}\n```\n\n## String Operations\n\nWoodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values.\n\n| OPERATION          | DESCRIPTION                                      |\n| ------------------ | ------------------------------------------------ |\n| `${param}`         | parameter substitution                           |\n| `${param,}`        | parameter substitution with lowercase first char |\n| `${param,,}`       | parameter substitution with lowercase            |\n| `${param^}`        | parameter substitution with uppercase first char |\n| `${param^^}`       | parameter substitution with uppercase            |\n| `${param:pos}`     | parameter substitution with substring            |\n| `${param:pos:len}` | parameter substitution with substring and length |\n| `${param=default}` | parameter substitution with default              |\n| `${param##prefix}` | parameter substitution with prefix removal       |\n| `${param%%suffix}` | parameter substitution with suffix removal       |\n| `${param/old/new}` | parameter substitution with find and replace     |\n\nExample variable substitution with substring:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA:0:8}\n```\n\nExample variable substitution strips `v` prefix from `v.1.0.0`:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG##v}\n```\n\n## `pull_request_metadata` specific event reason values\n\nFor the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`.\n\n**GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list.\n\n:::note\nEvent reason values are forge-specific and may change between versions.\n:::\n\n| Event                | GitHub             | Gitea              | Forgejo            | GitLab             | Bitbucket | Bitbucket Datacenter | Description                                                                    |\n| -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ |\n| `assigned`           | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was assigned to a user                                            |\n| `converted_to_draft` | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Pull request was converted to a draft                                          |\n| `demilestoned`       | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was removed from a milestone                                      |\n| `description_edited` | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Description edited                                                             |\n| `edited`             | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:                | :x:       | :x:                  | The title or body of a pull request was edited, or the base branch was changed |\n| `label_added`        | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Pull had no labels and now got label(s) added                                  |\n| `label_cleared`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | All labels removed                                                             |\n| `label_updated`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | New label(s) added / label(s) changed                                          |\n| `locked`             | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was locked                                      |\n| `milestoned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was added to a milestone                                          |\n| `ready_for_review`   | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Draft pull request was marked as ready for review                              |\n| `review_requested`   | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | New review was requested                                                       |\n| `title_edited`       | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Title edited                                                                   |\n| `unassigned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | User was unassigned from a pull request                                        |\n| `unlabeled`          | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Label was removed from a pull request                                          |\n| `unlocked`           | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was unlocked                                    |\n\n**Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/51-plugins/20-creating-plugins.md",
    "content": "# Creating plugins\n\nCreating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT.\n\n## Settings\n\nTo allow users to configure the behavior of your plugin, you should use `settings:`.\n\nThese are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix.\nUsing a setting like `url` results in an env var named `PLUGIN_URL`.\n\nCharacters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`.\nCamelCase is not respected, `anInt` get `PLUGIN_ANINT`. <!-- cspell:ignore ANINT -->\n\n### Basic settings\n\nUsing any basic YAML type (scalar) will be converted into a string:\n\n| Setting              | Environment value            |\n| -------------------- | ---------------------------- |\n| `some-bool: false`   | `PLUGIN_SOME_BOOL=\"false\"`   |\n| `some_String: hello` | `PLUGIN_SOME_STRING=\"hello\"` |\n| `anInt: 3`           | `PLUGIN_ANINT=\"3\"`           |\n\n### Complex settings\n\nIt's also possible to use complex settings like this:\n\n```yaml\nsteps:\n  - name: plugin\n    image: foo/plugin\n    settings:\n      complex:\n        abc: 2\n        list:\n          - 2\n          - 3\n```\n\nValues like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{\"abc\": \"2\", \"list\": [ \"2\", \"3\" ]}`.\n\n### Secrets\n\nSecrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage).\n\n## Plugin library\n\nFor Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See <https://codeberg.org/woodpecker-plugins/go-plugin>.\n\n## Metadata\n\nIn your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins).\n\nSupported metadata:\n\n- `name`: The plugin's full name\n- `icon`: URL to your plugin's icon\n- `description`: A short description of what it's doing\n- `author`: Your name\n- `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin)\n- `containerImage`: name of the container image\n- `containerImageUrl`: link to the container image\n- `url`: homepage or repository of your plugin\n\nIf you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required.\n\n## Example plugin\n\nThis provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline.\n\n### What end users will see\n\nThe below example demonstrates how we might configure a webhook plugin in the YAML file:\n\n```yaml\nsteps:\n  - name: webhook\n    image: foo/webhook\n    settings:\n      url: https://example.com\n      method: post\n      body: |\n        hello world\n```\n\n### Write the logic\n\nCreate a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`.\n\n```bash\n#!/bin/sh\n\ncurl \\\n  -X ${PLUGIN_METHOD} \\\n  -d ${PLUGIN_BODY} \\\n  ${PLUGIN_URL}\n```\n\n### Package it\n\nCreate a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint.\n\n```dockerfile\n# please pin the version, e.g. alpine:3.19\nFROM alpine\nADD script.sh /bin/\nRUN chmod +x /bin/script.sh\nRUN apk -Uuv add curl ca-certificates\nENTRYPOINT /bin/script.sh\n```\n\nBuild and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community.\n\n```shell\ndocker build -t foo/webhook .\ndocker push foo/webhook\n```\n\nExecute your plugin locally from the command line to verify it is working:\n\n```shell\ndocker run --rm \\\n  -e PLUGIN_METHOD=post \\\n  -e PLUGIN_URL=https://example.com \\\n  -e PLUGIN_BODY=\"hello world\" \\\n  foo/webhook\n```\n\n## Best practices\n\n- Build your plugin for different architectures to allow many users to use them.\n  At least, you should support `amd64` and `arm64`.\n- Provide binaries for users using the `local` backend.\n  These should also be built for different OS/architectures.\n- Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible.\n- Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names.\n- Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)).\n- Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/51-plugins/51-overview.md",
    "content": "# Plugins\n\nPlugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline.\nPlugins can be used to deploy code, publish artifacts, send notification, and more.\n\nThey are automatically pulled from the default container registry the agent's have configured.\n\n```dockerfile title=\"Dockerfile\"\nFROM cloud/kubectl\nCOPY deploy /usr/local/deploy\nENTRYPOINT [\"/usr/local/deploy\"]\n```\n\n```bash title=\"deploy\"\nkubectl apply -f $PLUGIN_TEMPLATE\n```\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: deploy-to-k8s\n    image: cloud/my-k8s-plugin\n    settings:\n      template: config/k8s/service.yaml\n```\n\nExample pipeline using the Prettier and S3 plugins:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  - name: prettier\n    image: woodpeckerci/plugin-prettier\n\n  - name: publish\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      source: some-file-name\n      target: /target/some-file\n```\n\n## Plugin Isolation\n\nPlugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree.\nWhile normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author.\n\nThat's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically\nadjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands`\nor `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin\nanymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition.\n\n## Finding Plugins\n\nFor official plugins, you can use the Woodpecker plugin index:\n\n- [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins)\n\n:::tip\nThere are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking.\n\n- [Drone Plugins](http://plugins.drone.io)\n- [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/)\n- [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community)\n\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/51-plugins/_category_.yaml",
    "content": "label: 'Plugins'\n# position: 2\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/60-services.md",
    "content": "# Services\n\nWoodpecker provides a services section in the YAML file used for defining service containers.\nThe below configuration composes database and cache containers.\n\nServices are accessed using custom hostnames.\nIn the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`.\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n\n  - name: cache\n    image: redis\n```\n\nYou can define a port and a protocol explicitly:\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    ports:\n      - 3306\n\n  - name: wireguard\n    image: wg\n    ports:\n      - 51820/udp\n```\n\n## Configuration\n\nService containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more.\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    environment:\n+      MYSQL_DATABASE: test\n+      MYSQL_ALLOW_EMPTY_PASSWORD: yes\n\n   - name: cache\n     image: redis\n```\n\n## Detachment\n\nService and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required.\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n\n   - name: database\n     image: redis\n+    detach: true\n\n   - name: test\n     image: golang\n     commands:\n       - go test\n```\n\nContainers from detached steps will terminate when the pipeline ends.\n\n## Initialization\n\nService containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff.\n\n```diff\n steps:\n   - name: test\n     image: golang\n     commands:\n+      - sleep 15\n       - go get\n       - go test\n\n services:\n   - name: database\n     image: mysql\n```\n\n## Complete Pipeline Example\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ROOT_PASSWORD: example\nsteps:\n  - name: get-version\n    image: ubuntu\n    commands:\n      - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null\n      - sleep 30s # need to wait for mysql-server init\n      - echo 'SHOW VARIABLES LIKE \"version\"' | mysql -u root -h database test -p example\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/70-volumes.md",
    "content": "# Volumes\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\n:::note\nVolumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     commands:\n       - docker build --rm -t octocat/hello-world .\n       - docker run --rm octocat/hello-world --test\n       - docker push octocat/hello-world\n       - docker rmi octocat/hello-world\n     volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nIf you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`.\n\nPlease note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error.\n\n```diff\n-volumes: [ ./certs:/etc/ssl/certs ]\n+volumes: [ /etc/ssl/certs:/etc/ssl/certs ]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/72-extensions/40-configuration-extension.md",
    "content": "# Configuration extension\n\nThe configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n<!-- cSpell:words templating,Starlark,Jsonnet -->\n\n- Preprocess the original configuration file with something like Go templating\n- Convert custom attributes to Woodpecker attributes\n- Add defaults to the configuration like default steps\n- Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ...\n- Centralize configuration for multiple repositories in one place\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your configuration extension.\n\nThe global configuration will be called before the repository specific configuration extension if both are configured.\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc: Netrc;\n  configuration: {\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"configs\": [\n    {\n      \"name\": \".woodpecker.yaml\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from Repo (.woodpecker.yaml)\\\"\\n\"\n    }\n  ]\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  configs: {\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/72-extensions/_category_.yaml",
    "content": "label: 'Extensions'\n# position: 3\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'index'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/72-extensions/index.md",
    "content": "# Extensions\n\nWoodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints.\n\nThere is currently one type of extension available:\n\n- [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly.\n\n## Security\n\n:::warning\nYou need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful\ndata like malicious pipeline configurations that could be executed.\n:::\n\nTo prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair.\nTo verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign).\nYou can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page.\n\n## Example extensions\n\nA simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions)\n\n## Configuration\n\nTo prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of:\n\n- Built-in networks:\n  - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.\n  - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.\n  - `external`: A valid non-private unicast IP, you can access all hosts on public internet.\n  - `*`: All hosts are allowed.\n- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6\n- (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/72-linter.md",
    "content": "# Linter\n\nWoodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines.\n\n![errors and warnings in UI](./linter-warnings-errors.png)\n\n## Running the linter from CLI\n\nYou can run the linter also manually from the CLI:\n\n```shell\nwoodpecker-cli lint <workflow files>\n```\n\n## Bad habit warnings\n\nWoodpecker warns you if your configuration contains some bad habits.\n\n### Event filter for all steps\n\nAll your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well.\n\nExamples of an **incorrect** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n  - event: tag\n```\n\nThis will trigger the warning because the first item (`branch: main`) does not filter with an event.\n\n```yaml\nsteps:\n  - name: test\n    when:\n      branch: main\n\n  - name: deploy\n    when:\n      event: tag\n```\n\nExamples of a **correct** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n    event: push\n  - event: tag\n```\n\n```yaml\nsteps:\n  - name: test\n    when:\n      event: [tag, push]\n\n  - name: deploy\n    when:\n      - event: tag\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/75-project-settings.md",
    "content": "# Project settings\n\nAs the owner of a project in Woodpecker you can change project related settings via the web interface.\n\n![project settings](./project-settings.png)\n\n## Pipeline path\n\nThe path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`.\n\n## Repository hooks\n\nYour Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting.\n\n## Allow pull requests\n\nEnables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests.\n\n## Allow deployments\n\nEnables a pipeline to be started with the `deploy` event from a successful pipeline.\n\n:::danger\nOnly activate this option if you trust all users who have push access to your repository.\nOtherwise, these users will be able to steal secrets that are only available for `deploy` events.\n:::\n\n## Require approval for\n\nTo prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`.\n\n## Trusted\n\nIf you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes.\n\n:::note\n\nOnly server admins can set this option. If you are not a server admin this option won't be shown in your project settings.\n\n:::\n\n## Custom trusted clone plugins\n\nDuring the clone process, Git credentials (e.g., for private repositories) may be required.\nThese credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html).\n\nThese credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting.\n\nWith these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo.\nTo prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level.\nWithout an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step.\n\n:::info\nThis setting does not affect subsequent steps, nor does it allow direct pushes to the repository.\nTo enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push).\n:::\n\n## Project visibility\n\nYou can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners.\n\n- `Public` Every user can see your project without being logged in.\n- `Internal` Only authenticated users of the Woodpecker instance can see this project.\n- `Private` Only you and other owners of the repository can see this project.\n\n## Timeout\n\nAfter this timeout a pipeline has to finish or will be treated as timed out.\n\n## Cancel previous pipelines\n\nBy enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/80-badges.md",
    "content": "# Status Badges\n\nWoodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code.\n\n## Badge endpoint\n\n```uri\n<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n```\n\nThe status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter.\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<branch>\n```\n\nPlease note status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/90-advanced-usage.md",
    "content": "# Advanced usage\n\n## Advanced YAML syntax\n\nYAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config:\n\n### Anchors & aliases\n\nYou can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config.\n\nTo convert this:\n\n```yaml\nsteps:\n  - name: test\n    image: golang:1.18\n    commands: go test ./...\n  - name: build\n    image: golang:1.18\n    commands: build\n```\n\nJust add a new section called **variables** like this:\n\n```diff\n+variables:\n+  - &golang_image 'golang:1.18'\n\n steps:\n   - name: test\n-    image: golang:1.18\n+    image: *golang_image\n     commands: go test ./...\n   - name: build\n-    image: golang:1.18\n+    image: *golang_image\n     commands: build\n```\n\n### Map merges and overwrites\n\n```yaml\nvariables:\n  - &base-plugin-settings\n    target: dist\n    recursive: false\n    try: true\n  - &special-setting\n    special: true\n  - &some-plugin codeberg.org/6543/docker-images/print_env\n\nsteps:\n  - name: develop\n    image: *some-plugin\n    settings:\n      <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map\n    when:\n      branch: develop\n\n  - name: main\n    image: *some-plugin\n    settings:\n      <<: *base-plugin-settings # merge one map and ...\n      try: false # ... overwrite original value\n      ongoing: false # ... adding a new value\n    when:\n      branch: main\n```\n\n### Sequence merges\n\n```yaml\nvariables:\n  pre_cmds: &pre_cmds\n    - echo start\n    - whoami\n  post_cmds: &post_cmds\n    - echo stop\n  hello_cmd: &hello_cmd\n    - echo hello\n\nsteps:\n  - name: step1\n    image: debian\n    commands:\n      - <<: *pre_cmds # prepend a sequence\n      - echo exec step now do dedicated things\n      - <<: *post_cmds # append a sequence\n  - name: step2\n    image: debian\n    commands:\n      - <<: [*pre_cmds, *hello_cmd] # prepend two sequences\n      - echo echo from second step\n      - <<: *post_cmds\n```\n\n### References\n\n- [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases)\n- [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml)\n\n## Persisting environment data between steps\n\nOne can create a file containing environment variables, and then source it in each step that needs them.\n\n```yaml\nsteps:\n  - name: init\n    image: bash\n    commands:\n      - echo \"FOO=hello\" >> envvars\n      - echo \"BAR=world\" >> envvars\n\n  - name: debug\n    image: bash\n    commands:\n      - source ./envvars\n      - echo $FOO\n```\n\n## Declaring global variables\n\nAs described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables:\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nNote that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps.\n\n## Docker in docker (dind) setup\n\n:::warning\nThis set up will only work on trusted repositories and for security reasons should only be used in private environments.\nSee [project settings](./75-project-settings.md#trusted) to enable \"trusted\" mode.\n:::\n\nThe snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service.\n\n:::note\nIf your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead.\n:::\n\nFirst we need to define a service running a docker with the `dind` tag.\nThis service must run in `privileged` mode:\n\n```yaml\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    ports:\n      - 2376\n```\n\nNext, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28).\n\nThis can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below).\n\n```diff\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n+    environment:\n+      DOCKER_TLS_CERTDIR: /dind-certs\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n     ports:\n       - 2376\n```\n\nIn the docker client step:\n\n1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon.\n   These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them).\n2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`)\n\nTest the connection with the docker client:\n\n```diff\nsteps:\n  - name: test\n    image: docker:cli # in production use something like 'docker:<major version>-cli'\n+    environment:\n+      DOCKER_HOST: \"tcp://docker:2376\"\n+      DOCKER_CERT_PATH: \"/dind-certs/client\"\n+      DOCKER_TLS_VERIFY: \"1\"\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n```\n\nThis step should output the server and client version information if everything has been set up correctly.\n\nFull example:\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /dind-certs\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    ports:\n      - 2376\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/20-usage/_category_.yaml",
    "content": "label: 'Usage'\n# position: 2\ncollapsible: true\ncollapsed: false\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/00-general.md",
    "content": "# General\n\nWoodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`).\n\nThe **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files.\n\nThe **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance.\n\nThe **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time).\n\n:::tip\nYou can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent.\n:::\n\n## Database\n\nWoodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page.\n\n## Forge\n\nWhat would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page.\n\n## Container images\n\n:::info\nNo `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch.\n:::\n\n- `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image)\n  - `vX.Y`\n  - `vX`\n- `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0).\n  - `vX.Y-alpine`\n  - `vX-alpine`\n- `next`: Built from the `main` branch\n- `pull_<PR_ID>`: Images built from Pull Request branches.\n\nImages are pushed to DockerHub and Quay.\n\n- woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server))\n- woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent))\n- woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli))\n- woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler))\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/05-installation/10-docker-compose.md",
    "content": "# Docker Compose\n\nThis example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings.\n\nIt creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information.\n\nThe server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it.\n\n```yaml title=\"docker-compose.yaml\"\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:v3\n    ports:\n      - 8000:8000\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\n  woodpecker-agent:\n    image: woodpeckerci/woodpecker-agent:v3\n    command: agent\n    restart: always\n    depends_on:\n      - woodpecker-server\n    volumes:\n      - woodpecker-agent-config:/etc/woodpecker\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\nvolumes:\n  woodpecker-server-data:\n  woodpecker-agent-config:\n```\n\nWoodpecker must know its own address. You must therefore specify the public address in the format `<scheme>://<hostname>`. Please omit any trailing slashes:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_HOST=${WOODPECKER_HOST}\n```\n\nIt is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR}\n+      - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR}\n```\n\nIf the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_SECURE=true # defaults to false\n+      - WOODPECKER_GRPC_VERIFY=true # default\n```\n\nAs agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n+    volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nAgents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER=woodpecker-server:9000\n```\n\nThe server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Handling sensitive data\n\nThere are several options for handling sensitive data in `docker compose` or `docker swarm` configurations:\n\nFor Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure.\n\nAlternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret\n+    secrets:\n+      - woodpecker-agent-secret\n+\n+ secrets:\n+   woodpecker-agent-secret:\n+     external: true\n```\n\nTo store values in a docker secret you can use the following command:\n\n```bash\necho \"my_agent_secret_key\" | docker secret create woodpecker-agent-secret -\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/05-installation/20-helm-chart.md",
    "content": "# Helm Chart\n\nWoodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments:\n\n```bash\nhelm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version <VERSION>\n```\n\n## Metrics\n\nTo enable metrics gathering, set the following in values.yml:\n\n```yaml\nmetrics:\n  enabled: true\n  port: 9001\n```\n\nThis activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics.\n\nTo enable both Prometheus pod monitoring discovery, set:\n\n<!-- cspell:disable -->\n\n```yaml\nprometheus:\n  podmonitor:\n    enabled: true\n    interval: 60s\n    labels: {}\n```\n\n<!-- cspell:enable -->\n\nIf you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled:\n\n```yaml\n# Search all available namespaces\npodMonitorNamespaceSelector:\n  matchLabels: {}\n# Enable all available pod monitors\npodMonitorSelector:\n  matchLabels: {}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/05-installation/30-packages.md",
    "content": "# Distribution packages\n\n## Official packages\n\n- DEB\n- RPM\n\nThe pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution.\n\n```Shell\nRELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '\"tag_name\":\\s\"v\\K[^\"]+')\n\n# Debian/Ubuntu (x86_64)\ncurl -fLOOO \"https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\"\nsudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\n\n# CentOS/RHEL (x86_64)\nsudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm\n```\n\nThe package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-server.service\"\n[Unit]\nDescription=WoodpeckerCI server\nDocumentation=https://woodpecker-ci.org/docs/administration/server-config\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env\nConditionPathExists=/etc/woodpecker/woodpecker-server.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-server.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-server\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-server.env\"\nWOODPECKER_OPEN=true\nWOODPECKER_HOST=${WOODPECKER_HOST}\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\nWOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\nAfter installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-agent.service\"\n[Unit]\nDescription=WoodpeckerCI agent\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/agent\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env\nConditionPathExists=/etc/woodpecker/woodpecker-agent.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-agent.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-agent\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-agent.env\"\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Community packages\n\n:::info\nWoodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions.\n:::\n\n- [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=)\n- [Arch Linux](https://archlinux.org/packages/?q=woodpecker)\n- [openSUSE](https://software.opensuse.org/package/woodpecker)\n- [YunoHost](https://apps.yunohost.org/app/woodpecker)\n- [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html)\n- [Easypanel](https://easypanel.io/docs/templates/woodpeckerci)\n\n### NixOS\n\n:::info\nThis module is not maintained by the Woodpecker developers.\nIf you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained.\n:::\n\nIn theory, the NixOS installation is very similar to the binary installation and supports multiple backends.\nIn practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken.\n\n<!-- cspell:words Optimisation -->\n\n```nix\n{ config\n, ...\n}:\nlet\n  domain = \"woodpecker.example.org\";\nin\n{\n  # This automatically sets up certificates via let's encrypt\n  security.acme.defaults.email = \"acme@example.com\";\n  security.acme.acceptTerms = true;\n\n  # Setting up a nginx proxy that handles tls for us\n  services.nginx = {\n    enable = true;\n    openFirewall = true;\n    recommendedTlsSettings = true;\n    recommendedOptimisation = true;\n    recommendedProxySettings = true;\n    virtualHosts.\"${domain}\" = {\n      enableACME = true;\n      forceSSL = true;\n      locations.\"/\".proxyPass = \"http://localhost:3007\";\n    };\n  };\n\n  services.woodpecker-server = {\n    enable = true;\n    environment = {\n      WOODPECKER_HOST = \"https://${domain}\";\n      WOODPECKER_SERVER_ADDR = \":3007\";\n      WOODPECKER_OPEN = \"true\";\n    };\n    # You can pass a file with env vars to the system it could look like:\n    # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX\n    environmentFile = \"/path/to/my/secrets/file\";\n  };\n\n  # This sets up a woodpecker agent\n  services.woodpecker-agents.agents.\"docker\" = {\n    enable = true;\n    # We need this to talk to the podman socket\n    extraGroups = [ \"podman\" ];\n    environment = {\n      WOODPECKER_SERVER = \"localhost:9000\";\n      WOODPECKER_MAX_WORKFLOWS = \"4\";\n      DOCKER_HOST = \"unix:///run/podman/podman.sock\";\n      WOODPECKER_BACKEND = \"docker\";\n    };\n    # Same as with woodpecker-server\n    environmentFile = [ \"/var/lib/secrets/woodpecker.env\" ];\n  };\n\n  # Here we setup podman and enable dns\n  virtualisation.podman = {\n    enable = true;\n    defaultNetwork.settings = {\n      dns_enabled = true;\n    };\n  };\n  # This is needed for podman to be able to talk over dns\n  networking.firewall.interfaces.\"podman0\" = {\n    allowedUDPPorts = [ 53 ];\n    allowedTCPPorts = [ 53 ];\n  };\n}\n```\n\nAll configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/05-installation/_category_.yaml",
    "content": "label: 'Installation'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/10-server.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Server\n\n## Forge and User configuration\n\nWoodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge.\n\nYou can also restrict the registration:\n\n- closed registration and manually managing users with the CLI `woodpecker-cli user`\n- open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN`\n\n  ```ini\n  WOODPECKER_OPEN=false\n  WOODPECKER_ADMIN=john.smith,jane_doe\n  ```\n\n- open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS`\n\n  ```ini\n  WOODPECKER_OPEN=true\n  WOODPECKER_ORGS=dolores,dog-patch\n  ```\n\nAdministrators should also be explicitly set in your configuration.\n\n```ini\nWOODPECKER_ADMIN=john.smith,jane_doe\n```\n\n## Repository configuration\n\nWoodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here.\n\n```ini\nWOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user\n```\n\n## Databases\n\nThe default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind:\n\n- Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`.\n\n- Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably.\n\n- Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes.\n\n- Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice.\n\n### SQLite\n\nBy default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n+    volumes:\n+      - woodpecker-server-data:/var/lib/woodpecker/\n```\n\n### MySQL/MariaDB\n\nThe below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples.\nThe minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=mysql\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n```\n\n### PostgreSQL\n\nThe below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples.\nPlease use Postgres versions equal or higher than **11**.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=postgres\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable\n```\n\n## TLS\n\nWoodpecker supports SSL configuration by mounting certificates into your container.\n\n```ini\nWOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\nWOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n\nTLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library.\n\n### Container configuration\n\nIn addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     ports:\n+      - 80:80\n+      - 443:443\n       - 9000:9000\n```\n\nAdditionally, the certificate and key must be mounted and referenced:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\n+      - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n     volumes:\n+      - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt\n+      - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key\n```\n\n## Reverse Proxy\n\n### Apache\n\nThis guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration:\n\n<!-- cspell:ignore apacheconf -->\n\n```apacheconf\nProxyPreserveHost On\n\nRequestHeader set X-Forwarded-Proto \"https\"\n\nProxyPass / http://127.0.0.1:8000/\nProxyPassReverse / http://127.0.0.1:8000/\n```\n\nYou must have these Apache modules installed:\n\n- `proxy`\n- `proxy_http`\n\nYou must configure Apache to set `X-Forwarded-Proto` when using https.\n\n```diff\n ProxyPreserveHost On\n\n+RequestHeader set X-Forwarded-Proto \"https\"\n\n ProxyPass / http://127.0.0.1:8000/\n ProxyPassReverse / http://127.0.0.1:8000/\n```\n\n### Nginx\n\nThis guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide).\n\nExample configuration:\n\n```nginx\nserver {\n    listen 80;\n    server_name woodpecker.example.com;\n\n    location / {\n        proxy_set_header X-Forwarded-For $remote_addr;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Host $http_host;\n\n        proxy_pass http://127.0.0.1:8000;\n        proxy_redirect off;\n        proxy_http_version 1.1;\n        proxy_buffering off;\n\n        chunked_transfer_encoding off;\n    }\n}\n```\n\nYou must configure the proxy to set `X-Forwarded` proxy headers:\n\n```diff\n server {\n     listen 80;\n     server_name woodpecker.example.com;\n\n     location / {\n+        proxy_set_header X-Forwarded-For $remote_addr;\n+        proxy_set_header X-Forwarded-Proto $scheme;\n\n         proxy_pass http://127.0.0.1:8000;\n         proxy_redirect off;\n         proxy_http_version 1.1;\n         proxy_buffering off;\n\n         chunked_transfer_encoding off;\n     }\n }\n```\n\n### Caddy\n\nThis guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration:\n\n```caddy\n# expose WebUI and API\nwoodpecker.example.com {\n  reverse_proxy woodpecker-server:8000\n}\n\n# expose gRPC\nwoodpecker-agent.example.com {\n  reverse_proxy h2c://woodpecker-server:9000\n}\n```\n\n### Tunnelmole\n\n[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.\n\nStart by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation).\n\nAfter the installation, run the following command to start tunnelmole:\n\n```bash\ntmole 8000\n```\n\nIt will start a tunnel and will give a response like this:\n\n```bash\n➜  ~ tmole 8000\nhttp://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\nhttps://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\n```\n\nSet `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server.\n\n### Ngrok\n\n[Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command:\n\n```bash\nngrok http 8000\n```\n\nSet `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server.\n\n### Traefik\n\nTo install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https.\n\n<!-- cspell:words redirectscheme certresolver  -->\n\n```yaml\nservices:\n  server:\n    image: woodpeckerci/woodpecker-server:latest\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=your_admin_user\n      # other settings ...\n\n    networks:\n      - dmz # externally defined network, so that traefik can connect to the server\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n\n    deploy:\n      labels:\n        - traefik.enable=true\n\n        # web server\n        - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000\n\n        - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker-secure.tls=true\n        - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-secure.service=woodpecker-service\n\n        - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker.entrypoints=web\n        - traefik.http.routers.woodpecker.service=woodpecker-service\n\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker\n\n        #  gRPC service\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c\n\n        - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc-secure.tls=true\n        - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc\n\n        - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc.entrypoints=web\n        - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc\n\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker\n\nvolumes:\n  woodpecker-server-data:\n    driver: local\n\nnetworks:\n  dmz:\n    external: true\n```\n\n## Metrics\n\n### Endpoint\n\nWoodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above.\n\n```yaml\nglobal:\n  scrape_interval: 60s\n\nscrape_configs:\n  - job_name: 'woodpecker'\n    bearer_token: dummyToken...\n\n    static_configs:\n      - targets: ['woodpecker.domain.com']\n```\n\n### Authorization\n\nAn administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token: dummyToken...\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\nAs an alternative, the token can also be read from a file:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token_file: /etc/secrets/woodpecker-monitoring-token\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\n### Reference\n\nList of Prometheus metrics specific to Woodpecker:\n\n```yaml\n# HELP woodpecker_pipeline_count Pipeline count.\n# TYPE woodpecker_pipeline_count counter\nwoodpecker_pipeline_count{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\nwoodpecker_pipeline_count{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\n# HELP woodpecker_pipeline_time Build time.\n# TYPE woodpecker_pipeline_time gauge\nwoodpecker_pipeline_time{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 116\nwoodpecker_pipeline_time{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 155\n# HELP woodpecker_pipeline_total_count Total number of builds.\n# TYPE woodpecker_pipeline_total_count gauge\nwoodpecker_pipeline_total_count 1025\n# HELP woodpecker_pending_steps Total number of pending pipeline steps.\n# TYPE woodpecker_pending_steps gauge\nwoodpecker_pending_steps 0\n# HELP woodpecker_repo_count Total number of repos.\n# TYPE woodpecker_repo_count gauge\nwoodpecker_repo_count 9\n# HELP woodpecker_running_steps Total number of running pipeline steps.\n# TYPE woodpecker_running_steps gauge\nwoodpecker_running_steps 0\n# HELP woodpecker_user_count Total number of users.\n# TYPE woodpecker_user_count gauge\nwoodpecker_user_count 1\n# HELP woodpecker_waiting_steps Total number of pipeline waiting on deps.\n# TYPE woodpecker_waiting_steps gauge\nwoodpecker_waiting_steps 0\n# HELP woodpecker_worker_count Total number of workers.\n# TYPE woodpecker_worker_count gauge\nwoodpecker_worker_count 4\n```\n\n## External Configuration API\n\nTo provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service.\nBefore the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration.\n\nEvery request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/rfc9421) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`.\n\nA simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service)\n\n:::warning\nYou need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks.\n:::\n\n### Configuration\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig\n```\n\n#### Example request made by Woodpecker\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipe\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-file-name.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"https://example.com\",\n    \"login\": \"user\",\n    \"password\": \"password\"\n  }\n}\n```\n\n#### Example response structure\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n\n## UI customization\n\nWoodpecker supports custom JS and CSS files. These files must be present in the server's filesystem.\nThey can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment.\nThe configuration variables are independent of each other, which means it can be just one file present, or both.\n\n```ini\nWOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css\nWOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js\n```\n\nThe examples below show how to place a banner message in the top navigation bar of Woodpecker.\n\n```css title=\"woodpecker.css\"\n.banner-message {\n  position: absolute;\n  width: 280px;\n  height: 40px;\n  margin-left: 240px;\n  margin-top: 5px;\n  padding-top: 5px;\n  font-weight: bold;\n  background: red no-repeat;\n  text-align: center;\n}\n```\n\n```javascript title=\"woodpecker.js\"\n// place/copy a minified version of your preferred lightweight JavaScript library here ...\n!(function () {\n  'use strict';\n  function e() {} /*...*/\n})();\n\n$().ready(function () {\n  $('.app nav img').first().htmlAfter(\"<div class='banner-message'>This is a demo banner message :)</div>\");\n});\n```\n\n## Environment variables\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### LOG_FILE\n\n- Name: `WOODPECKER_LOG_FILE`\n- Default: `stderr`\n\nOutput destination for logs.\n'stdout' and 'stderr' can be used as special keywords.\n\n---\n\n### DATABASE_LOG\n\n- Name: `WOODPECKER_DATABASE_LOG`\n- Default: `false`\n\nEnable logging in database engine (currently xorm).\n\n---\n\n### DATABASE_LOG_SQL\n\n- Name: `WOODPECKER_DATABASE_LOG_SQL`\n- Default: `false`\n\nEnable logging of sql commands.\n\n---\n\n### DATABASE_MAX_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS`\n- Default: `100`\n\nMax database connections xorm is allowed create.\n\n---\n\n### DATABASE_IDLE_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS`\n- Default: `2`\n\nAmount of database connections xorm will hold open.\n\n---\n\n### DATABASE_CONNECTION_TIMEOUT\n\n- Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT`\n- Default: `3 Seconds`\n\nTime an active database connection is allowed to stay open.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOST\n\n- Name: `WOODPECKER_HOST`\n- Default: none\n\nServer fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix.\n\nExamples:\n\n- `WOODPECKER_HOST=http://woodpecker.example.org`\n- `WOODPECKER_HOST=http://example.org/woodpecker`\n- `WOODPECKER_HOST=http://example.org:1234/woodpecker`\n\n---\n\n### SERVER_ADDR\n\n- Name: `WOODPECKER_SERVER_ADDR`\n- Default: `:8000`\n\nConfigures the HTTP listener port.\n\n---\n\n### SERVER_ADDR_TLS\n\n- Name: `WOODPECKER_SERVER_ADDR_TLS`\n- Default: `:443`\n\nConfigures the HTTPS listener port when SSL is enabled.\n\n---\n\n### SERVER_CERT\n\n- Name: `WOODPECKER_SERVER_CERT`\n- Default: none\n\nPath to an SSL certificate used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_CERT=/path/to/cert.pem`\n\n---\n\n### SERVER_KEY\n\n- Name: `WOODPECKER_SERVER_KEY`\n- Default: none\n\nPath to an SSL certificate key used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_KEY=/path/to/key.pem`\n\n---\n\n### CUSTOM_CSS_FILE\n\n- Name: `WOODPECKER_CUSTOM_CSS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .CSS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css`\n\n---\n\n### CUSTOM_JS_FILE\n\n- Name: `WOODPECKER_CUSTOM_JS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .JS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js`\n\n---\n\n### GRPC_ADDR\n\n- Name: `WOODPECKER_GRPC_ADDR`\n- Default: `:9000`\n\nConfigures the gRPC listener port.\n\n---\n\n### GRPC_SECRET\n\n- Name: `WOODPECKER_GRPC_SECRET`\n- Default: `secret`\n\nConfigures the gRPC JWT secret.\n\n---\n\n### GRPC_SECRET_FILE\n\n- Name: `WOODPECKER_GRPC_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GRPC_SECRET` from the specified filepath.\n\n---\n\n### METRICS_SERVER_ADDR\n\n- Name: `WOODPECKER_METRICS_SERVER_ADDR`\n- Default: none\n\nConfigures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely.\n\nExample: `:9001`\n\n---\n\n### ADMIN\n\n- Name: `WOODPECKER_ADMIN`\n- Default: none\n\nComma-separated list of admin accounts.\n\nExample: `WOODPECKER_ADMIN=user1,user2`\n\n---\n\n### ORGS\n\n- Name: `WOODPECKER_ORGS`\n- Default: none\n\nComma-separated list of approved organizations.\n\nExample: `org1,org2`\n\n---\n\n### REPO_OWNERS\n\n- Name: `WOODPECKER_REPO_OWNERS`\n- Default: none\n\nRepositories by those owners will be allowed to be used in woodpecker.\n\nExample: `user1,user2`\n\n---\n\n### OPEN\n\n- Name: `WOODPECKER_OPEN`\n- Default: `false`\n\nEnable to allow user registration.\n\n---\n\n### AUTHENTICATE_PUBLIC_REPOS\n\n- Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`\n- Default: `false`\n\nAlways use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies.\n\n---\n\n### DEFAULT_ALLOW_PULL_REQUESTS\n\n- Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS`\n- Default: `true`\n\nThe default setting for allowing pull requests on a repo.\n\n---\n\n### DEFAULT_APPROVAL_MODE\n\n- Name: `WOODPECKER_DEFAULT_APPROVAL_MODE`\n- Default: `forks`\n\nThe default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`.\n\n---\n\n### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS\n\n- Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS`\n- Default: `pull_request, push`\n\nList of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\n\n---\n\n### DEFAULT_CLONE_PLUGIN\n\n- Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN`\n- Default: `docker.io/woodpeckerci/plugin-git`\n\nThe default docker image to be used when cloning the repo.\n\nIt is also added to the trusted clone plugin list.\n\n### DEFAULT_WORKFLOW_LABELS\n\n- Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS`\n- Default: none\n\nYou can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set.\n\nExample: `platform=linux/amd64,backend=docker`\n\n### DEFAULT_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`\n- Default: 60\n\nThe default time for a repo in minutes before a pipeline gets killed\n\n### MAX_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT`\n- Default: 120\n\nThe maximum time in minutes you can set in the repo settings before a pipeline gets killed\n\n---\n\n### SESSION_EXPIRES\n\n- Name: `WOODPECKER_SESSION_EXPIRES`\n- Default: `72h`\n\nConfigures the session expiration time.\nContext: when someone does log into Woodpecker, a temporary session token is created.\nAs long as the session is valid (until it expires or log-out),\na user can log into Woodpecker, without re-authentication.\n\n### PLUGINS_PRIVILEGED\n\n- Name: `WOODPECKER_PLUGINS_PRIVILEGED`\n- Default: none\n\nDocker images to run in privileged mode. Only change if you are sure what you do!\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n### PLUGINS_TRUSTED_CLONE\n\n- Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE`\n- Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git`\n\nPlugins which are trusted to handle the Git credential info in clone steps.\nIf a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos.\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n<!-- ---\n\n### `VOLUME`\n\n- Name: `WOODPECKER_VOLUME`\n- Default: none\n\nComma-separated list of Docker volumes that are mounted into every pipeline step.\n\nExample: `WOODPECKER_VOLUME=/path/on/host:/path/in/container:rw`| -->\n\n---\n\n### DOCKER_CONFIG\n\n- Name: `WOODPECKER_DOCKER_CONFIG`\n- Default: none\n\nConfigures a specific private registry config for all pipelines.\n\nExample: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json`\n\n---\n\n### ENVIRONMENT\n\n- Name: `WOODPECKER_ENVIRONMENT`\n- Default: none\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\nExample: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2`\n\n<!-- ---\n\n### NETWORK\n\n- Name: `WOODPECKER_NETWORK`\n- Default: none\n\nComma-separated list of Docker networks that are attached to every pipeline step.\n\nExample: `WOODPECKER_NETWORK=network1,network2` -->\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath\n\n---\n\n### DISABLE_USER_AGENT_REGISTRATION\n\n- Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION`\n- Default: false\n\nBy default, users can create new agents for their repos they have admin access to.\nIf an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements.\n\n:::note\nYou should set this option if you have, for example,\nglobal secrets and don't trust your users to create a rogue agent and pipeline for secret extraction.\n:::\n\n---\n\n### KEEPALIVE_MIN_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_MIN_TIME`\n- Default: none\n\nServer-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\n\nExample: `WOODPECKER_KEEPALIVE_MIN_TIME=10s`\n\n---\n\n### DATABASE_DRIVER\n\n- Name: `WOODPECKER_DATABASE_DRIVER`\n- Default: `sqlite3`\n\nThe database driver name. Possible values are `sqlite3`, `mysql` or `postgres`.\n\n---\n\n### DATABASE_DATASOURCE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE`\n- Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container\n\nThe database connection string. The default value is the path of the embedded SQLite database file.\n\nExample:\n\n```bash\n# MySQL\n# https://github.com/go-sql-driver/mysql#dsn-data-source-name\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n\n# PostgreSQL\n# https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable\n```\n\n---\n\n### DATABASE_DATASOURCE_FILE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath\n\n---\n\n### PROMETHEUS_AUTH_TOKEN\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN`\n- Default: none\n\nToken to secure the Prometheus metrics endpoint.\nMust be set to enable the endpoint.\n\n---\n\n### PROMETHEUS_AUTH_TOKEN_FILE\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath\n\n---\n\n### STATUS_CONTEXT\n\n- Name: `WOODPECKER_STATUS_CONTEXT`\n- Default: `ci/woodpecker`\n\nContext prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository.\n\n---\n\n### STATUS_CONTEXT_FORMAT\n\n- Name: `WOODPECKER_STATUS_CONTEXT_FORMAT`\n- Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}`\n\nTemplate for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language.\nSupported variables:\n\n- `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`)\n- `event`: the event which started the pipeline\n- `workflow`: the workflow's name\n- `owner`: the repo's owner\n- `repo`: the repo's name\n\n---\n\n### CONFIG_SERVICE_ENDPOINT\n\n- Name: `WOODPECKER_CONFIG_SERVICE_ENDPOINT`\n- Default: none\n\nSpecify a configuration service endpoint, see [Configuration Extension](#external-configuration-api)\n\n---\n\n### EXTENSIONS_ALLOWED_HOSTS\n\n- Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS`\n- Default: `external`\n\nComma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list.\n\n---\n\n### FORGE_TIMEOUT\n\n- Name: `WOODPECKER_FORGE_TIMEOUT`\n- Default: 5s\n\nSpecify timeout when fetching the Woodpecker configuration from forge. See <https://pkg.go.dev/time#ParseDuration> for syntax reference.\n\n---\n\n### FORGE_RETRY\n\n- Name: `WOODPECKER_FORGE_RETRY`\n- Default: 3\n\nSpecify how many retries of fetching the Woodpecker configuration from a forge are done before we fail.\n\n---\n\n### ENABLE_SWAGGER\n\n- Name: `WOODPECKER_ENABLE_SWAGGER`\n- Default: true\n\nEnable the Swagger UI for API documentation.\n\n---\n\n### DISABLE_VERSION_CHECK\n\n- Name: `WOODPECKER_DISABLE_VERSION_CHECK`\n- Default: false\n\nDisable version check in admin web UI.\n\n---\n\n### LOG_STORE\n\n- Name: `WOODPECKER_LOG_STORE`\n- Default: `database`\n\nWhere to store logs. Possible values:\n\n- `database`: stores the logs in the database\n- `file`: stores logs in JSON files on the files system\n- `addon`: uses an [addon](./100-addons.md#log) to store logs\n\n---\n\n### LOG_STORE_FILE_PATH\n\n- Name: `WOODPECKER_LOG_STORE_FILE_PATH`\n- Default: none\n\nIf [`WOODPECKER_LOG_STORE`](#log_store) is:\n\n- `file`: Directory to store logs in\n- `addon`: The path to the addon executable\n\n---\n\n### EXPERT_WEBHOOK_HOST\n\n- Name: `WOODPECKER_EXPERT_WEBHOOK_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### EXPERT_FORGE_OAUTH_HOST\n\n- Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified public forge URL, used if forge url is not a public URL. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### GITHUB\\_\\*\n\nSee [GitHub configuration](./12-forges/20-github.md#configuration)\n\n---\n\n### GITEA\\_\\*\n\nSee [Gitea configuration](./12-forges/30-gitea.md#configuration)\n\n---\n\n### BITBUCKET\\_\\*\n\nSee [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration)\n\n---\n\n### GITLAB\\_\\*\n\nSee [GitLab configuration](./12-forges/40-gitlab.md#configuration)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/100-addons.md",
    "content": "# Addons\n\nAddons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service.\n\n:::warning\nAddon forges are still experimental. Their implementation can change and break at any time.\n:::\n\n:::danger\nYou must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information.\n:::\n\n## Usage\n\nTo use an addon forge, download the correct addon version.\n\n### Forge\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file\n```\n\nIn case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`.\n\n#### List of addon forges\n\n- [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws).\n\n### Log\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_LOG_STORE=addon\nWOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file\n```\n\n## Developing addon forges\n\nSee [Addons](../../92-development/100-addons.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/10-docker.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Docker\n\nThis is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent.\n\n## Private registries\n\nWoodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config).\n\nTo add your credential helper to the Woodpecker server container you could use the following code to build a custom image:\n\n```dockerfile\nFROM woodpeckerci/woodpecker-server:latest-alpine\n\nRUN apk add -U --no-cache docker-credential-ecr-login\n```\n\n## Step specific configuration\n\n### Run user\n\nBy default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group:\n\n```yaml\nsteps:\n  - name: example\n    image: alpine\n    commands:\n      - whoami\n    backend_options:\n      docker:\n        user: 65534:65534\n```\n\nThe syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag.\n\n## Tips and tricks\n\n### Image cleanup\n\nThe agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.\n\n:::danger\nThe following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation.\n:::\n\n- Remove all unused images\n\n  <!-- cspell:ignore trunc -->\n\n  ```bash\n  docker image rm $(docker images --filter \"dangling=true\" -q --no-trunc)\n  ```\n\n- Remove Woodpecker volumes\n\n  ```bash\n  docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true  -q)\n  ```\n\n### Podman\n\nThere is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog).\n\n## Environment variables\n\n### BACKEND_DOCKER_NETWORK\n\n- Name: `WOODPECKER_BACKEND_DOCKER_NETWORK`\n- Default: none\n\nSet to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other!\n\n---\n\n### BACKEND_DOCKER_ENABLE_IPV6\n\n- Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6`\n- Default: `false`\n\nEnable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6.\n\n---\n\n### BACKEND_DOCKER_VOLUMES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n- Default: none\n\nList of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA\ncertificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM_SWAP\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_SHM_SIZE\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE`\n- Default: `0`\n\nThe maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_QUOTA\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA`\n- Default: `0`\n\nThe number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SHARES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES`\n- Default: `0`\n\nThe relative weight vs. other containers.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SET\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET`\n- Default: none\n\nComma-separated list to limit the specific CPUs or cores a pipeline container can use.\n\nExample: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/20-kubernetes.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Kubernetes\n\nThe Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps.\n\n## Metadata labels\n\nWoodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies.\n\nThe following metadata labels are supported:\n\n- `woodpecker-ci.org/forge-id`\n- `woodpecker-ci.org/repo-forge-id`\n- `woodpecker-ci.org/repo-id`\n- `woodpecker-ci.org/repo-name`\n- `woodpecker-ci.org/repo-full-name`\n- `woodpecker-ci.org/branch`\n- `woodpecker-ci.org/org-id`\n- `woodpecker-ci.org/task-uuid`\n- `woodpecker-ci.org/step`\n\n## Private registries\n\nIn addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML.\n\nPlace these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.\n\n## Step specific configuration\n\n### Resources\n\nThe Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory.\nWe recommend to add a `resources` definition to all steps to ensure efficient scheduling.\n\nHere is an example definition with an arbitrary `resources` definition below the `backend_options` section:\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        resources:\n          requests:\n            memory: 200Mi\n            cpu: 100m\n          limits:\n            memory: 400Mi\n            cpu: 1000m\n```\n\nYou can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis.\n\n### Runtime class\n\n`runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes.\n\n### Service account\n\n`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        # Use the service account `default` in the current namespace.\n        # This usually the same as wherever woodpecker is deployed.\n        serviceAccountName: default\n```\n\nTo give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)\n\n### Node selector\n\n`nodeSelector` specifies the labels which are used to select the node on which the step will be executed.\n\nLabels defined here will be appended to a list which already contains `\"kubernetes.io/arch\"`.\nBy default `\"kubernetes.io/arch\"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`.\nWithout a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures.\n\nTo overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`.\nA practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture.\nIn this case, one must define an arbitrary key in the matrix section of the respective matrix element:\n\n```yaml\nmatrix:\n  include:\n    - NAME: runner1\n      ARCH: arm64\n```\n\nAnd then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var:\n\n```yaml\n[...]\n    backend_options:\n      kubernetes:\n        nodeSelector:\n          kubernetes.io/arch: \"${ARCH}\"\n```\n\nYou can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent\nor [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis.\n\n### Tolerations\n\nWhen you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations.\n\nExample pipeline configuration:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go get\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        serviceAccountName: 'my-service-account'\n        resources:\n          requests:\n            memory: 128Mi\n            cpu: 1000m\n          limits:\n            memory: 256Mi\n        nodeSelector:\n          beta.kubernetes.io/instance-type: Standard_D2_v3\n        tolerations:\n          - key: 'key1'\n            operator: 'Equal'\n            value: 'value1'\n            effect: 'NoSchedule'\n            tolerationSeconds: 3600\n```\n\n### Volumes\n\nTo mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.\n\nPersistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference.\n\n_If your PVC is not highly available or NFS-based, you may also need to integrate affinity settings to ensure that your steps are executed on the correct node._\n\nNOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver:\n\n```yaml\naccessModes:\n  - ReadWriteMany\n```\n\nAssuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step:\n\n```yaml\nsteps:\n  - name: \"Restore Cache\"\n    image: meltwater/drone-cache\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    settings:\n      mount:\n        - \"woodpecker-cache\"\n    [...]\n```\n\nOr as follows when using a normal image:\n\n```yaml\nsteps:\n  - name: \"Edit cache\"\n    image: alpine:latest\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    commands:\n      - echo \"Hello World\" > /woodpecker/src/cache/output.txt\n    [...]\n```\n\n### Security context\n\nUse the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:\n\n```yaml\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo Hello world\n    backend_options:\n      kubernetes:\n        securityContext:\n          runAsUser: 999\n          runAsGroup: 999\n          privileged: true\n    [...]\n```\n\nNote that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object.\nBy default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the\nconfiguration shown above will result in something like the following Pod spec:\n\n<!-- cspell:disable -->\n\n```yaml\nkind: Pod\nspec:\n  securityContext:\n    runAsUser: 999\n    runAsGroup: 999\n  containers:\n    - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0\n      image: alpine\n      securityContext:\n        privileged: true\n  [...]\n```\n\n<!-- cspell:enable -->\n\nYou can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      seccompProfile:\n        type: Localhost\n        localhostProfile: profiles/audit.json\n```\n\nor restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      apparmorProfile:\n        type: Localhost\n        localhostProfile: k8s-apparmor-example-deny-write\n```\n\nor configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always')\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      fsGroupChangePolicy: OnRootMismatch\n```\n\n:::note\nThe feature requires Kubernetes v1.30 or above.\n:::\n\n### Annotations and labels\n\nYou can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration:\n\n```yaml\nbackend_options:\n  kubernetes:\n    annotations:\n      workflow-group: alpha\n      io.kubernetes.cri-o.Devices: /dev/fuse\n    labels:\n      environment: ci\n      app.kubernetes.io/name: builder\n```\n\nIn order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent:\n[WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step).\n\n## Tips and tricks\n\n### CRI-O\n\nCRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration:\n\n```yaml\nworkspace:\n  base: '/woodpecker'\n  path: '/'\n```\n\nSee [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details.\n\n### `KUBERNETES_SERVICE_HOST` environment variable\n\nLike the below env vars used for configuration, this can be set in the environment for configuration of the agent.\nIt configures the address of the Kubernetes API server to connect to.\n\nIf running the agent within Kubernetes, this will already be set and you don't have to add it manually.\n\n## Environment variables\n\nThese env vars can be set in the `env:` sections of the agent.\n\n---\n\n### BACKEND_K8S_NAMESPACE\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE`\n- Default: `woodpecker`\n\nThe namespace to create worker Pods in.\n\n---\n\n### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION`\n- Default: `false`\n\nEnables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation.\n\nWith this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker.\n\n### BACKEND_K8S_VOLUME_SIZE\n\n- Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE`\n- Default: `10G`\n\nThe volume size of the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS`\n- Default: none\n\nThe storage class to use for the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_RWX\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX`\n- Default: `true`\n\nDetermines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead.\n\n---\n\n### BACKEND_K8S_POD_LABELS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS`\n- Default: none\n\nAdditional labels to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-label\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if additional Pod labels can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS`\n- Default: none\n\nAdditional annotations to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-annotation\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if Pod annotations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS`\n- Default: none\n\nAdditional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{\"effect\":\"NoSchedule\",\"key\":\"jobs\",\"operator\":\"Exists\"}]`.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP`\n- Default: `true`\n\nDetermines if Pod tolerations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_NODE_SELECTOR\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR`\n- Default: none\n\nAdditional node selector to apply to worker pods. Must be a YAML object, e.g. `{\"topology.kubernetes.io/region\":\"eu-central-1\"}`.\n\n---\n\n### BACKEND_K8S_SECCTX_NONROOT <!-- cspell:ignore SECCTX NONROOT -->\n\n- Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT`\n- Default: `false`\n\nDetermines if containers must be required to run as non-root users.\n\n---\n\n### BACKEND_K8S_PULL_SECRET_NAMES\n\n- Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`\n- Default: none\n\nSecret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\n\n---\n\n### BACKEND_K8S_PRIORITY_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS`\n- Default: none, which will use the default priority class configured in Kubernetes\n\nWhich [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/30-local.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Local\n\n:::danger\nThe local backend executes pipelines on the local system without any isolation.\n:::\n\n:::note\nCurrently we do not support [services](../../../20-usage/60-services.md) for this backend.\n[Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095).\n:::\n\nSince the commands run directly in the same context as the agent (same user, same\nfilesystem), a malicious pipeline could be used to access the agent\nconfiguration especially the `WOODPECKER_AGENT_SECRET` variable.\n\nIt is recommended to use this backend only for private setup where the code and\npipeline can be trusted. It should not be used in a public instance where\nanyone can submit code or add new repositories. The agent should not run as a privileged user (root).\n\nThe local backend will use a random directory in `$TMPDIR` to store the cloned\ncode and execute commands.\n\nIn order to use this backend, you need to download (or build) the\n[agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine.\n\n## Step specific configuration\n\n### Shell\n\nThe `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is\nused to run the commands.\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: bash\n    commands: [...]\n```\n\n### Plugins\n\n```yaml\nsteps:\n  - name: build\n    image: /usr/bin/tree\n```\n\nIf no commands are provided, plugins are treated in the usual manner.\nIn the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path.\n\n## Environment variables\n\n### BACKEND_LOCAL_TEMP_DIR\n\n- Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR`\n- Default: default temp directory\n\nDirectory to create folders for workflows.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/50-custom.md",
    "content": "# Custom\n\nIf none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend:\n\n```go\npackage main\n\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n  backendTypes \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc main() {\n  core.RunAgent([]backendTypes.Backend{\n    yourBackend,\n  })\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/_category_.yaml",
    "content": "label: 'Backends'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/11-overview.md",
    "content": "# Forges\n\n## Supported features\n\n| Feature                                                                                                                | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |\n| ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- |\n| Event: Push                                                                                                            | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Tag                                                                                                             | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Pull-Request                                                                                                    | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Release                                                                                                         | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| Event: Deploy¹                                                                                                         | :white_check_mark:     | :x:                  | :x:                      | :x:                    | :x:                          | :x:                                                |\n| [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| [Multiple workflows](../../../20-usage/25-workflows.md)                                                                | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| [when.path filter](../../../20-usage/20-workflow-syntax.md#path)                                                       | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n\n¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks.\n\nIn addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/20-github.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitHub\n\nWoodpecker comes with built-in support for GitHub and GitHub Enterprise.\nTo use Woodpecker with GitHub the following environment variables should be set for the server component:\n\n```ini\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID\nWOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET\n```\n\nYou will get these values from GitHub when you register your OAuth application.\nTo do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App.\n\n:::warning\nDo not use a \"GitHub App\" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically)\n:::\n\n## App Settings\n\n- Name: An arbitrary name for your App\n- Homepage URL: The URL of your Woodpecker instance\n- Callback URL: `https://<your-woodpecker-instance>/authorize`\n- (optional) Upload the Woodpecker Logo: <https://avatars.githubusercontent.com/u/84780935?s=200&v=4>\n\n## Client Secret Creation\n\nAfter your App has been created, you can generate a client secret.\nUse this one for the `WOODPECKER_GITHUB_SECRET` environment variable.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITHUB\n\n- Name: `WOODPECKER_GITHUB`\n- Default: `false`\n\nEnables the GitHub driver.\n\n---\n\n### GITHUB_URL\n\n- Name: `WOODPECKER_GITHUB_URL`\n- Default: `https://github.com`\n\nConfigures the GitHub server address.\n\n---\n\n### GITHUB_CLIENT\n\n- Name: `WOODPECKER_GITHUB_CLIENT`\n- Default: none\n\nConfigures the GitHub OAuth client id to authorize access.\n\n---\n\n### GITHUB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITHUB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath.\n\n---\n\n### GITHUB_SECRET\n\n- Name: `WOODPECKER_GITHUB_SECRET`\n- Default: none\n\nConfigures the GitHub OAuth client secret. This is used to authorize access.\n\n---\n\n### GITHUB_SECRET_FILE\n\n- Name: `WOODPECKER_GITHUB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath.\n\n---\n\n### GITHUB_MERGE_REF\n\n- Name: `WOODPECKER_GITHUB_MERGE_REF`\n- Default: `true`\n\n---\n\n### GITHUB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITHUB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### GITHUB_PUBLIC_ONLY\n\n- Name: `WOODPECKER_GITHUB_PUBLIC_ONLY`\n- Default: `false`\n\nConfigures the GitHub OAuth client to only obtain a token that can manage public repositories.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/30-gitea.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Gitea\n\nWoodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITEA=true\nWOODPECKER_GITEA_URL=YOUR_GITEA_URL\nWOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT\nWOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET\n```\n\n## Gitea on the same host with containers\n\nIf you have Gitea also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `gitea`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea\n```\n\n## Registration\n\nRegister your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook).\n\n![gitea oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITEA\n\n- Name: `WOODPECKER_GITEA`\n- Default: `false`\n\nEnables the Gitea driver.\n\n---\n\n### GITEA_URL\n\n- Name: `WOODPECKER_GITEA_URL`\n- Default: `https://try.gitea.io`\n\nConfigures the Gitea server address.\n\n---\n\n### GITEA_CLIENT\n\n- Name: `WOODPECKER_GITEA_CLIENT`\n- Default: none\n\nConfigures the Gitea OAuth client id. This is used to authorize access.\n\n---\n\n### GITEA_CLIENT_FILE\n\n- Name: `WOODPECKER_GITEA_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath\n\n---\n\n### GITEA_SECRET\n\n- Name: `WOODPECKER_GITEA_SECRET`\n- Default: none\n\nConfigures the Gitea OAuth client secret. This is used to authorize access.\n\n---\n\n### GITEA_SECRET_FILE\n\n- Name: `WOODPECKER_GITEA_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_SECRET` from the specified filepath\n\n---\n\n### GITEA_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITEA_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/35-forgejo.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Forgejo\n\nWoodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_FORGEJO=true\nWOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL\nWOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT\nWOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET\n```\n\n## Forgejo on the same host with containers\n\nIf you have Forgejo also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `forgejo`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo\n```\n\n## Registration\n\nRegister your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).\n\n![forgejo oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### FORGEJO\n\n- Name: `WOODPECKER_FORGEJO`\n- Default: `false`\n\nEnables the Forgejo driver.\n\n---\n\n### FORGEJO_URL\n\n- Name: `WOODPECKER_FORGEJO_URL`\n- Default: `https://next.forgejo.org`\n\nConfigures the Forgejo server address.\n\n---\n\n### FORGEJO_CLIENT\n\n- Name: `WOODPECKER_FORGEJO_CLIENT`\n- Default: none\n\nConfigures the Forgejo OAuth client id. This is used to authorize access.\n\n---\n\n### FORGEJO_CLIENT_FILE\n\n- Name: `WOODPECKER_FORGEJO_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath\n\n---\n\n### FORGEJO_SECRET\n\n- Name: `WOODPECKER_FORGEJO_SECRET`\n- Default: none\n\nConfigures the Forgejo OAuth client secret. This is used to authorize access.\n\n---\n\n### FORGEJO_SECRET_FILE\n\n- Name: `WOODPECKER_FORGEJO_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath\n\n---\n\n### FORGEJO_SKIP_VERIFY\n\n- Name: `WOODPECKER_FORGEJO_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/40-gitlab.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitLab\n\nWoodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITLAB=true\nWOODPECKER_GITLAB_URL=http://gitlab.mycompany.com\nWOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82\nWOODPECKER_GITLAB_SECRET=30f5064039e6b359e075\n```\n\n## Registration\n\nYou must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application.\n\nPlease use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application.\n\nIf you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITLAB\n\n- Name: `WOODPECKER_GITLAB`\n- Default: `false`\n\nEnables the GitLab driver.\n\n---\n\n### GITLAB_URL\n\n- Name: `WOODPECKER_GITLAB_URL`\n- Default: `https://gitlab.com`\n\nConfigures the GitLab server address.\n\n---\n\n### GITLAB_CLIENT\n\n- Name: `WOODPECKER_GITLAB_CLIENT`\n- Default: none\n\nConfigures the GitLab OAuth client id. This is used to authorize access.\n\n---\n\n### GITLAB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITLAB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath\n\n---\n\n### GITLAB_SECRET\n\n- Name: `WOODPECKER_GITLAB_SECRET`\n- Default: none\n\nConfigures the GitLab OAuth client secret. This is used to authorize access.\n\n---\n\n### GITLAB_SECRET_FILE\n\n- Name: `WOODPECKER_GITLAB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath\n\n---\n\n### GITLAB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITLAB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/50-bitbucket.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket\n\nWoodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_BITBUCKET=true\nWOODPECKER_BITBUCKET_CLIENT=... # called \"Key\" in Bitbucket\nWOODPECKER_BITBUCKET_SECRET=...\n```\n\n## Registration\n\nYou must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`).\n\nPlease set a name and set the `Callback URL` like this:\n\n```uri\nhttps://<your-woodpecker-address>/authorize\n```\n\n![bitbucket oauth setup](bitbucket_oauth.png)\n\nPlease also be sure to check the following permissions:\n\n- Account: Email, Read\n- Workspace membership: Read\n- Projects: Read\n- Repositories: Read\n- Pull requests: Read\n- Webhooks: Read and Write\n\n![bitbucket permissions](bitbucket_permissions.png)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET\n\n- Name: `WOODPECKER_BITBUCKET`\n- Default: `false`\n\nEnables the Bitbucket driver.\n\n---\n\n### BITBUCKET_CLIENT\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT`\n- Default: none\n\nConfigures the Bitbucket OAuth client key. This is used to authorize access.\n\n---\n\n### BITBUCKET_CLIENT_FILE\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath\n\n---\n\n### BITBUCKET_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_SECRET`\n- Default: none\n\nConfigures the Bitbucket OAuth client secret. This is used to authorize access.\n\n---\n\n### BITBUCKET_SECRET_FILE\n\n- Name: `WOODPECKER_BITBUCKET_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath\n\n## Known Issues\n\nBitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details.\n\n## Missing Features\n\nPath filters for pull requests are not supported. We are interested in patches to include this functionality.\nIf you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket Datacenter / Server\n\n:::warning\nWoodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash.\n:::\n\nTo enable Bitbucket Server you should configure the Woodpecker container using the following environment variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BITBUCKET_DC=true\n+      - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo\n+      - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy\n+      - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com\n+      - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true\n\n   woodpecker-agent:\n     [...]\n```\n\n## Service Account\n\nWoodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories.\n\n## Registration\n\nWoodpecker must be registered with Bitbucket Datacenter / Server.\nIn the administration section of Bitbucket choose \"Application Links\" and then \"Create link\".\nWoodpecker should be listed as \"External Application\" and the direction should be set to \"Incoming\".\nNote the client id and client secret of the registration to be used in the configuration of Woodpecker.\n\nSee also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html).\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET_DC\n\n- Name: `WOODPECKER_BITBUCKET_DC`\n- Default: `false`\n\nEnables the Bitbucket Server driver.\n\n---\n\n### BITBUCKET_DC_URL\n\n- Name: `WOODPECKER_BITBUCKET_DC_URL`\n- Default: none\n\nConfigures the Bitbucket Server address.\n\n---\n\n### BITBUCKET_DC_CLIENT_ID\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client id.\n\n---\n\n### BITBUCKET_DC_CLIENT_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client secret.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME`\n- Default: none\n\nThis username is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD`\n- Default: none\n\nThe password is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath\n\n---\n\n### BITBUCKET_DC_SKIP_VERIFY\n\n- Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN\n\n- Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN`\n- Default: `false`\n\nWhen enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/_category_.yaml",
    "content": "label: 'Forges'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/30-agent.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Agent\n\nAgents are configured by the command line or environment variables. At the minimum you need the following information:\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\n```\n\nThe following are automatically set and can be overridden:\n\n- `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname\n- `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1\n\n## Workflows per agent\n\nBy default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent.\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\nWOODPECKER_MAX_WORKFLOWS=4\n```\n\n## Agent registration\n\nWhen the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before.\n\nThere are two types of tokens to connect an agent to the server:\n\n### Using system token\n\nA _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents.\n\nIn that case registration process would be as following:\n\n1. The first time the agent communicates with the server, it is using the system token\n1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent\n1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`)\n1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server\n\n### Using agent token\n\nAn _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`.\n\nTo get an _agent token_ you have to register the agent manually in the server using the UI:\n\n1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent`\n   ![Agent creation](./new-agent-registration.png)\n   ![Agent created](./new-agent-created.png)\n1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET`\n1. The agent will connect to the server using the provided token and will update its status in the UI:\n   ![Agent connected](./new-agent-connected.png)\n\n## Environment variables\n\n### SERVER\n\n- Name: `WOODPECKER_SERVER`\n- Default: `localhost:9000`\n\nConfigures gRPC address of the server.\n\n---\n\n### USERNAME\n\n- Name: `WOODPECKER_USERNAME`\n- Default: `x-oauth-basic`\n\nThe gRPC username.\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`\n\n---\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOSTNAME\n\n- Name: `WOODPECKER_HOSTNAME`\n- Default: none\n\nConfigures the agent hostname.\n\n---\n\n### AGENT_CONFIG_FILE\n\n- Name: `WOODPECKER_AGENT_CONFIG_FILE`\n- Default: `/etc/woodpecker/agent.conf`\n\nConfigures the path of the agent config file.\n\n---\n\n### MAX_WORKFLOWS\n\n- Name: `WOODPECKER_MAX_WORKFLOWS`\n- Default: `1`\n\nConfigures the number of parallel workflows.\n\n---\n\n### AGENT_LABELS\n\n- Name: `WOODPECKER_AGENT_LABELS`\n- Default: none\n\nConfigures custom labels for the agent, to let workflows filter by it.\nUse a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard.\nIf you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched.\nBy default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed.\nTo learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels).\n\n---\n\n### HEALTHCHECK\n\n- Name: `WOODPECKER_HEALTHCHECK`\n- Default: `true`\n\nEnable healthcheck endpoint.\n\n---\n\n### HEALTHCHECK_ADDR\n\n- Name: `WOODPECKER_HEALTHCHECK_ADDR`\n- Default: `:3000`\n\nConfigures healthcheck endpoint address.\n\n---\n\n### KEEPALIVE_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_TIME`\n- Default: none\n\nAfter a duration of this time of no activity, the agent pings the server to check if the transport is still alive.\n\n---\n\n### KEEPALIVE_TIMEOUT\n\n- Name: `WOODPECKER_KEEPALIVE_TIMEOUT`\n- Default: `20s`\n\nAfter pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity.\n\n---\n\n### GRPC_SECURE\n\n- Name: `WOODPECKER_GRPC_SECURE`\n- Default: `false`\n\nConfigures if the connection to `WOODPECKER_SERVER` should be made using a secure transport.\n\n---\n\n### GRPC_VERIFY\n\n- Name: `WOODPECKER_GRPC_VERIFY`\n- Default: `true`\n\nConfigures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`.\n\n---\n\n### BACKEND\n\n- Name: `WOODPECKER_BACKEND`\n- Default: `auto-detect`\n\nConfigures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`.\n\n### BACKEND_DOCKER\\_\\*\n\nSee [Docker backend configuration](./11-backends/10-docker.md#environment-variables)\n\n---\n\n### BACKEND_K8S\\_\\*\n\nSee [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables)\n\n---\n\n### BACKEND_LOCAL\\_\\*\n\nSee [Local backend configuration](./11-backends/30-local.md#environment-variables)\n\n### Advanced Settings\n\n:::warning\nOnly change these If you know what you do.\n:::\n\n#### CONNECT_RETRY_COUNT\n\n- Name: `WOODPECKER_CONNECT_RETRY_COUNT`\n- Default: `5`\n\nConfigures number of times agent retries to connect to the server.\n\n#### CONNECT_RETRY_DELAY\n\n- Name: `WOODPECKER_CONNECT_RETRY_DELAY`\n- Default: `2s`\n\nConfigures delay between agent connection retries to the server.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/40-autoscaler.md",
    "content": "# Autoscaler\n\nIf your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler).\n\nPlease note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap).\n\n## Setup\n\n### docker compose\n\nIf you are using docker compose you can add the following to your `docker-compose.yaml` file:\n\n```yaml\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:next\n    [...]\n\n  woodpecker-autoscaler:\n    image: woodpeckerci/autoscaler:next\n    restart: always\n    depends_on:\n      - woodpecker-server\n    environment:\n      - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url\n      - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user\n      - WOODPECKER_MIN_AGENTS=0\n      - WOODPECKER_MAX_AGENTS=3\n      - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time\n      - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include \"https://\" in the value.\n      - WOODPECKER_GRPC_SECURE=true\n      - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents\n      - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below\n      - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/10-configuration/_category_.yaml",
    "content": "label: 'Configuration'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/30-administration/_category_.yaml",
    "content": "label: 'Administration'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/40-cli.md",
    "content": "# CLI\n\n# NAME\n\nwoodpecker-cli - command line utility\n\n# SYNOPSIS\n\nwoodpecker-cli\n\n```\n[--config|-c]=[value]\n[--disable-update-check]\n[--log-file]=[value]\n[--log-level]=[value]\n[--nocolor]\n[--pretty]\n[--server|-s]=[value]\n[--skip-verify]\n[--socks-proxy-off]\n[--socks-proxy]=[value]\n[--token|-t]=[value]\n```\n\n# DESCRIPTION\n\nWoodpecker command line utility\n\n**Usage**:\n\n```\nwoodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]\n```\n\n# GLOBAL OPTIONS\n\n**--config, -c**=\"\": path to config file\n\n**--disable-update-check**: disable update check (default: false)\n\n**--log-file**=\"\": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr)\n\n**--log-level**=\"\": set logging level (default: info)\n\n**--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false)\n\n**--pretty**: enable pretty-printed debug output (default: true)\n\n**--server, -s**=\"\": server address\n\n**--skip-verify**: skip ssl verification (default: false)\n\n**--socks-proxy**=\"\": socks proxy address\n\n**--socks-proxy-off**: socks proxy ignored (default: false)\n\n**--token, -t**=\"\": server auth token\n\n\n# COMMANDS\n\n## admin\n\nmanage server settings\n\n### log-level\n\nretrieve log level from server, or set it with [level]\n\n### org\n\nmanage organizations\n\n#### ls\n\nlist organizations\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nOrganization ID: {{ .ID }}\\n)\n\n### registry\n\nmanage global registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage global secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n### user\n\nmanage users\n\n#### add\n\nadd a user\n\n#### ls\n\nlist all users\n\n**--format**=\"\": format output (default: {{ .Login }})\n\n#### rm\n\nremove a user\n\n#### show\n\nshow user information\n\n**--format**=\"\": format output (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## exec\n\nexecute a local pipeline\n\n**--backend-docker-api-version**=\"\": the version of the API to reach, leave empty for latest.\n\n**--backend-docker-cert**=\"\": path to load the TLS certificates for connecting to docker server\n\n**--backend-docker-host**=\"\": path to docker socket or url to the docker server\n\n**--backend-docker-ipv6**: backend docker enable IPV6 (default: false)\n\n**--backend-docker-limit-cpu-quota**=\"\": impose a cpu quota (default: 0)\n\n**--backend-docker-limit-cpu-set**=\"\": set the cpus allowed to execute containers\n\n**--backend-docker-limit-cpu-shares**=\"\": change the cpu shares (default: 0)\n\n**--backend-docker-limit-mem**=\"\": maximum memory allowed in bytes (default: 0)\n\n**--backend-docker-limit-mem-swap**=\"\": maximum memory used for swap in bytes (default: 0)\n\n**--backend-docker-limit-shm-size**=\"\": docker /dev/shm allowed in bytes (default: 0)\n\n**--backend-docker-network**=\"\": backend docker network\n\n**--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true)\n\n**--backend-docker-volumes**=\"\": backend docker volumes (comma separated)\n\n**--backend-engine**=\"\": backend engine to run pipelines on (default: auto-detect)\n\n**--backend-http-proxy**=\"\": if set, pass the environment variable down as \"HTTP_PROXY\" to steps\n\n**--backend-https-proxy**=\"\": if set, pass the environment variable down as \"HTTPS_PROXY\" to steps\n\n**--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false)\n\n**--backend-k8s-namespace**=\"\": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker)\n\n**--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false)\n\n**--backend-k8s-pod-annotations**=\"\": backend k8s additional Agent-wide worker pod annotations\n\n**--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false)\n\n**--backend-k8s-pod-image-pull-secret-names**=\"\": backend k8s pull secret names for private registries\n\n**--backend-k8s-pod-labels**=\"\": backend k8s additional Agent-wide worker pod labels\n\n**--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false)\n\n**--backend-k8s-pod-node-selector**=\"\": backend k8s Agent-wide worker pod node selector\n\n**--backend-k8s-pod-tolerations**=\"\": backend k8s Agent-wide worker pod tolerations\n\n**--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true)\n\n**--backend-k8s-priority-class**=\"\": which kubernetes priority class to assign to created job pods\n\n**--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false)\n\n**--backend-k8s-storage-class**=\"\": backend k8s storage class\n\n**--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true)\n\n**--backend-k8s-volume-size**=\"\": backend k8s volume size (default 10G) (default: 10G)\n\n**--backend-local-temp-dir**=\"\": set a different temp dir to clone workflows into (default: system temporary directory)\n\n**--backend-no-proxy**=\"\": if set, pass the environment variable down as \"NO_PROXY\" to steps\n\n**--commit-author-avatar**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_AVATAR\".\n\n**--commit-author-email**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_EMAIL\".\n\n**--commit-author-name**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR\".\n\n**--commit-branch**=\"\": Set the metadata environment variable \"CI_COMMIT_BRANCH\". (default: main)\n\n**--commit-message**=\"\": Set the metadata environment variable \"CI_COMMIT_MESSAGE\".\n\n**--commit-pull-labels**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_LABELS\".\n\n**--commit-pull-milestone**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_MILESTONE\".\n\n**--commit-ref**=\"\": Set the metadata environment variable \"CI_COMMIT_REF\".\n\n**--commit-refspec**=\"\": Set the metadata environment variable \"CI_COMMIT_REFSPEC\".\n\n**--commit-release-is-pre**: Set the metadata environment variable \"CI_COMMIT_PRERELEASE\". (default: false)\n\n**--commit-sha**=\"\": Set the metadata environment variable \"CI_COMMIT_SHA\".\n\n**--env**=\"\": Set the metadata environment variable \"CI_ENV\".\n\n**--forge-type**=\"\": Set the metadata environment variable \"CI_FORGE_TYPE\".\n\n**--forge-url**=\"\": Set the metadata environment variable \"CI_FORGE_URL\".\n\n**--local**: run from local directory (default: true)\n\n**--metadata-file**=\"\": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags\n\n**--netrc-machine**=\"\": \n\n**--netrc-password**=\"\": \n\n**--netrc-username**=\"\": \n\n**--network**=\"\": external networks\n\n**--pipeline-changed-files**=\"\": Set the metadata environment variable \"CI_PIPELINE_FILES\", either json formatted list of strings, or comma separated string list.\n\n**--pipeline-created**=\"\": Set the metadata environment variable \"CI_PIPELINE_CREATED\". (default: 0)\n\n**--pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TASK\".\n\n**--pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TARGET\".\n\n**--pipeline-event**=\"\": Set the metadata environment variable \"CI_PIPELINE_EVENT\". (default: manual)\n\n**--pipeline-number**=\"\": Set the metadata environment variable \"CI_PIPELINE_NUMBER\". (default: 0)\n\n**--pipeline-parent**=\"\": Set the metadata environment variable \"CI_PIPELINE_PARENT\". (default: 0)\n\n**--pipeline-started**=\"\": Set the metadata environment variable \"CI_PIPELINE_STARTED\". (default: 0)\n\n**--pipeline-url**=\"\": Set the metadata environment variable \"CI_PIPELINE_FORGE_URL\".\n\n**--plugins-privileged**=\"\": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none\n\n**--prev-commit-author-avatar**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_AVATAR\".\n\n**--prev-commit-author-email**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_EMAIL\".\n\n**--prev-commit-author-name**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR\".\n\n**--prev-commit-branch**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_BRANCH\".\n\n**--prev-commit-message**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_MESSAGE\".\n\n**--prev-commit-ref**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REF\".\n\n**--prev-commit-refspec**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REFSPEC\".\n\n**--prev-commit-sha**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_SHA\".\n\n**--prev-pipeline-created**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_CREATED\". (default: 0)\n\n**--prev-pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TASK\".\n\n**--prev-pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TARGET\".\n\n**--prev-pipeline-event**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_EVENT\".\n\n**--prev-pipeline-finished**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FINISHED\". (default: 0)\n\n**--prev-pipeline-number**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_NUMBER\". (default: 0)\n\n**--prev-pipeline-started**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STARTED\". (default: 0)\n\n**--prev-pipeline-status**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STATUS\".\n\n**--prev-pipeline-url**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FORGE_URL\".\n\n**--repo**=\"\": Set the full name to derive metadata environment variables \"CI_REPO\", \"CI_REPO_NAME\" and \"CI_REPO_OWNER\".\n\n**--repo-clone-ssh-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_SSH_URL\".\n\n**--repo-clone-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_URL\".\n\n**--repo-default-branch**=\"\": Set the metadata environment variable \"CI_REPO_DEFAULT_BRANCH\". (default: main)\n\n**--repo-path**=\"\": path to local repository\n\n**--repo-private**=\"\": Set the metadata environment variable \"CI_REPO_PRIVATE\".\n\n**--repo-remote-id**=\"\": Set the metadata environment variable \"CI_REPO_REMOTE_ID\".\n\n**--repo-trusted-network**: Set the metadata environment variable \"CI_REPO_TRUSTED_NETWORK\". (default: false)\n\n**--repo-trusted-security**: Set the metadata environment variable \"CI_REPO_TRUSTED_SECURITY\". (default: false)\n\n**--repo-trusted-volumes**: Set the metadata environment variable \"CI_REPO_TRUSTED_VOLUMES\". (default: false)\n\n**--repo-url**=\"\": Set the metadata environment variable \"CI_REPO_URL\".\n\n**--secrets**=\"\": map of secrets, ex. 'secret=\"val\",secret2=\"value2\"'\n\n**--secrets**=\"\": path to yaml file with secrets map\n\n**--system-host**=\"\": Set the metadata environment variable \"CI_SYSTEM_HOST\".\n\n**--system-name**=\"\": Set the metadata environment variable \"CI_SYSTEM_NAME\". (default: woodpecker)\n\n**--system-platform**=\"\": Set the metadata environment variable \"CI_SYSTEM_PLATFORM\".\n\n**--system-url**=\"\": Set the metadata environment variable \"CI_SYSTEM_URL\". (default: https://github.com/woodpecker-ci/woodpecker)\n\n**--timeout**=\"\": pipeline timeout (default: 1h0m0s)\n\n**--volumes**=\"\": pipeline volumes\n\n**--workflow-name**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NAME\".\n\n**--workflow-number**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NUMBER\". (default: 0)\n\n**--workspace-base**=\"\":  (default: /woodpecker)\n\n**--workspace-path**=\"\":  (default: src)\n\n## info\n\nshow information about the current user\n\n**--format**=\"\": format output (deprecated) (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## lint\n\nlint a pipeline configuration file\n\n**--plugins-privileged**=\"\": allow plugins to run in privileged mode, if set empty, there is no\n\n**--plugins-trusted-clone**=\"\": plugins that are trusted to handle Git credentials in cloning steps (default: \"docker.io/woodpeckerci/plugin-git:2.7.0\", \"docker.io/woodpeckerci/plugin-git\", \"quay.io/woodpeckerci/plugin-git\")\n\n**--strict**: treat warnings as errors (default: false)\n\n## org\n\nmanage organizations\n\n### registry\n\nmanage organization registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these event\n\n**--image**=\"\": limit secret to these image\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n## pipeline\n\nmanage pipelines\n\n### approve\n\napprove a pipeline\n\n### create\n\ncreate new pipeline\n\n**--branch**=\"\": branch to create pipeline from\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--var**=\"\": key=value\n\n### decline\n\ndecline a pipeline\n\n### deploy\n\ntrigger a pipeline with the 'deployment' event\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter (default: push)\n\n**--format**=\"\": format output (default: Number: {{ .Number }}\\nStatus: {{ .Status }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nMessage: {{ .Message }}\\nAuthor: {{ .Author }}\\nTarget: {{ .Deploy }}\\n)\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n**--status**=\"\": status filter (default: success)\n\n### last\n\nshow latest pipeline information\n\n**--branch**=\"\": branch name (default: main)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### ls\n\nshow pipeline history\n\n**--after**=\"\": only return pipelines after this date (RFC3339)\n\n**--before**=\"\": only return pipelines before this date (RFC3339)\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter\n\n**--limit**=\"\": limit the list size (default: 25)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--status**=\"\": status filter\n\n### log\n\nmanage logs\n\n#### purge\n\npurge a log\n\n#### show\n\nshow pipeline logs\n\n### ps\n\nshow pipeline steps\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\\x1b[0m\\nStep: {{ .step.Name }}\\nStarted: {{ .step.Started }}\\nStopped: {{ .step.Stopped }}\\nType: {{ .step.Type }}\\nState: {{ .step.State }}\\n)\n\n### purge\n\npurge pipelines\n\n**--branch**=\"\": remove pipelines of this branch only\n\n**--dry-run**: disable non-read api calls (default: false)\n\n**--keep-min**=\"\": minimum number of pipelines to keep (default: 10)\n\n**--older-than**=\"\": remove pipelines older than the specified time limit (default: 0s)\n\n### queue\n\nshow pipeline queue\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }} #{{ .Number }} \\x1b[0m\\nStatus: {{ .Status }}\\nEvent: {{ .Event }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\\nMessage: {{ .Message }}\\n)\n\n### show\n\nshow pipeline information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### start\n\nstart a pipeline\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n### stop\n\nstop a pipeline\n\n## repo\n\nmanage repositories\n\n### add\n\nadd a repository\n\n### chown\n\nassume ownership of a repository\n\n### cron\n\nmanage cron jobs\n\n#### add\n\nadd a cron job\n\n**--branch**=\"\": cron branch\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n#### rm\n\nremove a cron job\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist cron jobs\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow cron job information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a cron job\n\n**--branch**=\"\": cron branch\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n### ls\n\nlist all repos\n\n**--all**: query all repos, including inactive ones (default: false)\n\n**--format**=\"\": format output (deprecated)\n\n**--org**=\"\": filter by organization\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### registry\n\nmanage registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n### rm\n\nremove a repository\n\n### repair\n\nrepair repository webhooks\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n### show\n\nshow repository information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### sync\n\nsynchronize the repository list\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }}\\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }}))\n\n### update\n\nupdate a repository\n\n**--config**=\"\": repository configuration path. Example: .woodpecker.yml\n\n**--pipeline-counter**=\"\": repository starting pipeline number (default: 0)\n\n**--require-approval**=\"\": repository requires approval for\n\n**--timeout**=\"\": repository timeout (default: 0s)\n\n**--trusted**: repository is trusted (default: false)\n\n**--unsafe**: allow unsafe operations (default: false)\n\n**--visibility**=\"\": repository visibility\n\n## setup\n\nsetup the woodpecker-cli for the first time\n\n**--server**=\"\": URL of the woodpecker server\n\n**--token**=\"\": token to authenticate with the woodpecker server\n\n## update\n\nupdate the woodpecker-cli to the latest version\n\n**--force**: force update even if the latest version is already installed (default: false)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/01-getting-started.md",
    "content": "# Getting started\n\nYou can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea).\n\n## Gitpod\n\nIf you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing:\n\n- An IDE in the browser or bridged to your local VS-Code or Jetbrains\n- A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge\n- A pre-configured Woodpecker server\n- A single pre-configured Woodpecker agent node\n- Our docs preview server\n\nStart Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`.\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker)\n\n## Preparation for local development\n\n### Install Go\n\nInstall Golang (>=1.20) as described by [this guide](https://go.dev/doc/install).\n\n### Install make\n\n> GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (<https://www.gnu.org/software/make/>).\n\nInstall make on:\n\n- Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/)\n- [Windows](https://stackoverflow.com/a/32127632/8461267)\n- Mac OS: `brew install make`\n\n### Install Node.js & `pnpm`\n\nInstall [Node.js (>=20)](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation.\n\nFor dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.\n[This guide](https://pnpm.io/installation) describes the installation of `pnpm`.\n\n### Install `pre-commit` (optional)\n\nWoodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code.\nTo apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage).\n\n### Create a `.env` file with your development configuration\n\nSimilar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it.\n\nA common config for debugging would look like this:\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ADMIN=your-username\n\nWOODPECKER_HOST=http://localhost:8000\n\n# github (sample for a forge config - see /docs/administration/forge/overview for other forges)\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=<redacted>\nWOODPECKER_GITHUB_SECRET=<redacted>\n\n# agent\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system\nWOODPECKER_MAX_WORKFLOWS=1\n\n# enable if you want to develop the UI\n# WOODPECKER_DEV_WWW_PROXY=http://localhost:8010\n\n# if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server\nWOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com\n\n# disable health-checks while debugging (normally not needed while developing)\nWOODPECKER_HEALTHCHECK=false\n\n# WOODPECKER_LOG_LEVEL=debug\n# WOODPECKER_LOG_LEVEL=trace\n```\n\n### Setup OAuth\n\nCreate an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md).\n\n## Developing with VS Code\n\nYou can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it.\n\nTo launch all needed services for local development, you can use \"Woodpecker CI\" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it.\n\nAs a starting guide for programming Go with VS Code, you can use this video guide:\n[![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80)\n\n### Debugging Woodpecker\n\nThe Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points.\n\n![Woodpecker debugging with VS Code](./vscode-debug.png)\n\n## Testing & linting code\n\nTo test or lint parts of Woodpecker, you can run one of the following commands:\n\n```bash\n# test server code\nmake test-server\n\n# test agent code\nmake test-agent\n\n# test cli code\nmake test-cli\n\n# test datastore / database related code like migrations of the server\nmake test-server-datastore\n\n# lint go code\nmake lint\n\n# lint UI code\nmake lint-frontend\n\n# test UI code\nmake test-frontend\n```\n\nIf you want to test a specific Go file, you can also use:\n\n```bash\ngo test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/<path-to-the-package-or-file-to-test>\n```\n\nOr you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands:\n\n![Run test via VS-Code](./vscode-run-test.png)\n\n## Run applications from terminal\n\nIf you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor.\n\n```bash title=\"start server\"\ngo run ./cmd/server\n```\n\n```bash title=\"start agent\"\ngo run ./cmd/agent\n```\n\n```bash title=\"execute cli command\"\ngo run ./cmd/cli [command]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/02-core-ideas.md",
    "content": "# Core ideas\n\n- A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂).\n- If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle).\n- What is used most often should be default.\n- Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md).\n\n## Addons and extensions\n\nIf you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an\n[addon](../30-administration/10-configuration/100-addons.md), [extension](../30-administration/10-configuration/10-server.md#external-configuration-api) or an\n[external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points:\n\n- Is your change very specific to your setup and unlikely to be used by anyone else?\n- Does your change violate the [guidelines](#guidelines)?\n\nBoth should be false when you open a pull request to get your change into the core repository.\n\n### Guidelines\n\n#### Forges\n\nA new forge must support these features:\n\n- OAuth2\n- Webhooks\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/03-ui.md",
    "content": "# UI Development\n\nTo develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api.\n\n## Setup\n\nThe UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed).\n\nTesting UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files.\n\n![UI Proxy architecture](./ui-proxy.svg)\n\nStart the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.\nAfter starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000).\n\n### Usage with remote server\n\nIf you would like to test your UI changes on a \"real-world\" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables:\n\n- `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org`\n- `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser\n\nThen, open the UI at `http://localhost:8010`.\n\n## Tools and frameworks\n\nThe following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing.\n\n- [Vue 3](https://v3.vuejs.org/)\n  - use `setup` and composition api\n  - place (re-usable) components in `web/src/components/`\n  - views should have a route in `web/src/router.ts` and are located in `web/src/views/`\n- [Tailwind CSS](https://tailwindcss.com/)\n  - use Tailwind classes where possible\n  - if needed extend the Tailwind config to use new classes\n  - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier)\n- [Vite](https://vitejs.dev/) (similar to Webpack)\n- [Typescript](https://www.typescriptlang.org/)\n  - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:)\n- [eslint](https://eslint.org/)\n- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file\n  - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)\n\n## Messages and Translations\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.\nYou must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet)\n\nFor more information about translations see [Translations](./08-translations.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/04-docs.md",
    "content": "# Documentation\n\nThe documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/).\n\nIf you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands:\n\n```bash\ncd docs/\n\npnpm install\n\n# build plugins used by the docs\npnpm build:woodpecker-plugins\n\n# start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually\npnpm start\n\n# or build the docs to deploy it to some static page hosting\npnpm build\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/05-architecture.md",
    "content": "# Architecture\n\n## Package architecture\n\n![Woodpecker architecture](./woodpecker-architecture.png)\n\n## System architecture\n\n### main package hierarchy\n\n| package            | meaning                                                        | imports                               |\n| ------------------ | -------------------------------------------------------------- | ------------------------------------- |\n| `cmd/**`           | parse command-line args & environment to stat server/cli/agent | all other                             |\n| `agent/**`         | code only agent (remote worker) will need                      | `pipeline`, `shared`                  |\n| `cli/**`           | code only cli tool does need                                   | `pipeline`, `shared`, `woodpecker-go` |\n| `server/**`        | code only server will need                                     | `pipeline`, `shared`                  |\n| `shared/**`        | code shared for all three main tools (go help utils)           | only std and external libs            |\n| `woodpecker-go/**` | go client for server rest api                                  | std                                   |\n\n### Server\n\n| package              | meaning                                                                             | imports                                                                                                                                                                               |\n| -------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `server/api/**`      | handle web requests from `server/router`                                            | `pipeline`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) |\n| `server/badges/**`   | generate svg badges for pipelines                                                   | `../model`                                                                                                                                                                            |\n| `server/ccmenu/**`   | generate xml ccmenu for pipelines                                                   | `../model`                                                                                                                                                                            |\n| `server/grpc/**`     | gRPC server agents can connect to                                                   | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store`                                                                           |\n| `server/logging/**`  | logging lib for gPRC server to stream logs while running                            | std                                                                                                                                                                                   |\n| `server/model/**`    | structs for store (db) and api (json)                                               | std                                                                                                                                                                                   |\n| `server/plugins/**`  | plugins for server                                                                  | `../model`, `../forge`                                                                                                                                                                |\n| `server/pipeline/**` | orchestrate pipelines                                                               | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins`                                                                                                 |\n| `server/pubsub/**`   | pubsub lib for server to push changes to the WebUI                                  | std                                                                                                                                                                                   |\n| `server/queue/**`    | queue lib for server where agents pull new pipelines from via gRPC                  | `server/model`                                                                                                                                                                        |\n| `server/forge/**`    | forge lib for server to connect and handle forge specific stuff                     | `shared`, `server/model`                                                                                                                                                              |\n| `server/router/**`   | handle requests to REST API (and all middleware) and serve UI and WebUI config      | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web`                                                                                                                      |\n| `server/store/**`    | handle database                                                                     | `server/model`                                                                                                                                                                        |\n| `server/shared/**`   | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) |                                                                                                                                                                                       |\n| `server/web/**`      | server SPA                                                                          |                                                                                                                                                                                       |\n\n- `../` = `server/`\n\n### Agent\n\nTODO\n\n### CLI\n\nTODO\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/06-conventions.md",
    "content": "# Conventions\n\n## Database naming\n\nDatabase tables are named plural, columns don't have any prefix.\n\nExample: Table name `agent`, columns `id`, `name`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/07-guides.md",
    "content": "# Guides\n\n## ORM\n\nWoodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection.\n\n## Add a new migration\n\nWoodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`.\n\n:::info\nAdding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created.\n:::\n\n:::warning\nYou should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager.\n:::\n\nTo automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start.\n\n## Constants of official images\n\nAll official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag.\n\n## Building images locally\n\n### Server\n\n```sh\n### build web component\nmake vendor\ncd web/\npnpm install --frozen-lockfile\npnpm build\ncd ..\n\n### define the platforms to build for (e.g. linux/amd64)\n# (the | is not a typo here)\nexport PLATFORMS='linux|amd64'\nmake cross-compile-server\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push .\n```\n\n:::info\nThe `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)).\nYou can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS).\n:::\n\n### Agent\n\n```sh\n### build the agent\nmake build-agent\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push .\n```\n\n### CLI\n\n```sh\n### build the CLI\nmake build-cli\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push .\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/08-translations.md",
    "content": "# Translations\n\nTo translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.**\n\n<a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n  <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/09-openapi.md",
    "content": "# Swagger, API Spec and Code Generation\n\nWoodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically\ngenerate Swagger v2 API specifications and a nice looking Web UI from the source code.\nAlso, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger)\nand then being using on the community's website documentation.\n\nIt's paramount important to keep the gin handler function's godoc documentation up-to-date,\nto always have accurate API documentation.\nWhenever you change, add or enhance an API endpoint, please update the godoc.\n\nYou don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools.\n\n## Gin-Handler API documentation guideline\n\nHere's a typical example of how annotations for Swagger documentation look like...\n\n```go title=\"server/api/user.go\"\n// @Summary  Get a user\n// @Description Returns a user with the specified login name. Requires admin rights.\n// @Router   /users/{login} [get]\n// @Produce  json\n// @Success  200 {object} User\n// @Tags   Users\n// @Param   Authorization header string true \"Insert your personal access token\" default(Bearer <personal access token>)\n// @Param   login   path string true \"the user's login name\"\n// @Param   foobar  query   string false \"optional foobar parameter\"\n// @Param   page    query int  false \"for response pagination, page offset number\" default(1)\n// @Param   perPage query int  false \"for response pagination, max items per page\" default(50)\n```\n\n```go title=\"server/model/user.go\"\ntype User struct {\n  ID int64 `json:\"id\" xorm:\"pk autoincr 'user_id'\"`\n// ...\n} // @name User\n```\n\nThese guidelines aim to have consistent wording in the OpenAPI doc:\n\n- first word after `@Summary` and `@Summary` are always uppercase\n- `@Summary` has no `.` (dot) at the end of the line\n- model structs shall use custom short names, to ease life for API consumers, using `@name`\n- `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI\n- when pagination is used, `@Param page` and `@Param perPage` must be added manually\n- `@Param Authorization` is almost always present, there are just a few un-protected endpoints\n\nThere are many examples in the `server/api` package, which you can use a blueprint.\nMore enhanced information you can find here <https://github.com/swaggo/swag/blob/master/README.md#declarative-comments-format>\n\n### Manual code generation\n\n```bash title=\"generate the server's Go code containing the OpenAPI\"\nmake generate-openapi\n```\n\n```bash title=\"update the Markdown in the ./docs folder\"\nmake generate-docs\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/09-testing.md",
    "content": "# Testing\n\n## Backend\n\n### Unit Tests\n\n[We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test)\nwith [`\"github.com/stretchr/testify/assert\"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing.\n\n### Integration Tests\n\n### Dummy backend\n\nThere is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave.\nTo enable it you need to build the agent or cli with the `test` build tag.\n\nAn example pipeline config would be:\n\n```yaml\nwhen:\n  event: manual\n\nsteps:\n  - name: echo\n    image: dummy\n    commands: echo \"hello woodpecker\"\n    environment:\n      SLEEP: '1s'\n\nservices:\n  echo:\n    image: dummy\n    commands: echo \"i am a service\"\n```\n\nThis could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`:\n\n<!-- cspell:disable -->\n\n```none\n9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: service\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: commands\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n```\n\n<!-- cspell:enable -->\n\nThere are also environment variables to alter step behavior:\n\n- `SLEEP: 10` will let the step wait 10 seconds\n- `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands`\n- `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled)\n- `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs\n- `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0\n- `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains\n\nYou can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/100-addons.md",
    "content": "# Addons\n\nThe Woodpecker server supports addons for forges and the log store.\n\n:::warning\nAddons are still experimental. Their implementation can change and break at any time.\n:::\n\n## Bug reports\n\nIf you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name.\n\n## Creating addons\n\nAddons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin).\n\n### Writing your code\n\nThis example will use the Go language.\n\nDirectly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there.\n\nIn the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument.\nThis will take care of connecting the addon forge to the server.\n\n:::note\nIt is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process.\n:::\n\n### Example structure\n\nThis is an example for a forge addon.\n\n```go\npackage main\n\nimport (\n  \"context\"\n  \"net/http\"\n\n  \"go.woodpecker-ci.org/woodpecker/v3/server/forge/addon\"\n  forgeTypes \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n  \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc main() {\n  addon.Serve(config{})\n}\n\ntype config struct {\n}\n\n// `config` must implement `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`. You must directly use Woodpecker's packages - see imports above.\n```\n\n### Addon types\n\n| Type      | Addon package                                                 | Service interface                                                 |\n| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- |\n| Forge     | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon`       | `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`         |\n| Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `\"go.woodpecker-ci.org/woodpecker/v3/server/service/log\".Service` |\n"
  },
  {
    "path": "docs/versioned_docs/version-3.12/92-development/_category_.yaml",
    "content": "label: 'Development'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/10-intro/index.md",
    "content": "# Welcome to Woodpecker\n\nWoodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics.\n\n## Have you ever heard of CI/CD or pipelines?\n\nDon't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of\nchecks, tests and routines along the way. A typical pipeline might include the following steps:\n\n1. Running tests\n2. Building your application\n3. Deploying your application\n\n[Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd)\n\n## Do you know containers?\n\nIf you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/).\n\n## Already have access to a Woodpecker instance?\n\nThen you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md).\n\n## Want to start from scratch and deploy your own Woodpecker instance?\n\nWoodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/10-intro.md",
    "content": "# Your first pipeline\n\nLet's get started and create your first pipeline.\n\n## 1. Repository Activation\n\nTo activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click.\n\n![new repository list](repo-new.png)\n\nTo enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something\nthat is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.).\n\n## 2. Define first workflow\n\nAfter enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository:\n\n```yaml title=\".woodpecker/my-first-workflow.yaml\"\nwhen:\n  - event: push\n    branch: main\n\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"This is the build step\"\n      - echo \"binary-data-123\" > executable\n  - name: a-test-step\n    image: golang:1.16\n    commands:\n      - echo \"Testing ...\"\n      - ./executable\n```\n\n**So what did we do here?**\n\n1. We defined your first workflow file `my-first-workflow.yaml`.\n2. This workflow will be executed when a push event happens on the `main` branch,\n   because we added a filter using the `when` section:\n\n   ```diff\n   + when:\n   +   - event: push\n   +     branch: main\n\n   ...\n   ```\n\n3. We defined two steps: `build` and `a-test-step`\n\nThe steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`.\n\nIn the `build` step we use the `debian` image and build a \"binary file\" called `executable`.\n\nIn the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it.\n\nYou can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to:\n\n```diff\n steps:\n   - name: build\n-    image: debian\n+    image: my-company/image-with-aws_cli\n     commands:\n       - aws help\n```\n\n## 3. Push the file and trigger first pipeline\n\nIf you push this file to your repository now, Woodpecker will already execute your first pipeline.\n\nYou can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository.\n\n![pipeline view](./pipeline.png)\n\nAs you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps.\n\nThis for example allows the first step to build your application using your source code and as the second step will receive\nthe same workspace it can use the previously built binary and test it.\n\n## 4. Use a plugin for reusable tasks\n\nSometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md).\n\nIf you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline:\n\n```yaml\nsteps:\n  # ...\n  - name: upload\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      access_key: a50d28f4dd477bc184fbd10b376de753\n      secret_key:\n        from_secret: aws_secret_key\n      source: public/**/*\n      target: /target/location\n```\n\nTo configure a plugin you can use the `settings` section.\n\nSometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md).\n\nSimilar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed.\n\nLearn more about [plugins](./51-plugins/51-overview.md).\n\nAs you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/100-troubleshooting.md",
    "content": "# Troubleshooting\n\n## How to debug clone issues\n\n(And what to do with an error message like `fatal: could not read Username for 'https://<url>': No such device or address`)\n\nThis error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`:\n\n```ini\nWOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true\n```\n\nIf that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container \"hang\":\n\n```yaml\nskip_clone: true\n\nsteps:\n  build:\n    image: debian:stable-backports\n    commands:\n      - apt update\n      - apt install -y inetutils-ping wget\n      - ping -c 4 git.example.com\n      - wget git.example.com\n      - sleep 9999999\n```\n\nGet the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf  bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline:\n\n```bash\ngit init\ngit remote add origin https://git.example.com/username/repo.git\ngit fetch --no-tags origin +refs/heads/branch:\n```\n\n(replace the url AND the branch with the correct values, use your username and password as log in values)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/15-terminology/architecture.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 226,\n      \"versionNonce\": 1002880859,\n      \"isDeleted\": false,\n      \"id\": \"UczUX5VuNnCB1rVvUJVfm\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.098092529257,\n      \"y\": 320.8758615860986,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 472.8823858375721,\n      \"height\": 183.19688715994928,\n      \"seed\": 917720693,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 161,\n      \"versionNonce\": 286006267,\n      \"isDeleted\": false,\n      \"id\": \"sKPZmBSWUdAYfBs4ByItH\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 539.5451038202509,\n      \"y\": 345.2419383247636,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 82.46875,\n      \"height\": 32.199999999999996,\n      \"seed\": 1485551573,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Server\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Server\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 333,\n      \"versionNonce\": 448586907,\n      \"isDeleted\": false,\n      \"id\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 649.8080506852966,\n      \"y\": 427.60908869342575,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 136,\n      \"height\": 60,\n      \"seed\": 1783625013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"r90dckf8trHemYzEwCgCW\"\n        },\n        {\n          \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 298,\n      \"versionNonce\": 1244067771,\n      \"isDeleted\": false,\n      \"id\": \"r90dckf8trHemYzEwCgCW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 703.8080506852966,\n      \"y\": 441.5090886934257,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 28,\n      \"height\": 32.199999999999996,\n      \"seed\": 660965013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113383,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"UI\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"originalText\": \"UI\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 105,\n      \"versionNonce\": 265992667,\n      \"isDeleted\": false,\n      \"id\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 800.9240766836483,\n      \"y\": 421.4987043996123,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135.3671503686619,\n      \"height\": 62.2689029398432,\n      \"seed\": 1115810805,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"svsVhxCbatcLj7lQLch0P\"\n        },\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 83,\n      \"versionNonce\": 1706870395,\n      \"isDeleted\": false,\n      \"id\": \"svsVhxCbatcLj7lQLch0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 828.1594096804793,\n      \"y\": 436.53315586953386,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.896484375,\n      \"height\": 32.199999999999996,\n      \"seed\": 2074781013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"GRPC\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"originalText\": \"GRPC\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 270,\n      \"versionNonce\": 418660123,\n      \"isDeleted\": false,\n      \"id\": \"hSrrwwnm9y7R-_CnJtaK1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.567103519039,\n      \"y\": 556.4146894573112,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 1983197877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 154,\n      \"versionNonce\": 871605179,\n      \"isDeleted\": false,\n      \"id\": \"8tsYgVssKnBd_Zw1QuqNz\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1298.4367898442752,\n      \"y\": 566.567242947784,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 96.5234375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1321669653,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent 1\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent 1\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 1323136091,\n      \"isDeleted\": false,\n      \"id\": \"eeugZg73_yD_6uLBBgmcX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 404.5001910129067,\n      \"y\": 707.1233710221009,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 210.068359375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1901447541,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"User => Browser\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"User => Browser\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"ellipse\",\n      \"version\": 106,\n      \"versionNonce\": 1501835515,\n      \"isDeleted\": false,\n      \"id\": \"mlDhl4OOc-H1tNgh77AAW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 482.5857164810477,\n      \"y\": 602.4394551739279,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 46.024748503793035,\n      \"height\": 44.21988070606176,\n      \"seed\": 791073493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"line\",\n      \"version\": 166,\n      \"versionNonce\": 627726747,\n      \"isDeleted\": false,\n      \"id\": \"ADEXzdYAhvj-_wVRftTIg\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 459.12202200277807,\n      \"y\": 697.1964604319912,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.31792517362464,\n      \"height\": 31.585599568061298,\n      \"seed\": 349155381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          42.415150610916044,\n          -28.87829787146393\n        ],\n        [\n          80.31792517362464,\n          2.7073016965973693\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 231,\n      \"versionNonce\": 801271355,\n      \"isDeleted\": false,\n      \"id\": \"xmz4J-rxLIjfUQ4q19PjD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 516.8788931508789,\n      \"y\": 870.4664542146543,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#fff4e6\",\n      \"width\": 385.34512717560705,\n      \"height\": 60.464035142111264,\n      \"seed\": 3531157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 93,\n      \"versionNonce\": 728690395,\n      \"isDeleted\": false,\n      \"id\": \"gSbpry_947XArfI7b6AAL\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 636.1468430141358,\n      \"y\": 878.5884970070326,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 132.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1989076725,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Autoscaler\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Autoscaler\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 118,\n      \"versionNonce\": 1258445691,\n      \"isDeleted\": false,\n      \"id\": \"WVy0mdTGbUx08RuxdQUH8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 523.3741602213286,\n      \"y\": 907.372811672524,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 369.1484375,\n      \"height\": 18.4,\n      \"seed\": 979386453,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Starts agents based on amount of pending pipelines\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Starts agents based on amount of pending pipelines\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 373,\n      \"versionNonce\": 1254044699,\n      \"isDeleted\": false,\n      \"id\": \"0Y1RcqzVFBFqh-wy-APMI\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1232.1955835481922,\n      \"y\": 605.8737363119278,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 292.6171875,\n      \"height\": 18.4,\n      \"seed\": 561999285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Executes pending workflows of a pipeline\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Executes pending workflows of a pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 630,\n      \"versionNonce\": 983038139,\n      \"isDeleted\": false,\n      \"id\": \"lGumbhMs3xx1vU2632hli\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 505.62283787078286,\n      \"y\": 383.42044095379515,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.015625,\n      \"height\": 36.8,\n      \"seed\": 722595605,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Central unit of a \\nWoodpecker instance \",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Central unit of a \\nWoodpecker instance \",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 131,\n      \"versionNonce\": 137308507,\n      \"isDeleted\": false,\n      \"id\": \"PbSQXehWVLYcQGXYFpd-B\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 971.7123256059622,\n      \"y\": 171.06951064323448,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 274.3443117379593,\n      \"height\": 74.90311522655017,\n      \"seed\": 1435321461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 96,\n      \"versionNonce\": 1222067707,\n      \"isDeleted\": false,\n      \"id\": \"2P2tz29C_2sUzVNSpaG17\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.5206131439782,\n      \"y\": 183.12082907329545,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 73.14453125,\n      \"height\": 32.199999999999996,\n      \"seed\": 884403669,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Forge\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Forge\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 141,\n      \"versionNonce\": 1133694619,\n      \"isDeleted\": false,\n      \"id\": \"0eYhFYPuRanZ7wkR2OlHO\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 986.864582863368,\n      \"y\": 225.1223531590797,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 247.234375,\n      \"height\": 18.4,\n      \"seed\": 1201957685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 55,\n      \"versionNonce\": 991183675,\n      \"isDeleted\": false,\n      \"id\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 820.419424341303,\n      \"y\": 340.29123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 247151765,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"bcUL-u4zkLA9CLG2YdaeN\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 38,\n      \"versionNonce\": 2008949723,\n      \"isDeleted\": false,\n      \"id\": \"bcUL-u4zkLA9CLG2YdaeN\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 831.853994653803,\n      \"y\": 358.79123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 94.130859375,\n      \"height\": 23,\n      \"seed\": 1638982133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Webhooks\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"originalText\": \"Webhooks\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 93,\n      \"versionNonce\": 295891067,\n      \"isDeleted\": false,\n      \"id\": \"Bphhue86mMXHN4klGamM3\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 697.3018309300141,\n      \"y\": 339.607928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 92986197,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"0YxY2hEPyDWFqR8_-f6bn\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 87,\n      \"versionNonce\": 2055547163,\n      \"isDeleted\": false,\n      \"id\": \"0YxY2hEPyDWFqR8_-f6bn\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 727.4522215550141,\n      \"y\": 358.107928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 56.69921875,\n      \"height\": 23,\n      \"seed\": 43952309,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"OAuth\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Bphhue86mMXHN4klGamM3\",\n      \"originalText\": \"OAuth\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 284,\n      \"versionNonce\": 1205292475,\n      \"isDeleted\": false,\n      \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1205.6453201409104,\n      \"y\": 250.4849674923464,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 272.1094712799886,\n      \"height\": 94.31865813977868,\n      \"seed\": 982632981,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"0eYhFYPuRanZ7wkR2OlHO\",\n        \"focus\": -0.8418551162334328,\n        \"gap\": 6.962614333266799\n      },\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          -69.68740859223726,\n          65.87860410965993\n        ],\n        [\n          -272.1094712799886,\n          94.31865813977868\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 53,\n      \"versionNonce\": 1803962459,\n      \"isDeleted\": false,\n      \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1050.575099048673,\n      \"y\": 297.96357160200637,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 170.765625,\n      \"height\": 36.8,\n      \"seed\": 1046069109,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"sends events like push, \\ntag, ...\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"originalText\": \"sends events like push, tag, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 487,\n      \"versionNonce\": 335895291,\n      \"isDeleted\": false,\n      \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 792.0835609101814,\n      \"y\": 316.38601649373913,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 176.92139414789008,\n      \"height\": 122.73778943055902,\n      \"seed\": 1681656021,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yvJTQ64RU50N6-hxEQlkl\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"UczUX5VuNnCB1rVvUJVfm\",\n        \"focus\": -0.03867359238356983,\n        \"gap\": 4.489845092359474\n      },\n      \"endBinding\": {\n        \"elementId\": \"PbSQXehWVLYcQGXYFpd-B\",\n        \"focus\": 0.7798878042817562,\n        \"gap\": 2.707370547890605\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          60.422360349016344,\n          -71.97786730696657\n        ],\n        [\n          176.92139414789008,\n          -122.73778943055902\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 62,\n      \"versionNonce\": 301106427,\n      \"isDeleted\": false,\n      \"id\": \"yvJTQ64RU50N6-hxEQlkl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 773.7910775091977,\n      \"y\": 226.00814918677256,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 157.4296875,\n      \"height\": 36.8,\n      \"seed\": 500049461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"allows users to login \\nusing existing account\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"originalText\": \"allows users to login using existing account\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 393,\n      \"versionNonce\": 598459861,\n      \"isDeleted\": false,\n      \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 936.9267543177084,\n      \"y\": 458.95033086418084,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 215.17788326846676,\n      \"height\": 93.99151368376693,\n      \"seed\": 234198933,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"rFf6NIofw6UBOyAFwg0Kn\"\n        }\n      ],\n      \"updated\": 1697530127259,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.30339107267010673,\n        \"gap\": 1\n      },\n      \"endBinding\": {\n        \"elementId\": \"hSrrwwnm9y7R-_CnJtaK1\",\n        \"focus\": -0.14057158065513534,\n        \"gap\": 3.4728449093634026\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          130.0760301643047,\n          42.90930518030268\n        ],\n        [\n          215.17788326846676,\n          93.99151368376693\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 8,\n      \"versionNonce\": 1693330843,\n      \"isDeleted\": false,\n      \"id\": \"rFf6NIofw6UBOyAFwg0Kn\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 997.4942845557462,\n      \"y\": 473.9409015069133,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 1592253685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 270,\n      \"versionNonce\": 1855882619,\n      \"isDeleted\": false,\n      \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 886.0581619083632,\n      \"y\": 485.67004123832135,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 174.09447592006472,\n      \"height\": 326.4905563076211,\n      \"seed\": 1479177813,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"apyMCAv2GIN_yzHXwX4tY\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.1341191028023529,\n        \"gap\": 1.9024338988657519\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"focus\": -0.7088661407505865,\n        \"gap\": 4.060573862784622\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          44.14165353942735,\n          196.18483635907205\n        ],\n        [\n          174.09447592006472,\n          326.4905563076211\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 66,\n      \"versionNonce\": 2007745083,\n      \"isDeleted\": false,\n      \"id\": \"apyMCAv2GIN_yzHXwX4tY\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 849.4927841977906,\n      \"y\": 663.4548775973934,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 882041781,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 347,\n      \"versionNonce\": 1353818811,\n      \"isDeleted\": false,\n      \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 534.9278465333664,\n      \"y\": 595.2199151317081,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 113.88020415193023,\n      \"height\": 119.81968366814112,\n      \"seed\": 944153877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"_A8uznhnpXuQBYzjP-iVx\",\n        \"focus\": 0.5397285671082249,\n        \"gap\": 1\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          113.88020415193023,\n          -119.81968366814112\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 61,\n      \"versionNonce\": 1099141979,\n      \"isDeleted\": false,\n      \"id\": \"j56ZKRwmXk72nHrZzLz_1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1081.8110514012087,\n      \"y\": 652.5253283508498,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 566.7373014532342,\n      \"height\": 68.58600908319681,\n      \"seed\": 112933493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 82,\n      \"versionNonce\": 1879994363,\n      \"isDeleted\": false,\n      \"id\": \"cAVYXfBRnfuGAv7QTQVow\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1300.6584159706863,\n      \"y\": 658.8425033454967,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 77.83203125,\n      \"height\": 23,\n      \"seed\": 951460821,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Backend\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Backend\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 376,- add some images explaining the architecture & terminology with\npipeline -> workflow -> step\n- combine advanced config usage\n- rename pipeline syntax to workflow syntax (and most references to\npipeline steps etc as well)\n- update agent registration part\n- add bug note to secrets encryption setting\n- remove usage from readme to point to up-to-date docs page\n- typos\n- closes #1408\n\n---------\n      \"angle\": 0,\n      \"x\": 1094.1972977313717,\n      \"y\": 681.8988272758752,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530.9453125,\n      \"height\": 55.199999999999996,\n      \"seed\": 843899189,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 50\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 384,\n      \"versionNonce\": 1778969915,\n      \"isDeleted\": false,\n      \"id\": \"pxF49EKDNO6IZq_34i7bY\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1064.2132116912126,\n      \"y\": 754.5018564383092,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 954528405,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 154,\n      \"versionNonce\": 1988988379,\n      \"isDeleted\": false,\n      \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 904.0288881242177,\n      \"y\": 882.4966027880746,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.83070714434325,\n      \"height\": 32.735025983189644,\n      \"seed\": 1228134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yNxAOEPZu_Jl7mnI01OXs\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"xmz4J-rxLIjfUQ4q19PjD\",\n        \"gap\": 1.8048677977312764,\n        \"focus\": 0.31250963573550006\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"gap\": 1.353616422651612,\n        \"focus\": 0.36496042109885213\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          158.83070714434325,\n          -32.735025983189644\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 25,\n      \"versionNonce\": 1393410779,\n      \"isDeleted\": false,\n      \"id\": \"yNxAOEPZu_Jl7mnI01OXs\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 963.8856479463893,\n      \"y\": 856.9290897964797,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 39.1171875,\n      \"height\": 18.4,\n      \"seed\": 759107925,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113387,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"starts\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"originalText\": \"starts\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 187,\n      \"versionNonce\": 671224603,\n      \"isDeleted\": false,\n      \"id\": \"sSj4Pda-fo-BBYM_dzml6\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1296.0854928322988,\n      \"y\": 776.6118140041631,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 104.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1381768885,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/15-terminology/index.md",
    "content": "# Terminology\n\n## Glossary\n\n- **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC.\n- **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines.\n- **Code**: Refers to the files tracked by the version control system used by the [forge][Forge].\n- **Commit**: A defined state of the code, usually associated with a version control system like Git.\n- **Container**: A lightweight and isolated environment where commands are executed.\n- **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel.\n- **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI.\n- **[Forge][Forge]**: The hosting platform or service where the repositories are hosted.\n- **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix.\n- **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events.\n- **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings.\n- **Repos**: Short for repositories, these are storage locations where code is stored.\n- **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration.\n- **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow].\n- **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions.\n- **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge].\n- **Steps**: Individual commands, actions or tasks within a [workflow][Workflow].\n- **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue.\n- **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code.\n- **Woodpecker CI**: The project name around Woodpecker.\n- **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker).\n- **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps.\n- **YAML File**: A file format used to define and configure [workflows][Workflow].\n\n## Woodpecker architecture\n\n![Woodpecker architecture](architecture.svg)\n\n## Pipeline, workflow & step\n\n![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg)\n\n## Conventions\n\nSometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker:\n\n- Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()`\n- Use the term **pipelines** instead of the previous **builds**\n- Use the term **steps** instead of the previous **jobs**\n- Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users\n\n<!-- References -->\n\n[Event]: ../20-workflow-syntax.md#event\n[Pipeline]: ../20-workflow-syntax.md\n[Workflow]: ../25-workflows.md\n[Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md\n[Plugin]: ../51-plugins/51-overview.md\n[Workspace]: ../20-workflow-syntax.md#workspace\n[Matrix]: ../30-matrix-workflows.md\n[Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md\n[Local]: ../../30-administration/10-configuration/11-backends/30-local.md\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/15-terminology/pipeline-workflow-step.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 97,\n      \"versionNonce\": 257762037,\n      \"isDeleted\": false,\n      \"id\": \"Y3hYdpX9r1qWfyHWs7AXT\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 393.622323134362,\n      \"y\": 336.02197155458475,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 366.3936710429598,\n      \"height\": 499.95605689083004,\n      \"seed\": 875444373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 67,\n      \"versionNonce\": 369556565,\n      \"isDeleted\": false,\n      \"id\": \"g1Eb010Kx_KFryVqNYWBQ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 520.0116988873679,\n      \"y\": 363.32095846456355,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 99.626953125,\n      \"height\": 32.199999999999996,\n      \"seed\": 1466195445,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Pipeline\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 314,\n      \"versionNonce\": 1983028731,\n      \"isDeleted\": false,\n      \"id\": \"9o-DNP0YdlIGVz1kEm_hW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 407.1590381712276,\n      \"y\": 410.9252244837219,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1869535061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 156,\n      \"versionNonce\": 1495247317,\n      \"isDeleted\": false,\n      \"id\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 490.3194993196821,\n      \"y\": 473.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 111355061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"ya0JzDo-4oscHIq87TZ_D\"\n        },\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 156,\n      \"versionNonce\": 1469425461,\n      \"isDeleted\": false,\n      \"id\": \"ya0JzDo-4oscHIq87TZ_D\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 566.0118821321821,\n      \"y\": 478.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1084671509,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 236,\n      \"versionNonce\": 1535319541,\n      \"isDeleted\": false,\n      \"id\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 491.2218643672577,\n      \"y\": 519.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 812596085,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"FRby8A9aUiKvHpM5mCdDN\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 231,\n      \"versionNonce\": 28677973,\n      \"isDeleted\": false,\n      \"id\": \"FRby8A9aUiKvHpM5mCdDN\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 583.0324112422577,\n      \"y\": 524.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1849820373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 291,\n      \"versionNonce\": 571598005,\n      \"isDeleted\": false,\n      \"id\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 489.6426911083554,\n      \"y\": 567.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1840554549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"UOwxmKIS0W62CFt_ffEy4\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 4032021,\n      \"isDeleted\": false,\n      \"id\": \"UOwxmKIS0W62CFt_ffEy4\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 581.4532379833554,\n      \"y\": 572.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 330077077,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 296,\n      \"versionNonce\": 1539516059,\n      \"isDeleted\": false,\n      \"id\": \"9laL3864YWOna6NQlVDqq\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 630.0635849044402,\n      \"y\": 383.14314287821776,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 294.3024370154917,\n      \"height\": 36.656016722015465,\n      \"seed\": 207575285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -1.000156025347643,\n        \"gap\": 27.782081605504118\n      },\n      \"endBinding\": {\n        \"elementId\": \"vS2PNUbmeBe3EPxl-dID8\",\n        \"focus\": 0.7761987167055517,\n        \"gap\": 8.978940924346716\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          294.3024370154917,\n          -36.656016722015465\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 249,\n      \"versionNonce\": 2076402229,\n      \"isDeleted\": false,\n      \"id\": \"vS2PNUbmeBe3EPxl-dID8\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 933.3449628442786,\n      \"y\": 336.02200598023114,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 301.298828125,\n      \"height\": 46,\n      \"seed\": 1632793173,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 751,\n      \"versionNonce\": 1371044827,\n      \"isDeleted\": false,\n      \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 751.1619011845514,\n      \"y\": 440.8355079324799,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 160.46519124360202,\n      \"height\": 2.2452348338335923,\n      \"seed\": 1331388341,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -0.6591700594229558,\n        \"gap\": 3.8807513696519322\n      },\n      \"endBinding\": {\n        \"elementId\": \"wfFvnFZuh0npL9hh0ez7o\",\n        \"focus\": 0.7652411053273549,\n        \"gap\": 20.75618622779257\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          160.46519124360202,\n          -2.2452348338335923\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 819540565,\n      \"isDeleted\": false,\n      \"id\": \"TbejdIYo_qNDw15yLP2IB\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 406.0812257713851,\n      \"y\": 626.8305540252475,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1553965333,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 466,\n      \"versionNonce\": 663477,\n      \"isDeleted\": false,\n      \"id\": \"wfFvnFZuh0npL9hh0ez7o\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 932.383278655946,\n      \"y\": 424.0107569968011,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 481.2890625,\n      \"height\": 115,\n      \"seed\": 781497973,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 110\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 464,\n      \"versionNonce\": 734626075,\n      \"isDeleted\": false,\n      \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 741.0645380446722,\n      \"y\": 492.31283255558515,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 178.4459423531871,\n      \"height\": 83.08707392565111,\n      \"seed\": 536879061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"q4TKpiq2KAwPaz19GdhtK\",\n        \"focus\": -0.7697471991854113,\n        \"gap\": 3.7450387249900814\n      },\n      \"endBinding\": {\n        \"elementId\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n        \"focus\": -0.7822252364700005,\n        \"gap\": 8.360835317635974\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          178.4459423531871,\n          83.08707392565111\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 327,\n      \"versionNonce\": 371646421,\n      \"isDeleted\": false,\n      \"id\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 927.8713157154953,\n      \"y\": 563.2132686484658,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 491.357421875,\n      \"height\": 46,\n      \"seed\": 385310005,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 91,\n      \"versionNonce\": 1180085909,\n      \"isDeleted\": false,\n      \"id\": \"0tGx2VdJLNf7W6HD76dtO\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 427.6895298601876,\n      \"y\": 432.3583566254258,\n      \"strokeColor\": \"#9c36b5\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 143.876953125,\n      \"height\": 23,\n      \"seed\": 450883221,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"build\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"build\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 338,\n      \"versionNonce\": 957223925,\n      \"isDeleted\": false,\n      \"id\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.7251825950889,\n      \"y\": 685.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 711939061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"8EqaPnZX2CgLaF08UNZZg\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 340,\n      \"versionNonce\": 510774613,\n      \"isDeleted\": false,\n      \"id\": \"8EqaPnZX2CgLaF08UNZZg\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 563.4175654075889,\n      \"y\": 690.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1370164565,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 421,\n      \"versionNonce\": 97999541,\n      \"isDeleted\": false,\n      \"id\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 488.62754764266447,\n      \"y\": 731.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 2145950389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"DX10t075MMDu7BLtuUaij\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 417,\n      \"versionNonce\": 2011446293,\n      \"isDeleted\": false,\n      \"id\": \"DX10t075MMDu7BLtuUaij\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 580.4380945176645,\n      \"y\": 736.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 500005909,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 475,\n      \"versionNonce\": 1284370805,\n      \"isDeleted\": false,\n      \"id\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.04837438376217,\n      \"y\": 779.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1666134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"-xogFSFcP-Vv5cuOSFm8T\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 476,\n      \"versionNonce\": 1092221653,\n      \"isDeleted\": false,\n      \"id\": \"-xogFSFcP-Vv5cuOSFm8T\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 578.8589212587622,\n      \"y\": 784.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1840462549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 125,\n      \"versionNonce\": 1310578741,\n      \"isDeleted\": false,\n      \"id\": \"N1a9yL7Pts16hUKY9-vhw\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 424.78852030984035,\n      \"y\": 646.2446482189896,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 133.857421875,\n      \"height\": 23,\n      \"seed\": 361699381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"test\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"test\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 184,\n      \"versionNonce\": 2127603131,\n      \"isDeleted\": false,\n      \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 737.454940151797,\n      \"y\": 535.9141784615474,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 190.41665096887027,\n      \"height\": 112.96427727851824,\n      \"seed\": 80234901,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.8392895251910331,\n        \"gap\": 2.0300115262207328\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          190.41665096887027,\n          112.96427727851824\n        ]\n      ]\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 327,\n      \"versionNonce\": 780710651,\n      \"isDeleted\": false,\n      \"id\": \"379hO6Dc5rygB38JgDbVo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 738.8084877231549,\n      \"y\": 591.3526691276127,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 186.8066399682357,\n      \"height\": 57.68023784868956,\n      \"seed\": 211046133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"2WwuMWX7YawqK0i1rDPJo\",\n        \"focus\": -0.5776522830934517,\n        \"gap\": 2.1657966147995467\n      },\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.7269489945238884,\n        \"gap\": 4.286474955497397\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          186.8066399682357,\n          57.68023784868956\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 285,\n      \"versionNonce\": 1165977685,\n      \"isDeleted\": false,\n      \"id\": \"0TjxOfERekC91N3yciQIq\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 929.901602646888,\n      \"y\": 632.4760859429873,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 518.076171875,\n      \"height\": 46,\n      \"seed\": 997763157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/20-workflow-syntax.md",
    "content": "# Workflow syntax\n\nThe Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status.\n\n:::note\nAn exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run.\n:::\n\n:::note\nWe support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility.\nRead more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3)\n:::\n\nExample steps:\n\n```yaml\nsteps:\n  - name: backend\n    image: golang\n    commands:\n      - go build\n      - go test\n  - name: frontend\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\nIn the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary.\n\nThe name is optional, if not added the steps will be numerated.\n\nAnother way to name a step is by using dictionaries:\n\n```yaml\nsteps:\n  backend:\n    image: golang\n    commands:\n      - go build\n      - go test\n  frontend:\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\n## Skip Commits\n\nWoodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive.\n\n```bash\ngit commit -m \"updated README [CI SKIP]\"\n```\n\n## Steps\n\nEvery step of your workflow executes commands inside a specified container.<br>\nThe defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).<br>\nThe associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\n### File changes are incremental\n\n- Woodpecker clones the source code in the beginning of the workflow\n- Changes to files are persisted through steps as the same volume is mounted to all steps\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"test content\" > myfile\n  - name: a-test-step\n    image: debian\n    commands:\n      - cat myfile\n```\n\n### `image`\n\nWoodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers.\n\nWhen using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands.\n\n```diff\n steps:\n   - name: build\n+    image: golang:1.6\n     commands:\n       - go build\n       - go test\n\n   - name: prettier\n+    image: woodpeckerci/plugin-prettier\n\n services:\n   - name: database\n+    image: mysql\n```\n\nWoodpecker supports any valid Docker image from any Docker registry:\n\n```yaml\nimage: golang\nimage: golang:1.7\nimage: library/golang:1.7\nimage: index.docker.io/library/golang\nimage: index.docker.io/library/golang:1.7\n```\n\nLearn more how you can use images from [different registries](./41-registries.md).\n\n### `pull`\n\nBy default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present.\n\nTo always pull the latest image when updates are available, use the `pull` option:\n\n```diff\n steps:\n   - name: build\n     image: golang:latest\n+    pull: true\n```\n\n### `commands`\n\nCommands of every step are executed serially as if you would enter them into your local shell.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\nThere is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script:\n\n```bash\n#!/bin/sh\nset -e\n\ngo build\ngo test\n```\n\nThe above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed:\n\n```bash\ndocker run --entrypoint=build.sh golang\n```\n\n:::note\nOnly build steps can define commands. You cannot use commands with plugins or services.\n:::\n\n### `entrypoint`\n\nAllows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `[\"/bin/sh\", \"-c\"]`).\n\nIf you define [`commands`](#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\nYou can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`.\n\n### `environment`\n\nWoodpecker provides the ability to pass environment variables to individual steps.\n\nFor more details, check the [environment docs](./50-environment.md).\n\n### `failure`\n\nSome of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n       - go build\n       - go test\n+    failure: ignore\n```\n\n### `when` - Conditional Execution\n\nWoodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true.\nA condition can be a check like:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - event: pull_request\n+        repo: test/test\n+      - event: push\n+        branch: main\n```\n\nThe `prettier` step is executed if one of these conditions is met:\n\n1. The pipeline is executed from a pull request in the repo `test/test`\n2. The pipeline is executed from a push to `main`\n\n#### `repo`\n\nExample conditional execution by repository:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - repo: test/test\n```\n\n#### `branch`\n\n:::note\nBranch conditions are not applied to tags.\n:::\n\nExample conditional execution by branch:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - branch: main\n```\n\n> The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only.\n\nExecute a step if the branch is `main` or `develop`:\n\n```yaml\nwhen:\n  - branch: [main, develop]\n```\n\nExecute a step if the branch starts with `prefix/*`:\n\n```yaml\nwhen:\n  - branch: prefix/*\n```\n\nThe branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples:\n\n- `*\\\\/*` to match patterns with exactly 1 `/`\n- `*\\\\/**` to match patters with at least 1 `/`\n- `*` to match patterns without `/`\n- `**` to match everything\n\nExecute a step using custom include and exclude logic:\n\n```yaml\nwhen:\n  - branch:\n      include: [main, release/*]\n      exclude: [release/1.0.0, release/1.1.*]\n```\n\n#### `event`\n\nThe available events are:\n\n- `push`: triggered when a commit is pushed to a branch.\n- `pull_request`: triggered when a pull request is opened or a new commit is pushed to it.\n- `pull_request_closed`: triggered when a pull request is closed or merged.\n- `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...).\n- `tag`: triggered when a tag is pushed.\n- `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).)\n- `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.)\n- `cron`: triggered when a cron job is executed.\n- `manual`: triggered when a user manually triggers a pipeline.\n\nExecute a step if the build event is a `tag`:\n\n```yaml\nwhen:\n  - event: tag\n```\n\nExecute a step if the pipeline event is a `push` to a specified branch:\n\n```diff\nwhen:\n  - event: push\n+   branch: main\n```\n\nExecute a step for multiple events:\n\n```yaml\nwhen:\n  - event: [push, tag, deployment]\n```\n\n#### `cron`\n\nThis filter **only** applies to cron events and filters based on the name of a cron job.\n\nMake sure to have a `event: cron` condition in the `when`-filters as well.\n\n```yaml\nwhen:\n  - event: cron\n    cron: sync_* # name of your cron job\n```\n\n[Read more about cron](./45-cron.md)\n\n#### `ref`\n\nThe `ref` filter compares the git reference against which the workflow is executed.\nThis allows you to filter, for example, tags that must start with **v**:\n\n```yaml\nwhen:\n  - event: tag\n    ref: refs/tags/v*\n```\n\n#### `status`\n\nThere are use cases for executing steps on failure, such as sending notifications for failed workflow/pipeline. Use the status constraint to execute steps even when the workflow fails:\n\n```diff\n steps:\n   - name: notify\n     image: alpine\n+    when:\n+      - status: [ success, failure ]\n```\n\n#### `platform`\n\n:::note\nThis condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch.\n:::\n\nExecute a step for a specific platform:\n\n```yaml\nwhen:\n  - platform: linux/amd64\n```\n\nExecute a step for a specific platform using wildcards:\n\n```yaml\nwhen:\n  - platform: [linux/*, windows/amd64]\n```\n\n#### `matrix`\n\nExecute a step for a single matrix permutation:\n\n```yaml\nwhen:\n  - matrix:\n      GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n```\n\n#### `instance`\n\nExecute a step only on a certain Woodpecker instance matching the specified hostname:\n\n```yaml\nwhen:\n  - instance: stage.woodpecker.company.com\n```\n\n#### `path`\n\n:::info\nPath conditions are applied only to **push** and **pull_request** events.\n:::\n\nExecute a step only on a pipeline with certain files being changed:\n\n```yaml\nwhen:\n  - path: 'src/*'\n```\n\nYou can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`.\n\nFor pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases.\n\n```yaml\nwhen:\n  - path:\n      include: ['.woodpecker/*.yaml', '*.ini']\n      exclude: ['*.md', 'docs/**']\n      ignore_message: '[ALL]'\n      on_empty: true\n```\n\n:::info\nPassing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting.\n:::\n\n#### `evaluate`\n\nExecute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression.\n\nThe expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library.\n\nRun on pushes to the default branch for the repository `owner/repo`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'\n```\n\nRun on commits created by user `woodpecker-ci`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n```\n\nSkip all commits containing `please ignore me` in the commit message:\n\n```yaml\nwhen:\n  - evaluate: 'not (CI_COMMIT_MESSAGE contains \"please ignore me\")'\n```\n\nRun on pull requests with the label `deploy`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"deploy\"'\n```\n\nSkip step only if `SKIP=true`, run otherwise or if undefined:\n\n```yaml\nwhen:\n  - evaluate: 'SKIP != \"true\"'\n```\n\n### `depends_on`\n\nNormally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`:\n\n```diff\n steps:\n   - name: build # build will be executed immediately\n     image: golang\n     commands:\n       - go build\n\n   - name: deploy\n     image: woodpeckerci/plugin-s3\n     settings:\n       bucket: my-bucket-name\n       source: some-file-name\n       target: /target/some-file\n+    depends_on: [build, test] # deploy will be executed after build and test finished\n\n   - name: test # test will be executed immediately as no dependencies are set\n     image: golang\n     commands:\n       - go test\n```\n\n:::note\nYou can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified.\n\n```yaml\nsteps:\n  - name: check code format\n    image: mstruebing/editorconfig-checker\n    depends_on: [] # enable parallel steps\n  ...\n```\n\n:::\n\n### `volumes`\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\nFor more details check the [volumes docs](./70-volumes.md).\n\n### `detach`\n\nWoodpecker gives the ability to detach steps to run them in background until the workflow finishes.\n\nFor more details check the [service docs](./60-services.md#detachment).\n\n### `directory`\n\nUsing `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run.\n\n### `backend_options`\n\nWith `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes.\n\nFurther details can be found in the documentation of the used backend:\n\n- [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration)\n- [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration)\n\n## `services`\n\nWoodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow.\n\nFor more details check the [services docs](./60-services.md).\n\n## `workspace`\n\nThe workspace defines the shared volume and working directory shared by all workflow steps.\nThe default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`).\nSo an example would be `/woodpecker/src/github.com/octocat/hello-world`.\n\nThe workspace can be customized using the workspace block in the YAML file:\n\n```diff\n+workspace:\n+  base: /go\n+  path: src/github.com/octocat/hello-world\n\n steps:\n   - name: build\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n```\n\n:::note\nPlugins will always have the workspace base at `/woodpecker`\n:::\n\nThe base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps.\n\n```diff\n workspace:\n+  base: /go\n   path: src/github.com/octocat/hello-world\n\n steps:\n   - name: deps\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n   - name: build\n     image: node:latest\n     commands:\n       - go build\n```\n\nThis would be equivalent to the following docker commands:\n\n```bash\ndocker volume create my-named-volume\n\ndocker run --volume=my-named-volume:/go golang:latest\ndocker run --volume=my-named-volume:/go node:latest\n```\n\nThe path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path.\n\n```diff\n workspace:\n   base: /go\n+  path: src/github.com/octocat/hello-world\n```\n\n```bash\ngit clone https://github.com/octocat/hello-world \\\n  /go/src/github.com/octocat/hello-world\n```\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `matrix`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations.\n\nFor more details check the [matrix build docs](./30-matrix-workflows.md).\n\n## `labels`\n\nYou can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent.\n\nTo specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo.\n\nWorkflow labels with an empty value are ignored.\nBy default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`.\n\n:::warning\nLabels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition.\n:::\n\nYou can add additional labels as a key value map:\n\n```diff\n+labels:\n+  location: europe # only agents with `location=europe` or `location=*` will be used\n+  weather: sun\n+  hostname: \"\" # this label will be ignored as it is empty\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\n### Filter by platform\n\nTo configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key.\nHave a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`.\n\nExample:\n\nAssuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`.\n\n```diff\n+labels:\n+  platform: linux/arm64\n\n steps:\n   [...]\n```\n\n## `variables`\n\nWoodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration.\n\nFor more details and examples check the [Advanced usage docs](./90-advanced-usage.md)\n\n## `clone`\n\nWoodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step.\n\nYou can manually configure the clone step in your workflow to customize it:\n\n```diff\n+clone:\n+  git:\n+    image: woodpeckerci/plugin-git\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\nExample configuration to override the depth:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n+    settings:\n+      partial: false\n+      depth: 50\n```\n\nExample configuration to use a custom clone plugin:\n\n```diff\n clone:\n   - name: git\n+    image: octocat/custom-git-plugin\n```\n\n### Git Submodules\n\nTo use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`:\n\n```diff\n [submodule \"my-module\"]\n path = my-module\n-url = git@github.com:octocat/my-module.git\n+url = https://github.com/octocat/my-module.git\n```\n\nTo use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n     settings:\n       recursive: true\n+      submodule_override:\n+        my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  ...\n```\n\n## `skip_clone`\n\n:::warning\nThe default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`.\n:::\n\nBy default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using:\n\n```yaml\nskip_clone: true\n```\n\n## `when` - Global workflow conditions\n\nWoodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue.\n\nFor more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution).\n\nExample conditional execution by branch:\n\n```diff\n+when:\n+  branch: main\n+\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n```\n\nThe workflow now triggers on `main`, but also if the target branch of a pull request is `main`.\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `depends_on`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword.\n\n## `runs_on`\n\nWorkflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example.\n\n## Advanced network options for steps\n\n:::warning\nOnly allowed if 'Trusted Network' option is enabled in repo settings by an admin.\n:::\n\n### `dns`\n\nIf the backend engine understands to change the DNS server and lookup domain,\nthis options will be used to alter the default DNS config to a custom one for a specific step.\n\n```yaml\nsteps:\n  - name: build\n    image: plugin/abc\n    dns: 1.2.3.4\n    dns_search: 'internal.company'\n```\n\n## Privileged mode\n\nWoodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities.\n\n:::info\nPrivileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     environment:\n       - DOCKER_HOST=tcp://docker:2375\n     commands:\n       - docker --tls=false ps\n\n services:\n   - name: docker\n     image: docker:dind\n     commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false\n+    privileged: true\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/25-workflows.md",
    "content": "# Workflows\n\nA pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps.\n\nIn case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow.\n\nBy placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored.\n\nYou can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md).\n\n## Benefits of using workflows\n\n- faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote\n- better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying\n- utilizing more agents to speed up the execution of the whole pipeline\n\n## Example workflow definition\n\n:::warning\nPlease note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow.\nIf you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket).\n:::\n\n```bash\n.woodpecker/\n├── build.yaml\n├── deploy.yaml\n├── lint.yaml\n└── test.yaml\n```\n\n```yaml title=\".woodpecker/build.yaml\"\nsteps:\n  - name: build\n    image: debian:stable-slim\n    commands:\n      - echo building\n      - sleep 5\n```\n\n```yaml title=\".woodpecker/deploy.yaml\"\nsteps:\n  - name: deploy\n    image: debian:stable-slim\n    commands:\n      - echo deploying\n\ndepends_on:\n  - lint\n  - build\n  - test\n```\n\n```yaml title=\".woodpecker/test.yaml\"\nsteps:\n  - name: test\n    image: debian:stable-slim\n    commands:\n      - echo testing\n      - sleep 5\n\ndepends_on:\n  - build\n```\n\n```yaml title=\".woodpecker/lint.yaml\"\nsteps:\n  - name: lint\n    image: debian:stable-slim\n    commands:\n      - echo linting\n      - sleep 5\n```\n\n## Status lines\n\nEach workflow will report its own status back to your forge.\n\n## Flow control\n\nThe workflows run in parallel on separate agents and share nothing.\n\nDependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully.\n\nThe name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`.\n\n```diff\n steps:\n   - name: deploy\n     image: debian:stable-slim\n     commands:\n       - echo deploying\n\n+depends_on:\n+  - lint\n+  - build\n+  - test\n```\n\nWorkflows that need to run even on failures should set the `runs_on` tag.\n\n```diff\n steps:\n   - name: notify\n     image: debian:stable-slim\n     commands:\n       - echo notifying\n\n depends_on:\n   - deploy\n\n+runs_on: [ success, failure ]\n```\n\n:::info\nSome workflows don't need the source code, like creating a notification on failure.\nRead more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone)\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/30-matrix-workflows.md",
    "content": "# Matrix workflows\n\nWoodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations.\n\n:::warning\nWoodpecker currently supports a maximum of **27 matrix axes** per workflow.\nIf your matrix exceeds this number, any additional axes will be silently ignored.\n:::\n\nExample matrix definition:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  REDIS_VERSION:\n    - 2.6\n    - 2.8\n    - 3.0\n```\n\nExample matrix definition containing only specific combinations:\n\n```yaml\nmatrix:\n  include:\n    - GO_VERSION: 1.4\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.6\n      REDIS_VERSION: 3.0\n```\n\n## Interpolation\n\nMatrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:8\n    - mysql:5\n    - mariadb:10.1\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go get\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: ${DATABASE}\n```\n\nExample YAML file after injecting the matrix parameters:\n\n```diff\n steps:\n   - name: build\n-    image: golang:${GO_VERSION}\n+    image: golang:1.4\n     commands:\n       - go get\n       - go build\n       - go test\n+    environment:\n+      - GO_VERSION=1.4\n+      - DATABASE=mysql:8\n\n services:\n   - name: database\n-    image: ${DATABASE}\n+    image: mysql:8\n```\n\n## Examples\n\n### Example matrix pipeline based on Docker image tag\n\n```yaml\nmatrix:\n  TAG:\n    - 1.7\n    - 1.8\n    - latest\n\nsteps:\n  - name: build\n    image: golang:${TAG}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline based on container image\n\n```yaml\nmatrix:\n  IMAGE:\n    - golang:1.7\n    - golang:1.8\n    - golang:latest\n\nsteps:\n  - name: build\n    image: ${IMAGE}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline using multiple platforms\n\n```yaml\nmatrix:\n  platform:\n    - linux/amd64\n    - linux/arm64\n\nlabels:\n  platform: ${platform}\n\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n\n  - name: test-arm-only\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n      - echo \"Arm is cool!\"\n    when:\n      platform: linux/arm*\n```\n\n:::note\nIf you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector).\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/40-secrets.md",
    "content": "# Secrets\n\nWoodpecker provides the ability to store named variables in a central secret store.\nThese secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`.\n\nThere are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins):\n\n1. **Repository secrets**: Available for all pipelines of a repository.\n1. **Organization secrets**: Available for all pipelines of an organization.\n1. **Global secrets**: Can only be set by instance administrators.\n   Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution.\n\nIn addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources.\n\n:::warning\nWoodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs.\n:::\n\n## Usage\n\nYou can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax.\n\nThe following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`:\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n     commands:\n+      - echo \"The secret is $TOKEN_ENV\"\n+    environment:\n+      TOKEN_ENV:\n+        from_secret: secret_token\n```\n\nThe same syntax can be used to pass secrets to (plugin) settings.\nA secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details).\n`PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution.\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n+    settings:\n+      TOKEN:\n+        from_secret: secret_token\n```\n\n### Escape secrets\n\nPlease note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts.\nIf secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing.\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n-      - echo ${TOKEN_ENV}\n+      - echo $${TOKEN_ENV}\n     environment:\n       TOKEN_ENV:\n         from_secret: secret_token\n```\n\n### Events filter\n\nBy default, secrets are not exposed to pull requests.\nHowever, you can change this behavior by creating the secret and enabling the `pull_request` event type.\nThis can be configured either via the UI or via the CLI.\n\n:::warning\nBe careful when exposing secrets for pull requests.\nIf your repository is public and accepts pull requests from everyone, your secrets may be at risk.\nMalicious actors could take advantage of this to expose your secrets or transfer them to an external location.\n:::\n\n### Plugins filter\n\nTo prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins.\nIf enabled, they are not available to any other plugins.\nPlugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets.\n\n:::tip\nIf you specify a tag, the filter will take it into account.\nHowever, if the same image appears several times in the list, the least privileged entry will take precedence.\nFor example, an image without a tag will allow all tags, even if it contains another entry with a tag attached.\n:::\n\n![plugins filter](./secrets-plugins-filter.png)\n\n## CLI\n\nIn addition to the UI, secrets can also be managed using the CLI.\n\nCreate the secret with the default settings.\nThe secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events).\n\n```bash\nwoodpecker-cli repo secret add \\\n  --repository octocat/hello-world \\\n  --name aws_access_key_id \\\n  --value <value>\n```\n\nCreate the secret and limit it to a single image:\n\n```diff\n woodpecker-cli secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secrets and limit it to a set of images:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n+  --image woodpeckerci/plugin-docker-buildx \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secret and enable it for multiple hook events:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n   --image woodpeckerci/plugin-s3 \\\n+  --event pull_request \\\n+  --event push \\\n+  --event tag \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nSecrets can be loaded from a file using the syntax `@`.\nThis method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example):\n\n```diff\n woodpecker-cli repo secret add \\\n   -repository octocat/hello-world \\\n   -name ssh_key \\\n+  -value @/root/ssh/id_rsa\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/41-registries.md",
    "content": "# Registries\n\nWoodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries.\n\n## Images from private registries\n\nYou must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file.\n\nThese credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin.\n\nExample configuration using a private image:\n\n```diff\n steps:\n   - name: build\n+    image: gcr.io/custom/golang\n     commands:\n       - go build\n       - go test\n```\n\nWoodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers.\n\nExample registry hostnames:\n\n- Image `gcr.io/foo/bar` has hostname `gcr.io`\n- Image `foo/bar` has hostname `docker.io`\n- Image `qux.com:8000/foo/bar` has hostname `qux.com:8000`\n\nExample registry hostname matching logic:\n\n- Hostname `gcr.io` matches image `gcr.io/foo/bar`\n- Hostname `docker.io` matches `golang`\n- Hostname `docker.io` matches `library/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang:latest`\n\n## Global registry support\n\nTo make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config).\n\n## GCR registry support\n\nFor specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file).\n\n## Local Images\n\n:::warning\nFor this, privileged rights are needed only available to admins. In addition, this only works when using a single agent.\n:::\n\nIt's possible to build a local image by mounting the docker socket as a volume.\n\nWith a `Dockerfile` at the root of the project:\n\n```yaml\nsteps:\n  - name: build-image\n    image: docker\n    commands:\n      - docker build --rm -t local/project-image .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  - name: build-project\n    image: local/project-image\n    commands:\n      - ./build.sh\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/45-cron.md",
    "content": "# Cron\n\nTo configure cron jobs you need at least push access to the repository.\n\n## Add a new cron job\n\n1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job:\n\n   ```diff\n    steps:\n      - name: sync_locales\n        image: weblate_sync\n        settings:\n          url: example.com\n          token:\n            from_secret: weblate_token\n   +    when:\n   +      event: cron\n   +      cron: \"name of the cron job\" # if you only want to execute this step by a specific cron job\n   ```\n\n2. Create a new cron job in the repository settings:\n\n   ![cron settings](./cron-settings.png)\n\n   The supported schedule syntax can be found at <https://pkg.go.dev/github.com/gdgvda/cron#hdr-CRON_Expression_Format>. If you need general understanding of the cron syntax <https://it-tools.tech/crontab-generator> is a good place to start and experiment.\n\n   Examples: `@every 5m`, `@daily`, `30 * * * *` ...\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/50-environment.md",
    "content": "# Environment variables\n\nWoodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables:\n\n```diff\n steps:\n   - name: build\n     image: golang\n+    environment:\n+      CGO: 0\n+      GOOS: linux\n+      GOARCH: amd64\n     commands:\n       - go build\n       - go test\n```\n\nPlease note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section.\n\n```diff\n steps:\n   - name: build\n     image: golang\n-    environment:\n-      - PATH=$PATH:/go\n     commands:\n+      - export PATH=$PATH:/go\n       - go build\n       - go test\n```\n\n:::warning\n`${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped:\n:::\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n-      - export PATH=${PATH}:/go\n+      - export PATH=$${PATH}:/go\n       - go build\n       - go test\n```\n\n## Built-in environment variables\n\nThis is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime.\n\n| NAME                               | Description                                                                                                        | Example                                                                                                    |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |\n| `CI`                               | CI environment name                                                                                                | `woodpecker`                                                                                               |\n|                                    | **Repository**                                                                                                     |                                                                                                            |\n| `CI_REPO`                          | repository full name `<owner>/<name>`                                                                              | `john-doe/my-repo`                                                                                         |\n| `CI_REPO_OWNER`                    | repository owner                                                                                                   | `john-doe`                                                                                                 |\n| `CI_REPO_NAME`                     | repository name                                                                                                    | `my-repo`                                                                                                  |\n| `CI_REPO_REMOTE_ID`                | repository remote ID, is the UID it has in the forge                                                               | `82`                                                                                                       |\n| `CI_REPO_URL`                      | repository web URL                                                                                                 | `https://git.example.com/john-doe/my-repo`                                                                 |\n| `CI_REPO_CLONE_URL`                | repository clone URL                                                                                               | `https://git.example.com/john-doe/my-repo.git`                                                             |\n| `CI_REPO_CLONE_SSH_URL`            | repository SSH clone URL                                                                                           | `git@git.example.com:john-doe/my-repo.git`                                                                 |\n| `CI_REPO_DEFAULT_BRANCH`           | repository default branch                                                                                          | `main`                                                                                                     |\n| `CI_REPO_PRIVATE`                  | repository is private                                                                                              | `true`                                                                                                     |\n| `CI_REPO_TRUSTED_NETWORK`          | repository has trusted network access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_VOLUMES`          | repository has trusted volumes access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_SECURITY`         | repository has trusted security access                                                                             | `false`                                                                                                    |\n|                                    | **Current Commit**                                                                                                 |                                                                                                            |\n| `CI_COMMIT_SHA`                    | commit SHA                                                                                                         | `eba09b46064473a1d345da7abf28b477468e8dbd`                                                                 |\n| `CI_COMMIT_REF`                    | commit ref                                                                                                         | `refs/heads/main`                                                                                          |\n| `CI_COMMIT_REFSPEC`                | commit ref spec                                                                                                    | `issue-branch:main`                                                                                        |\n| `CI_COMMIT_BRANCH`                 | commit branch (equals target branch for pull requests)                                                             | `main`                                                                                                     |\n| `CI_COMMIT_SOURCE_BRANCH`          | commit source branch (set only for pull request events)                                                            | `issue-branch`                                                                                             |\n| `CI_COMMIT_TARGET_BRANCH`          | commit target branch (set only for pull request events)                                                            | `main`                                                                                                     |\n| `CI_COMMIT_TAG`                    | commit tag name (empty if event is not `tag`)                                                                      | `v1.10.3`                                                                                                  |\n| `CI_COMMIT_PULL_REQUEST`           | commit pull request number (set only for pull request events)                                                      | `1`                                                                                                        |\n| `CI_COMMIT_PULL_REQUEST_LABELS`    | labels assigned to pull request (set only for pull request events)                                                 | `server`                                                                                                   |\n| `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events)                  | `summer-sprint`                                                                                            |\n| `CI_COMMIT_MESSAGE`                | commit message                                                                                                     | `Initial commit`                                                                                           |\n| `CI_COMMIT_AUTHOR`                 | commit author username                                                                                             | `john-doe`                                                                                                 |\n| `CI_COMMIT_AUTHOR_EMAIL`           | commit author email address                                                                                        | `john-doe@example.com`                                                                                     |\n| `CI_COMMIT_PRERELEASE`             | release is a pre-release (empty if event is not `release`)                                                         | `false`                                                                                                    |\n|                                    | **Current pipeline**                                                                                               |                                                                                                            |\n| `CI_PIPELINE_NUMBER`               | pipeline number                                                                                                    | `8`                                                                                                        |\n| `CI_PIPELINE_PARENT`               | number of parent pipeline                                                                                          | `0`                                                                                                        |\n| `CI_PIPELINE_EVENT`                | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                            | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PIPELINE_EVENT_REASON`         | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change              | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PIPELINE_URL`                  | link to the web UI for the pipeline                                                                                | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n| `CI_PIPELINE_FORGE_URL`            | link to the forge's web UI for the commit(s) or tag that triggered the pipeline                                    | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd`                 |\n| `CI_PIPELINE_DEPLOY_TARGET`        | pipeline deploy target for `deployment` events                                                                     | `production`                                                                                               |\n| `CI_PIPELINE_DEPLOY_TASK`          | pipeline deploy task for `deployment` events                                                                       | `migration`                                                                                                |\n| `CI_PIPELINE_CREATED`              | pipeline created UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_STARTED`              | pipeline started UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_FILES`                | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[\".woodpecker.yml\",\"README.md\"]`                                                                    |\n| `CI_PIPELINE_AUTHOR`               | pipeline author username                                                                                           | `octocat`                                                                                                  |\n| `CI_PIPELINE_AVATAR`               | pipeline author avatar                                                                                             | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | **Current workflow**                                                                                               |                                                                                                            |\n| `CI_WORKFLOW_NAME`                 | workflow name                                                                                                      | `release`                                                                                                  |\n|                                    | **Current step**                                                                                                   |                                                                                                            |\n| `CI_STEP_NAME`                     | step name                                                                                                          | `build package`                                                                                            |\n| `CI_STEP_NUMBER`                   | step number                                                                                                        | `0`                                                                                                        |\n| `CI_STEP_STARTED`                  | step started UNIX timestamp                                                                                        | `1722617519`                                                                                               |\n| `CI_STEP_URL`                      | URL to step in UI                                                                                                  | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n|                                    | **Previous commit**                                                                                                |                                                                                                            |\n| `CI_PREV_COMMIT_SHA`               | previous commit SHA                                                                                                | `15784117e4e103f36cba75a9e29da48046eb82c4`                                                                 |\n| `CI_PREV_COMMIT_REF`               | previous commit ref                                                                                                | `refs/heads/main`                                                                                          |\n| `CI_PREV_COMMIT_REFSPEC`           | previous commit ref spec                                                                                           | `issue-branch:main`                                                                                        |\n| `CI_PREV_COMMIT_BRANCH`            | previous commit branch                                                                                             | `main`                                                                                                     |\n| `CI_PREV_COMMIT_SOURCE_BRANCH`     | previous commit source branch (set only for pull request events)                                                   | `issue-branch`                                                                                             |\n| `CI_PREV_COMMIT_TARGET_BRANCH`     | previous commit target branch (set only for pull request events)                                                   | `main`                                                                                                     |\n| `CI_PREV_COMMIT_URL`               | previous commit link in forge                                                                                      | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_COMMIT_MESSAGE`           | previous commit message                                                                                            | `test`                                                                                                     |\n| `CI_PREV_COMMIT_AUTHOR`            | previous commit author username                                                                                    | `john-doe`                                                                                                 |\n| `CI_PREV_COMMIT_AUTHOR_EMAIL`      | previous commit author email address                                                                               | `john-doe@example.com`                                                                                     |\n|                                    | **Previous pipeline**                                                                                              |                                                                                                            |\n| `CI_PREV_PIPELINE_NUMBER`          | previous pipeline number                                                                                           | `7`                                                                                                        |\n| `CI_PREV_PIPELINE_PARENT`          | previous pipeline number of parent pipeline                                                                        | `0`                                                                                                        |\n| `CI_PREV_PIPELINE_EVENT`           | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                   | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PREV_PIPELINE_EVENT_REASON`    | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change         | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PREV_PIPELINE_URL`             | previous pipeline link in CI                                                                                       | `https://ci.example.com/repos/7/pipeline/7`                                                                |\n| `CI_PREV_PIPELINE_FORGE_URL`       | previous pipeline link to event in forge                                                                           | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_PIPELINE_DEPLOY_TARGET`   | previous pipeline deploy target for `deployment` events                                                            | `production`                                                                                               |\n| `CI_PREV_PIPELINE_DEPLOY_TASK`     | previous pipeline deploy task for `deployment` events                                                              | `migration`                                                                                                |\n| `CI_PREV_PIPELINE_STATUS`          | previous pipeline status                                                                                           | `success`, `failure`                                                                                       |\n| `CI_PREV_PIPELINE_CREATED`         | previous pipeline created UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_STARTED`         | previous pipeline started UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_FINISHED`        | previous pipeline finished UNIX timestamp                                                                          | `1722610383`                                                                                               |\n| `CI_PREV_PIPELINE_AUTHOR`          | previous pipeline author username                                                                                  | `octocat`                                                                                                  |\n| `CI_PREV_PIPELINE_AVATAR`          | previous pipeline author avatar                                                                                    | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | &emsp;                                                                                                             |                                                                                                            |\n| `CI_WORKSPACE`                     | Path of the workspace where source code gets cloned to                                                             | `/woodpecker/src/git.example.com/john-doe/my-repo`                                                         |\n|                                    | **System**                                                                                                         |                                                                                                            |\n| `CI_SYSTEM_NAME`                   | name of the CI system                                                                                              | `woodpecker`                                                                                               |\n| `CI_SYSTEM_URL`                    | link to CI system                                                                                                  | `https://ci.example.com`                                                                                   |\n| `CI_SYSTEM_HOST`                   | hostname of CI server                                                                                              | `ci.example.com`                                                                                           |\n| `CI_SYSTEM_VERSION`                | version of the server                                                                                              | `2.7.0`                                                                                                    |\n|                                    | **Forge**                                                                                                          |                                                                                                            |\n| `CI_FORGE_TYPE`                    | name of forge                                                                                                      | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab`                                   |\n| `CI_FORGE_URL`                     | root URL of configured forge                                                                                       | `https://git.example.com`                                                                                  |\n|                                    | **Internal** - Please don't use!                                                                                   |                                                                                                            |\n| `CI_SCRIPT`                        | Internal script path. Used to call pipeline step commands.                                                         |                                                                                                            |\n| `CI_NETRC_USERNAME`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_PASSWORD`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_MACHINE`                 | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n\n## Global environment variables\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nThese can be used, for example, to manage the image tag used by multiple projects.\n\n```ini\nWOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18\n```\n\n```diff\n steps:\n   - name: build\n-    image: golang:1.18\n+    image: golang:${GOLANG_VERSION}\n     commands:\n       - [...]\n```\n\n## String Substitution\n\nWoodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration.\n\nExample commit substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA}\n```\n\nExample tag substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG}\n```\n\n## String Operations\n\nWoodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values.\n\n| OPERATION          | DESCRIPTION                                      |\n| ------------------ | ------------------------------------------------ |\n| `${param}`         | parameter substitution                           |\n| `${param,}`        | parameter substitution with lowercase first char |\n| `${param,,}`       | parameter substitution with lowercase            |\n| `${param^}`        | parameter substitution with uppercase first char |\n| `${param^^}`       | parameter substitution with uppercase            |\n| `${param:pos}`     | parameter substitution with substring            |\n| `${param:pos:len}` | parameter substitution with substring and length |\n| `${param=default}` | parameter substitution with default              |\n| `${param##prefix}` | parameter substitution with prefix removal       |\n| `${param%%suffix}` | parameter substitution with suffix removal       |\n| `${param/old/new}` | parameter substitution with find and replace     |\n\nExample variable substitution with substring:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA:0:8}\n```\n\nExample variable substitution strips `v` prefix from `v.1.0.0`:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG##v}\n```\n\n## `pull_request_metadata` specific event reason values\n\nFor the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`.\n\n**GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list.\n\n:::note\nEvent reason values are forge-specific and may change between versions.\n:::\n\n| Event                | GitHub             | Gitea              | Forgejo            | GitLab             | Bitbucket | Bitbucket Datacenter | Description                                                                    |\n| -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ |\n| `assigned`           | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was assigned to a user                                            |\n| `converted_to_draft` | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Pull request was converted to a draft                                          |\n| `demilestoned`       | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was removed from a milestone                                      |\n| `description_edited` | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Description edited                                                             |\n| `edited`             | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:                | :x:       | :x:                  | The title or body of a pull request was edited, or the base branch was changed |\n| `label_added`        | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Pull had no labels and now got label(s) added                                  |\n| `label_cleared`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | All labels removed                                                             |\n| `label_updated`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | New label(s) added / label(s) changed                                          |\n| `locked`             | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was locked                                      |\n| `milestoned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was added to a milestone                                          |\n| `ready_for_review`   | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Draft pull request was marked as ready for review                              |\n| `review_requested`   | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | New review was requested                                                       |\n| `title_edited`       | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Title edited                                                                   |\n| `unassigned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | User was unassigned from a pull request                                        |\n| `unlabeled`          | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Label was removed from a pull request                                          |\n| `unlocked`           | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was unlocked                                    |\n\n**Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/51-plugins/20-creating-plugins.md",
    "content": "# Creating plugins\n\nCreating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT.\n\n## Settings\n\nTo allow users to configure the behavior of your plugin, you should use `settings:`.\n\nThese are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix.\nUsing a setting like `url` results in an env var named `PLUGIN_URL`.\n\nCharacters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`.\nCamelCase is not respected, `anInt` get `PLUGIN_ANINT`. <!-- cspell:ignore ANINT -->\n\n### Basic settings\n\nUsing any basic YAML type (scalar) will be converted into a string:\n\n| Setting              | Environment value            |\n| -------------------- | ---------------------------- |\n| `some-bool: false`   | `PLUGIN_SOME_BOOL=\"false\"`   |\n| `some_String: hello` | `PLUGIN_SOME_STRING=\"hello\"` |\n| `anInt: 3`           | `PLUGIN_ANINT=\"3\"`           |\n\n### Complex settings\n\nIt's also possible to use complex settings like this:\n\n```yaml\nsteps:\n  - name: plugin\n    image: foo/plugin\n    settings:\n      complex:\n        abc: 2\n        list:\n          - 2\n          - 3\n```\n\nValues like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{\"abc\": \"2\", \"list\": [ \"2\", \"3\" ]}`.\n\n### Secrets\n\nSecrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage).\n\n## Plugin library\n\nFor Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See <https://codeberg.org/woodpecker-plugins/go-plugin>.\n\n## Metadata\n\nIn your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins).\n\nSupported metadata:\n\n- `name`: The plugin's full name\n- `icon`: URL to your plugin's icon\n- `description`: A short description of what it's doing\n- `author`: Your name\n- `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin)\n- `containerImage`: name of the container image\n- `containerImageUrl`: link to the container image\n- `url`: homepage or repository of your plugin\n\nIf you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required.\n\n## Example plugin\n\nThis provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline.\n\n### What end users will see\n\nThe below example demonstrates how we might configure a webhook plugin in the YAML file:\n\n```yaml\nsteps:\n  - name: webhook\n    image: foo/webhook\n    settings:\n      url: https://example.com\n      method: post\n      body: |\n        hello world\n```\n\n### Write the logic\n\nCreate a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`.\n\n```bash\n#!/bin/sh\n\ncurl \\\n  -X ${PLUGIN_METHOD} \\\n  -d ${PLUGIN_BODY} \\\n  ${PLUGIN_URL}\n```\n\n### Package it\n\nCreate a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint.\n\n```dockerfile\n# please pin the version, e.g. alpine:3.19\nFROM alpine\nADD script.sh /bin/\nRUN chmod +x /bin/script.sh\nRUN apk -Uuv add curl ca-certificates\nENTRYPOINT /bin/script.sh\n```\n\nBuild and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community.\n\n```shell\ndocker build -t foo/webhook .\ndocker push foo/webhook\n```\n\nExecute your plugin locally from the command line to verify it is working:\n\n```shell\ndocker run --rm \\\n  -e PLUGIN_METHOD=post \\\n  -e PLUGIN_URL=https://example.com \\\n  -e PLUGIN_BODY=\"hello world\" \\\n  foo/webhook\n```\n\n## Best practices\n\n- Build your plugin for different architectures to allow many users to use them.\n  At least, you should support `amd64` and `arm64`.\n- Provide binaries for users using the `local` backend.\n  These should also be built for different OS/architectures.\n- Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible.\n- Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names.\n- Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)).\n- Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/51-plugins/51-overview.md",
    "content": "# Plugins\n\nPlugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline.\nPlugins can be used to deploy code, publish artifacts, send notification, and more.\n\nThey are automatically pulled from the default container registry the agent's have configured.\n\n```dockerfile title=\"Dockerfile\"\nFROM cloud/kubectl\nCOPY deploy /usr/local/deploy\nENTRYPOINT [\"/usr/local/deploy\"]\n```\n\n```bash title=\"deploy\"\nkubectl apply -f $PLUGIN_TEMPLATE\n```\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: deploy-to-k8s\n    image: cloud/my-k8s-plugin\n    settings:\n      template: config/k8s/service.yaml\n```\n\nExample pipeline using the Prettier and S3 plugins:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  - name: prettier\n    image: woodpeckerci/plugin-prettier\n\n  - name: publish\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      source: some-file-name\n      target: /target/some-file\n```\n\n## Plugin Isolation\n\nPlugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree.\nWhile normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author.\n\nThat's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically\nadjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands`\nor `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin\nanymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition.\n\n## Finding Plugins\n\nFor official plugins, you can use the Woodpecker plugin index:\n\n- [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins)\n\n:::tip\nThere are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking.\n\n- [Drone Plugins](http://plugins.drone.io)\n- [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/)\n- [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community)\n\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/51-plugins/_category_.yaml",
    "content": "label: 'Plugins'\n# position: 2\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/60-services.md",
    "content": "# Services\n\nWoodpecker provides a services section in the YAML file used for defining service containers.\nThe below configuration composes database and cache containers.\n\nServices are accessed using custom hostnames.\nIn the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`.\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n\n  - name: cache\n    image: redis\n```\n\nYou can define a port and a protocol explicitly:\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    ports:\n      - 3306\n\n  - name: wireguard\n    image: wg\n    ports:\n      - 51820/udp\n```\n\n## Configuration\n\nService containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more.\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    environment:\n+      MYSQL_DATABASE: test\n+      MYSQL_ALLOW_EMPTY_PASSWORD: yes\n\n   - name: cache\n     image: redis\n```\n\n## Detachment\n\nService and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required.\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n\n   - name: database\n     image: redis\n+    detach: true\n\n   - name: test\n     image: golang\n     commands:\n       - go test\n```\n\nContainers from detached steps will terminate when the pipeline ends.\n\n## Initialization\n\nService containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff.\n\n```diff\n steps:\n   - name: test\n     image: golang\n     commands:\n+      - sleep 15\n       - go get\n       - go test\n\n services:\n   - name: database\n     image: mysql\n```\n\n## Complete Pipeline Example\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ROOT_PASSWORD: example\nsteps:\n  - name: get-version\n    image: ubuntu\n    commands:\n      - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null\n      - sleep 30s # need to wait for mysql-server init\n      - echo 'SHOW VARIABLES LIKE \"version\"' | mysql -u root -h database test -p example\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/70-volumes.md",
    "content": "# Volumes\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\n:::note\nVolumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     commands:\n       - docker build --rm -t octocat/hello-world .\n       - docker run --rm octocat/hello-world --test\n       - docker push octocat/hello-world\n       - docker rmi octocat/hello-world\n     volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nIf you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`.\n\nPlease note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error.\n\n```diff\n-volumes: [ ./certs:/etc/ssl/certs ]\n+volumes: [ /etc/ssl/certs:/etc/ssl/certs ]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/72-extensions/40-configuration-extension.md",
    "content": "# Configuration extension\n\nThe configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n<!-- cSpell:words templating,Starlark,Jsonnet -->\n\n- Preprocess the original configuration file with something like Go templating\n- Convert custom attributes to Woodpecker attributes\n- Add defaults to the configuration like default steps\n- Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ...\n- Centralize configuration for multiple repositories in one place\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your configuration extension.\n\nThe global configuration will be called before the repository specific configuration extension if both are configured.\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc: Netrc;\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to fetch files or other information (like changed files, issues) from the repository using the forge api or even clone the repository.\n:::\n\nExample request:\n\n```json\n// Please check the latest structure in the models mentioned above.\n// This example is likely outdated.\n\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"myPassword\",\n    \"type\": \"forge\"\n  }\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  configs: {\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/72-extensions/_category_.yaml",
    "content": "label: 'Extensions'\n# position: 3\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'index'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/72-extensions/index.md",
    "content": "# Extensions\n\nWoodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints.\n\nThere is currently one type of extension available:\n\n- [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly.\n\n## Security\n\n:::warning\nYou need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful\ndata like malicious pipeline configurations that could be executed.\n:::\n\nTo prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair.\nTo verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign).\nYou can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page.\n\n## Example extensions\n\nA simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions)\n\n## Configuration\n\nTo prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of:\n\n- Built-in networks:\n  - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.\n  - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.\n  - `external`: A valid non-private unicast IP, you can access all hosts on public internet.\n  - `*`: All hosts are allowed.\n- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6\n- (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/72-linter.md",
    "content": "# Linter\n\nWoodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines.\n\n![errors and warnings in UI](./linter-warnings-errors.png)\n\n## Running the linter from CLI\n\nYou can run the linter also manually from the CLI:\n\n```shell\nwoodpecker-cli lint <workflow files>\n```\n\n## Bad habit warnings\n\nWoodpecker warns you if your configuration contains some bad habits.\n\n### Event filter for all steps\n\nAll your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well.\n\nExamples of an **incorrect** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n  - event: tag\n```\n\nThis will trigger the warning because the first item (`branch: main`) does not filter with an event.\n\n```yaml\nsteps:\n  - name: test\n    when:\n      branch: main\n\n  - name: deploy\n    when:\n      event: tag\n```\n\nExamples of a **correct** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n    event: push\n  - event: tag\n```\n\n```yaml\nsteps:\n  - name: test\n    when:\n      event: [tag, push]\n\n  - name: deploy\n    when:\n      - event: tag\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/75-project-settings.md",
    "content": "# Project settings\n\nAs the owner of a project in Woodpecker you can change project related settings via the web interface.\n\n![project settings](./project-settings.png)\n\n## Pipeline path\n\nThe path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`.\n\n## Repository hooks\n\nYour Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting.\n\n## Allow pull requests\n\nEnables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests.\n\n## Allow deployments\n\nEnables a pipeline to be started with the `deploy` event from a successful pipeline.\n\n:::danger\nOnly activate this option if you trust all users who have push access to your repository.\nOtherwise, these users will be able to steal secrets that are only available for `deploy` events.\n:::\n\n## Require approval for\n\nTo prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`.\n\n## Trusted\n\nIf you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes.\n\n:::note\n\nOnly server admins can set this option. If you are not a server admin this option won't be shown in your project settings.\n\n:::\n\n## Custom trusted clone plugins\n\nDuring the clone process, Git credentials (e.g., for private repositories) may be required.\nThese credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html).\n\nThese credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting.\n\nWith these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo.\nTo prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level.\nWithout an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step.\n\n:::info\nThis setting does not affect subsequent steps, nor does it allow direct pushes to the repository.\nTo enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push).\n:::\n\n## Project visibility\n\nYou can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners.\n\n- `Public` Every user can see your project without being logged in.\n- `Internal` Only authenticated users of the Woodpecker instance can see this project.\n- `Private` Only you and other owners of the repository can see this project.\n\n## Timeout\n\nAfter this timeout a pipeline has to finish or will be treated as timed out.\n\n## Cancel previous pipelines\n\nBy enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/80-badges.md",
    "content": "# Status Badges\n\nWoodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code.\n\n## Badge endpoint\n\n```uri\n<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n```\n\nThe status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter.\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<branch>\n```\n\nBy default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state.\nIf you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event:\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?events=manual,cron\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/90-advanced-usage.md",
    "content": "# Advanced usage\n\n## Advanced YAML syntax\n\nYAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config:\n\n### Anchors & aliases\n\nYou can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config.\n\nTo convert this:\n\n```yaml\nsteps:\n  - name: test\n    image: golang:1.18\n    commands: go test ./...\n  - name: build\n    image: golang:1.18\n    commands: build\n```\n\nJust add a new section called **variables** like this:\n\n```diff\n+variables:\n+  - &golang_image 'golang:1.18'\n\n steps:\n   - name: test\n-    image: golang:1.18\n+    image: *golang_image\n     commands: go test ./...\n   - name: build\n-    image: golang:1.18\n+    image: *golang_image\n     commands: build\n```\n\n### Map merges and overwrites\n\n```yaml\nvariables:\n  - &base-plugin-settings\n    target: dist\n    recursive: false\n    try: true\n  - &special-setting\n    special: true\n  - &some-plugin codeberg.org/6543/docker-images/print_env\n\nsteps:\n  - name: develop\n    image: *some-plugin\n    settings:\n      <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map\n    when:\n      branch: develop\n\n  - name: main\n    image: *some-plugin\n    settings:\n      <<: *base-plugin-settings # merge one map and ...\n      try: false # ... overwrite original value\n      ongoing: false # ... adding a new value\n    when:\n      branch: main\n```\n\n### Sequence merges\n\n```yaml\nvariables:\n  pre_cmds: &pre_cmds\n    - echo start\n    - whoami\n  post_cmds: &post_cmds\n    - echo stop\n  hello_cmd: &hello_cmd\n    - echo hello\n\nsteps:\n  - name: step1\n    image: debian\n    commands:\n      - <<: *pre_cmds # prepend a sequence\n      - echo exec step now do dedicated things\n      - <<: *post_cmds # append a sequence\n  - name: step2\n    image: debian\n    commands:\n      - <<: [*pre_cmds, *hello_cmd] # prepend two sequences\n      - echo echo from second step\n      - <<: *post_cmds\n```\n\n### References\n\n- [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases)\n- [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml)\n\n## Persisting environment data between steps\n\nOne can create a file containing environment variables, and then source it in each step that needs them.\n\n```yaml\nsteps:\n  - name: init\n    image: bash\n    commands:\n      - echo \"FOO=hello\" >> envvars\n      - echo \"BAR=world\" >> envvars\n\n  - name: debug\n    image: bash\n    commands:\n      - source ./envvars\n      - echo $FOO\n```\n\n## Declaring global variables\n\nAs described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables:\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nNote that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps.\n\n## Docker in docker (dind) setup\n\n:::warning\nThis set up will only work on trusted repositories and for security reasons should only be used in private environments.\nSee [project settings](./75-project-settings.md#trusted) to enable \"trusted\" mode.\n:::\n\nThe snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service.\n\n:::note\nIf your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead.\n:::\n\nFirst we need to define a service running a docker with the `dind` tag.\nThis service must run in `privileged` mode:\n\n```yaml\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    ports:\n      - 2376\n```\n\nNext, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28).\n\nThis can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below).\n\n```diff\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n+    environment:\n+      DOCKER_TLS_CERTDIR: /dind-certs\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n     ports:\n       - 2376\n```\n\nIn the docker client step:\n\n1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon.\n   These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them).\n2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`)\n\nTest the connection with the docker client:\n\n```diff\nsteps:\n  - name: test\n    image: docker:cli # in production use something like 'docker:<major version>-cli'\n+    environment:\n+      DOCKER_HOST: \"tcp://docker:2376\"\n+      DOCKER_CERT_PATH: \"/dind-certs/client\"\n+      DOCKER_TLS_VERIFY: \"1\"\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n```\n\nThis step should output the server and client version information if everything has been set up correctly.\n\nFull example:\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /dind-certs\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    ports:\n      - 2376\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/20-usage/_category_.yaml",
    "content": "label: 'Usage'\n# position: 2\ncollapsible: true\ncollapsed: false\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/00-general.md",
    "content": "# General\n\nWoodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`).\n\nThe **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files.\n\nThe **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance.\n\nThe **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time).\n\n:::tip\nYou can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent.\n:::\n\n## Database\n\nWoodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page.\n\n## Forge\n\nWhat would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page.\n\n## Container images\n\n:::info\nNo `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch.\n:::\n\n- `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image)\n  - `vX.Y`\n  - `vX`\n- `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0).\n  - `vX.Y-alpine`\n  - `vX-alpine`\n- `next`: Built from the `main` branch\n- `pull_<PR_ID>`: Images built from Pull Request branches.\n\nImages are pushed to DockerHub and Quay.\n\n- woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server))\n- woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent))\n- woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli))\n- woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler))\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/05-installation/10-docker-compose.md",
    "content": "# Docker Compose\n\nThis example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings.\n\nIt creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information.\n\nThe server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it.\n\n```yaml title=\"docker-compose.yaml\"\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:v3\n    ports:\n      - 8000:8000\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\n  woodpecker-agent:\n    image: woodpeckerci/woodpecker-agent:v3\n    command: agent\n    restart: always\n    depends_on:\n      - woodpecker-server\n    volumes:\n      - woodpecker-agent-config:/etc/woodpecker\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\nvolumes:\n  woodpecker-server-data:\n  woodpecker-agent-config:\n```\n\nWoodpecker must know its own address. You must therefore specify the public address in the format `<scheme>://<hostname>`. Please omit any trailing slashes:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_HOST=${WOODPECKER_HOST}\n```\n\nIt is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR}\n+      - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR}\n```\n\nIf the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_SECURE=true # defaults to false\n+      - WOODPECKER_GRPC_VERIFY=true # default\n```\n\nAs agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n+    volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nAgents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER=woodpecker-server:9000\n```\n\nThe server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Handling sensitive data\n\nThere are several options for handling sensitive data in `docker compose` or `docker swarm` configurations:\n\nFor Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure.\n\nAlternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret\n+    secrets:\n+      - woodpecker-agent-secret\n+\n+ secrets:\n+   woodpecker-agent-secret:\n+     external: true\n```\n\nTo store values in a docker secret you can use the following command:\n\n```bash\necho \"my_agent_secret_key\" | docker secret create woodpecker-agent-secret -\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/05-installation/20-helm-chart.md",
    "content": "# Helm Chart\n\nWoodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments:\n\n```bash\nhelm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version <VERSION>\n```\n\n## Metrics\n\nTo enable metrics gathering, set the following in values.yml:\n\n```yaml\nmetrics:\n  enabled: true\n  port: 9001\n```\n\nThis activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics.\n\nTo enable both Prometheus pod monitoring discovery, set:\n\n<!-- cspell:disable -->\n\n```yaml\nprometheus:\n  podmonitor:\n    enabled: true\n    interval: 60s\n    labels: {}\n```\n\n<!-- cspell:enable -->\n\nIf you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled:\n\n```yaml\n# Search all available namespaces\npodMonitorNamespaceSelector:\n  matchLabels: {}\n# Enable all available pod monitors\npodMonitorSelector:\n  matchLabels: {}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/05-installation/30-packages.md",
    "content": "# Distribution packages\n\n## Official packages\n\n- DEB\n- RPM\n\nThe pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution.\n\n```Shell\nRELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '\"tag_name\":\\s\"v\\K[^\"]+')\n\n# Debian/Ubuntu (x86_64)\ncurl -fLOOO \"https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\"\nsudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\n\n# CentOS/RHEL (x86_64)\nsudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm\n```\n\nThe package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-server.service\"\n[Unit]\nDescription=WoodpeckerCI server\nDocumentation=https://woodpecker-ci.org/docs/administration/server-config\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env\nConditionPathExists=/etc/woodpecker/woodpecker-server.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-server.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-server\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-server.env\"\nWOODPECKER_OPEN=true\nWOODPECKER_HOST=${WOODPECKER_HOST}\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\nWOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\nAfter installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-agent.service\"\n[Unit]\nDescription=WoodpeckerCI agent\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/agent\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env\nConditionPathExists=/etc/woodpecker/woodpecker-agent.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-agent.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-agent\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-agent.env\"\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Community packages\n\n:::info\nWoodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions.\n:::\n\n- [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=)\n- [Arch Linux](https://archlinux.org/packages/?q=woodpecker)\n- [openSUSE](https://software.opensuse.org/package/woodpecker)\n- [YunoHost](https://apps.yunohost.org/app/woodpecker)\n- [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html)\n- [Easypanel](https://easypanel.io/docs/templates/woodpeckerci)\n\n### NixOS\n\n:::info\nThis module is not maintained by the Woodpecker developers.\nIf you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained.\n:::\n\nIn theory, the NixOS installation is very similar to the binary installation and supports multiple backends.\nIn practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken.\n\n<!-- cspell:words Optimisation -->\n\n```nix\n{ config\n, ...\n}:\nlet\n  domain = \"woodpecker.example.org\";\nin\n{\n  # This automatically sets up certificates via let's encrypt\n  security.acme.defaults.email = \"acme@example.com\";\n  security.acme.acceptTerms = true;\n\n  # Setting up a nginx proxy that handles tls for us\n  services.nginx = {\n    enable = true;\n    openFirewall = true;\n    recommendedTlsSettings = true;\n    recommendedOptimisation = true;\n    recommendedProxySettings = true;\n    virtualHosts.\"${domain}\" = {\n      enableACME = true;\n      forceSSL = true;\n      locations.\"/\".proxyPass = \"http://localhost:3007\";\n    };\n  };\n\n  services.woodpecker-server = {\n    enable = true;\n    environment = {\n      WOODPECKER_HOST = \"https://${domain}\";\n      WOODPECKER_SERVER_ADDR = \":3007\";\n      WOODPECKER_OPEN = \"true\";\n    };\n    # You can pass a file with env vars to the system it could look like:\n    # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX\n    environmentFile = \"/path/to/my/secrets/file\";\n  };\n\n  # This sets up a woodpecker agent\n  services.woodpecker-agents.agents.\"docker\" = {\n    enable = true;\n    # We need this to talk to the podman socket\n    extraGroups = [ \"podman\" ];\n    environment = {\n      WOODPECKER_SERVER = \"localhost:9000\";\n      WOODPECKER_MAX_WORKFLOWS = \"4\";\n      DOCKER_HOST = \"unix:///run/podman/podman.sock\";\n      WOODPECKER_BACKEND = \"docker\";\n    };\n    # Same as with woodpecker-server\n    environmentFile = [ \"/var/lib/secrets/woodpecker.env\" ];\n  };\n\n  # Here we setup podman and enable dns\n  virtualisation.podman = {\n    enable = true;\n    defaultNetwork.settings = {\n      dns_enabled = true;\n    };\n  };\n  # This is needed for podman to be able to talk over dns\n  networking.firewall.interfaces.\"podman0\" = {\n    allowedUDPPorts = [ 53 ];\n    allowedTCPPorts = [ 53 ];\n  };\n}\n```\n\nAll configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/05-installation/_category_.yaml",
    "content": "label: 'Installation'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/10-server.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Server\n\n## Forge and User configuration\n\nWoodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge.\n\nYou can also restrict the registration:\n\n- closed registration and manually managing users with the CLI `woodpecker-cli user`\n- open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN`\n\n  ```ini\n  WOODPECKER_OPEN=false\n  WOODPECKER_ADMIN=john.smith,jane_doe\n  ```\n\n- open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS`\n\n  ```ini\n  WOODPECKER_OPEN=true\n  WOODPECKER_ORGS=dolores,dog-patch\n  ```\n\nAdministrators should also be explicitly set in your configuration.\n\n```ini\nWOODPECKER_ADMIN=john.smith,jane_doe\n```\n\n## Repository configuration\n\nWoodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here.\n\n```ini\nWOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user\n```\n\n## Databases\n\nThe default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind:\n\n- Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`.\n\n- Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably.\n\n- Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes.\n\n- Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice.\n\n### SQLite\n\nBy default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n+    volumes:\n+      - woodpecker-server-data:/var/lib/woodpecker/\n```\n\n### MySQL/MariaDB\n\nThe below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples.\nThe minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=mysql\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n```\n\n### PostgreSQL\n\nThe below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples.\nPlease use Postgres versions equal or higher than **11**.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=postgres\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable\n```\n\n## TLS\n\nWoodpecker supports SSL configuration by mounting certificates into your container.\n\n```ini\nWOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\nWOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n\nTLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library.\n\n### Container configuration\n\nIn addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     ports:\n+      - 80:80\n+      - 443:443\n       - 9000:9000\n```\n\nAdditionally, the certificate and key must be mounted and referenced:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\n+      - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n     volumes:\n+      - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt\n+      - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key\n```\n\n## Reverse Proxy\n\n### Apache\n\nThis guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration:\n\n<!-- cspell:ignore apacheconf -->\n\n```apacheconf\nProxyPreserveHost On\n\nRequestHeader set X-Forwarded-Proto \"https\"\n\nProxyPass / http://127.0.0.1:8000/\nProxyPassReverse / http://127.0.0.1:8000/\n```\n\nYou must have these Apache modules installed:\n\n- `proxy`\n- `proxy_http`\n\nYou must configure Apache to set `X-Forwarded-Proto` when using https.\n\n```diff\n ProxyPreserveHost On\n\n+RequestHeader set X-Forwarded-Proto \"https\"\n\n ProxyPass / http://127.0.0.1:8000/\n ProxyPassReverse / http://127.0.0.1:8000/\n```\n\n### Nginx\n\nThis guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide).\n\nExample configuration:\n\n```nginx\nserver {\n    listen 80;\n    server_name woodpecker.example.com;\n\n    location / {\n        proxy_set_header X-Forwarded-For $remote_addr;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Host $http_host;\n\n        proxy_pass http://127.0.0.1:8000;\n        proxy_redirect off;\n        proxy_http_version 1.1;\n        proxy_buffering off;\n\n        chunked_transfer_encoding off;\n    }\n}\n```\n\nYou must configure the proxy to set `X-Forwarded` proxy headers:\n\n```diff\n server {\n     listen 80;\n     server_name woodpecker.example.com;\n\n     location / {\n+        proxy_set_header X-Forwarded-For $remote_addr;\n+        proxy_set_header X-Forwarded-Proto $scheme;\n\n         proxy_pass http://127.0.0.1:8000;\n         proxy_redirect off;\n         proxy_http_version 1.1;\n         proxy_buffering off;\n\n         chunked_transfer_encoding off;\n     }\n }\n```\n\n### Caddy\n\nThis guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration:\n\n```caddy\n# expose WebUI and API\nwoodpecker.example.com {\n  reverse_proxy woodpecker-server:8000\n}\n\n# expose gRPC\nwoodpecker-agent.example.com {\n  reverse_proxy h2c://woodpecker-server:9000\n}\n```\n\n### Tunnelmole\n\n[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.\n\nStart by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation).\n\nAfter the installation, run the following command to start tunnelmole:\n\n```bash\ntmole 8000\n```\n\nIt will start a tunnel and will give a response like this:\n\n```bash\n➜  ~ tmole 8000\nhttp://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\nhttps://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\n```\n\nSet `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server.\n\n### Ngrok\n\n[Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command:\n\n```bash\nngrok http 8000\n```\n\nSet `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server.\n\n### Traefik\n\nTo install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https.\n\n<!-- cspell:words redirectscheme certresolver  -->\n\n```yaml\nservices:\n  server:\n    image: woodpeckerci/woodpecker-server:latest\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=your_admin_user\n      # other settings ...\n\n    networks:\n      - dmz # externally defined network, so that traefik can connect to the server\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n\n    deploy:\n      labels:\n        - traefik.enable=true\n\n        # web server\n        - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000\n\n        - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker-secure.tls=true\n        - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-secure.service=woodpecker-service\n\n        - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker.entrypoints=web\n        - traefik.http.routers.woodpecker.service=woodpecker-service\n\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker\n\n        #  gRPC service\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c\n\n        - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc-secure.tls=true\n        - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc\n\n        - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc.entrypoints=web\n        - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc\n\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker\n\nvolumes:\n  woodpecker-server-data:\n    driver: local\n\nnetworks:\n  dmz:\n    external: true\n```\n\n## Metrics\n\n### Endpoint\n\nWoodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above.\n\n```yaml\nglobal:\n  scrape_interval: 60s\n\nscrape_configs:\n  - job_name: 'woodpecker'\n    bearer_token: dummyToken...\n\n    static_configs:\n      - targets: ['woodpecker.domain.com']\n```\n\n### Authorization\n\nAn administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token: dummyToken...\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\nAs an alternative, the token can also be read from a file:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token_file: /etc/secrets/woodpecker-monitoring-token\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\n### Reference\n\nList of Prometheus metrics specific to Woodpecker:\n\n```yaml\n# HELP woodpecker_pipeline_count Pipeline count.\n# TYPE woodpecker_pipeline_count counter\nwoodpecker_pipeline_count{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\nwoodpecker_pipeline_count{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\n# HELP woodpecker_pipeline_time Build time.\n# TYPE woodpecker_pipeline_time gauge\nwoodpecker_pipeline_time{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 116\nwoodpecker_pipeline_time{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 155\n# HELP woodpecker_pipeline_total_count Total number of builds.\n# TYPE woodpecker_pipeline_total_count gauge\nwoodpecker_pipeline_total_count 1025\n# HELP woodpecker_pending_steps Total number of pending pipeline steps.\n# TYPE woodpecker_pending_steps gauge\nwoodpecker_pending_steps 0\n# HELP woodpecker_repo_count Total number of repos.\n# TYPE woodpecker_repo_count gauge\nwoodpecker_repo_count 9\n# HELP woodpecker_running_steps Total number of running pipeline steps.\n# TYPE woodpecker_running_steps gauge\nwoodpecker_running_steps 0\n# HELP woodpecker_user_count Total number of users.\n# TYPE woodpecker_user_count gauge\nwoodpecker_user_count 1\n# HELP woodpecker_waiting_steps Total number of pipeline waiting on deps.\n# TYPE woodpecker_waiting_steps gauge\nwoodpecker_waiting_steps 0\n# HELP woodpecker_worker_count Total number of workers.\n# TYPE woodpecker_worker_count gauge\nwoodpecker_worker_count 4\n```\n\n## External Configuration API\n\nTo provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service.\nBefore the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration.\n\nEvery request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/rfc9421) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`.\n\nA simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service)\n\n:::warning\nYou need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks.\n:::\n\n### Configuration\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig\n```\n\n#### Example request made by Woodpecker\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipe\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-file-name.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"https://example.com\",\n    \"login\": \"user\",\n    \"password\": \"password\"\n  }\n}\n```\n\n#### Example response structure\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n\n## UI customization\n\nWoodpecker supports custom JS and CSS files. These files must be present in the server's filesystem.\nThey can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment.\nThe configuration variables are independent of each other, which means it can be just one file present, or both.\n\n```ini\nWOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css\nWOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js\n```\n\nThe examples below show how to place a banner message in the top navigation bar of Woodpecker.\n\n```css title=\"woodpecker.css\"\n.banner-message {\n  position: absolute;\n  width: 280px;\n  height: 40px;\n  margin-left: 240px;\n  margin-top: 5px;\n  padding-top: 5px;\n  font-weight: bold;\n  background: red no-repeat;\n  text-align: center;\n}\n```\n\n```javascript title=\"woodpecker.js\"\n// place/copy a minified version of your preferred lightweight JavaScript library here ...\n!(function () {\n  'use strict';\n  function e() {} /*...*/\n})();\n\n$().ready(function () {\n  $('.app nav img').first().htmlAfter(\"<div class='banner-message'>This is a demo banner message :)</div>\");\n});\n```\n\n## Environment variables\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### LOG_FILE\n\n- Name: `WOODPECKER_LOG_FILE`\n- Default: `stderr`\n\nOutput destination for logs.\n'stdout' and 'stderr' can be used as special keywords.\n\n---\n\n### DATABASE_LOG\n\n- Name: `WOODPECKER_DATABASE_LOG`\n- Default: `false`\n\nEnable logging in database engine (currently xorm).\n\n---\n\n### DATABASE_LOG_SQL\n\n- Name: `WOODPECKER_DATABASE_LOG_SQL`\n- Default: `false`\n\nEnable logging of sql commands.\n\n---\n\n### DATABASE_MAX_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS`\n- Default: `100`\n\nMax database connections xorm is allowed create.\n\n---\n\n### DATABASE_IDLE_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS`\n- Default: `2`\n\nAmount of database connections xorm will hold open.\n\n---\n\n### DATABASE_CONNECTION_TIMEOUT\n\n- Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT`\n- Default: `3 Seconds`\n\nTime an active database connection is allowed to stay open.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOST\n\n- Name: `WOODPECKER_HOST`\n- Default: none\n\nServer fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix.\n\nExamples:\n\n- `WOODPECKER_HOST=http://woodpecker.example.org`\n- `WOODPECKER_HOST=http://example.org/woodpecker`\n- `WOODPECKER_HOST=http://example.org:1234/woodpecker`\n\n---\n\n### SERVER_ADDR\n\n- Name: `WOODPECKER_SERVER_ADDR`\n- Default: `:8000`\n\nConfigures the HTTP listener port.\n\n---\n\n### SERVER_ADDR_TLS\n\n- Name: `WOODPECKER_SERVER_ADDR_TLS`\n- Default: `:443`\n\nConfigures the HTTPS listener port when SSL is enabled.\n\n---\n\n### SERVER_CERT\n\n- Name: `WOODPECKER_SERVER_CERT`\n- Default: none\n\nPath to an SSL certificate used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_CERT=/path/to/cert.pem`\n\n---\n\n### SERVER_KEY\n\n- Name: `WOODPECKER_SERVER_KEY`\n- Default: none\n\nPath to an SSL certificate key used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_KEY=/path/to/key.pem`\n\n---\n\n### CUSTOM_CSS_FILE\n\n- Name: `WOODPECKER_CUSTOM_CSS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .CSS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css`\n\n---\n\n### CUSTOM_JS_FILE\n\n- Name: `WOODPECKER_CUSTOM_JS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .JS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js`\n\n---\n\n### GRPC_ADDR\n\n- Name: `WOODPECKER_GRPC_ADDR`\n- Default: `:9000`\n\nConfigures the gRPC listener port.\n\n---\n\n### GRPC_SECRET\n\n- Name: `WOODPECKER_GRPC_SECRET`\n- Default: `secret`\n\nConfigures the gRPC JWT secret.\n\n---\n\n### GRPC_SECRET_FILE\n\n- Name: `WOODPECKER_GRPC_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GRPC_SECRET` from the specified filepath.\n\n---\n\n### METRICS_SERVER_ADDR\n\n- Name: `WOODPECKER_METRICS_SERVER_ADDR`\n- Default: none\n\nConfigures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely.\n\nExample: `:9001`\n\n---\n\n### ADMIN\n\n- Name: `WOODPECKER_ADMIN`\n- Default: none\n\nComma-separated list of admin accounts.\n\nExample: `WOODPECKER_ADMIN=user1,user2`\n\n---\n\n### ORGS\n\n- Name: `WOODPECKER_ORGS`\n- Default: none\n\nComma-separated list of approved organizations.\n\nExample: `org1,org2`\n\n---\n\n### REPO_OWNERS\n\n- Name: `WOODPECKER_REPO_OWNERS`\n- Default: none\n\nRepositories by those owners will be allowed to be used in woodpecker.\n\nExample: `user1,user2`\n\n---\n\n### OPEN\n\n- Name: `WOODPECKER_OPEN`\n- Default: `false`\n\nEnable to allow user registration.\n\n---\n\n### AUTHENTICATE_PUBLIC_REPOS\n\n- Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`\n- Default: `false`\n\nAlways use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies.\n\n---\n\n### DEFAULT_ALLOW_PULL_REQUESTS\n\n- Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS`\n- Default: `true`\n\nThe default setting for allowing pull requests on a repo.\n\n---\n\n### DEFAULT_APPROVAL_MODE\n\n- Name: `WOODPECKER_DEFAULT_APPROVAL_MODE`\n- Default: `forks`\n\nThe default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`.\n\n---\n\n### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS\n\n- Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS`\n- Default: `pull_request, push`\n\nList of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\n\n---\n\n### DEFAULT_CLONE_PLUGIN\n\n- Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN`\n- Default: `docker.io/woodpeckerci/plugin-git`\n\nThe default docker image to be used when cloning the repo.\n\nIt is also added to the trusted clone plugin list.\n\n### DEFAULT_WORKFLOW_LABELS\n\n- Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS`\n- Default: none\n\nYou can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set.\n\nExample: `platform=linux/amd64,backend=docker`\n\n### DEFAULT_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`\n- Default: 60\n\nThe default time for a repo in minutes before a pipeline gets killed\n\n### MAX_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT`\n- Default: 120\n\nThe maximum time in minutes you can set in the repo settings before a pipeline gets killed\n\n---\n\n### SESSION_EXPIRES\n\n- Name: `WOODPECKER_SESSION_EXPIRES`\n- Default: `72h`\n\nConfigures the session expiration time.\nContext: when someone does log into Woodpecker, a temporary session token is created.\nAs long as the session is valid (until it expires or log-out),\na user can log into Woodpecker, without re-authentication.\n\n### PLUGINS_PRIVILEGED\n\n- Name: `WOODPECKER_PLUGINS_PRIVILEGED`\n- Default: none\n\nDocker images to run in privileged mode. Only change if you are sure what you do!\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n### PLUGINS_TRUSTED_CLONE\n\n- Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE`\n- Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git`\n\nPlugins which are trusted to handle the Git credential info in clone steps.\nIf a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos.\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n<!-- ---\n\n### `VOLUME`\n\n- Name: `WOODPECKER_VOLUME`\n- Default: none\n\nComma-separated list of Docker volumes that are mounted into every pipeline step.\n\nExample: `WOODPECKER_VOLUME=/path/on/host:/path/in/container:rw`| -->\n\n---\n\n### DOCKER_CONFIG\n\n- Name: `WOODPECKER_DOCKER_CONFIG`\n- Default: none\n\nConfigures a specific private registry config for all pipelines.\n\nExample: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json`\n\n---\n\n### ENVIRONMENT\n\n- Name: `WOODPECKER_ENVIRONMENT`\n- Default: none\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\nExample: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2`\n\n<!-- ---\n\n### NETWORK\n\n- Name: `WOODPECKER_NETWORK`\n- Default: none\n\nComma-separated list of Docker networks that are attached to every pipeline step.\n\nExample: `WOODPECKER_NETWORK=network1,network2` -->\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath\n\n---\n\n### DISABLE_USER_AGENT_REGISTRATION\n\n- Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION`\n- Default: false\n\nBy default, users can create new agents for their repos they have admin access to.\nIf an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements.\n\n:::note\nYou should set this option if you have, for example,\nglobal secrets and don't trust your users to create a rogue agent and pipeline for secret extraction.\n:::\n\n---\n\n### KEEPALIVE_MIN_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_MIN_TIME`\n- Default: none\n\nServer-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\n\nExample: `WOODPECKER_KEEPALIVE_MIN_TIME=10s`\n\n---\n\n### DATABASE_DRIVER\n\n- Name: `WOODPECKER_DATABASE_DRIVER`\n- Default: `sqlite3`\n\nThe database driver name. Possible values are `sqlite3`, `mysql` or `postgres`.\n\n---\n\n### DATABASE_DATASOURCE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE`\n- Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container\n\nThe database connection string. The default value is the path of the embedded SQLite database file.\n\nExample:\n\n```bash\n# MySQL\n# https://github.com/go-sql-driver/mysql#dsn-data-source-name\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n\n# PostgreSQL\n# https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable\n```\n\n---\n\n### DATABASE_DATASOURCE_FILE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath\n\n---\n\n### PROMETHEUS_AUTH_TOKEN\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN`\n- Default: none\n\nToken to secure the Prometheus metrics endpoint.\nMust be set to enable the endpoint.\n\n---\n\n### PROMETHEUS_AUTH_TOKEN_FILE\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath\n\n---\n\n### STATUS_CONTEXT\n\n- Name: `WOODPECKER_STATUS_CONTEXT`\n- Default: `ci/woodpecker`\n\nContext prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository.\n\n---\n\n### STATUS_CONTEXT_FORMAT\n\n- Name: `WOODPECKER_STATUS_CONTEXT_FORMAT`\n- Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}`\n\nTemplate for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language.\nSupported variables:\n\n- `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`)\n- `event`: the event which started the pipeline\n- `workflow`: the workflow's name\n- `owner`: the repo's owner\n- `repo`: the repo's name\n\n---\n\n### CONFIG_SERVICE_ENDPOINT\n\n- Name: `WOODPECKER_CONFIG_SERVICE_ENDPOINT`\n- Default: none\n\nSpecify a configuration service endpoint, see [Configuration Extension](#external-configuration-api)\n\n---\n\n### EXTENSIONS_ALLOWED_HOSTS\n\n- Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS`\n- Default: `external`\n\nComma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list.\n\n---\n\n### FORGE_TIMEOUT\n\n- Name: `WOODPECKER_FORGE_TIMEOUT`\n- Default: 5s\n\nSpecify timeout when fetching the Woodpecker configuration from forge. See <https://pkg.go.dev/time#ParseDuration> for syntax reference.\n\n---\n\n### FORGE_RETRY\n\n- Name: `WOODPECKER_FORGE_RETRY`\n- Default: 3\n\nSpecify how many retries of fetching the Woodpecker configuration from a forge are done before we fail.\n\n---\n\n### ENABLE_SWAGGER\n\n- Name: `WOODPECKER_ENABLE_SWAGGER`\n- Default: true\n\nEnable the Swagger UI for API documentation.\n\n---\n\n### DISABLE_VERSION_CHECK\n\n- Name: `WOODPECKER_DISABLE_VERSION_CHECK`\n- Default: false\n\nDisable version check in admin web UI.\n\n---\n\n### LOG_STORE\n\n- Name: `WOODPECKER_LOG_STORE`\n- Default: `database`\n\nWhere to store logs. Possible values:\n\n- `database`: stores the logs in the database\n- `file`: stores logs in JSON files on the files system\n- `addon`: uses an [addon](./100-addons.md#log) to store logs\n\n---\n\n### LOG_STORE_FILE_PATH\n\n- Name: `WOODPECKER_LOG_STORE_FILE_PATH`\n- Default: none\n\nIf [`WOODPECKER_LOG_STORE`](#log_store) is:\n\n- `file`: Directory to store logs in\n- `addon`: The path to the addon executable\n\n---\n\n### EXPERT_WEBHOOK_HOST\n\n- Name: `WOODPECKER_EXPERT_WEBHOOK_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### EXPERT_FORGE_OAUTH_HOST\n\n- Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified public forge URL, used if forge url is not a public URL. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### GITHUB\\_\\*\n\nSee [GitHub configuration](./12-forges/20-github.md#configuration)\n\n---\n\n### GITEA\\_\\*\n\nSee [Gitea configuration](./12-forges/30-gitea.md#configuration)\n\n---\n\n### BITBUCKET\\_\\*\n\nSee [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration)\n\n---\n\n### GITLAB\\_\\*\n\nSee [GitLab configuration](./12-forges/40-gitlab.md#configuration)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/100-addons.md",
    "content": "# Addons\n\nAddons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service.\n\n:::warning\nAddon forges are still experimental. Their implementation can change and break at any time.\n:::\n\n:::danger\nYou must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information.\n:::\n\n## Usage\n\nTo use an addon forge, download the correct addon version.\n\n### Forge\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file\n```\n\nIn case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`.\n\n#### List of addon forges\n\n- [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws).\n\n### Log\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_LOG_STORE=addon\nWOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file\n```\n\n## Developing addon forges\n\nSee [Addons](../../92-development/100-addons.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/10-docker.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Docker\n\nThis is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent.\n\n## Private registries\n\nWoodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config).\n\nTo add your credential helper to the Woodpecker server container you could use the following code to build a custom image:\n\n```dockerfile\nFROM woodpeckerci/woodpecker-server:latest-alpine\n\nRUN apk add -U --no-cache docker-credential-ecr-login\n```\n\n## Step specific configuration\n\n### Run user\n\nBy default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group:\n\n```yaml\nsteps:\n  - name: example\n    image: alpine\n    commands:\n      - whoami\n    backend_options:\n      docker:\n        user: 65534:65534\n```\n\nThe syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag.\n\n## Tips and tricks\n\n### Image cleanup\n\nThe agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.\n\n:::danger\nThe following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation.\n:::\n\n- Remove all unused images\n\n  <!-- cspell:ignore trunc -->\n\n  ```bash\n  docker image rm $(docker images --filter \"dangling=true\" -q --no-trunc)\n  ```\n\n- Remove Woodpecker volumes\n\n  ```bash\n  docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true  -q)\n  ```\n\n### Podman\n\nThere is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog).\n\n## Environment variables\n\n### BACKEND_DOCKER_NETWORK\n\n- Name: `WOODPECKER_BACKEND_DOCKER_NETWORK`\n- Default: none\n\nSet to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other!\n\n---\n\n### BACKEND_DOCKER_ENABLE_IPV6\n\n- Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6`\n- Default: `false`\n\nEnable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6.\n\n---\n\n### BACKEND_DOCKER_VOLUMES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n- Default: none\n\nList of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA\ncertificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM_SWAP\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_SHM_SIZE\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE`\n- Default: `0`\n\nThe maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_QUOTA\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA`\n- Default: `0`\n\nThe number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SHARES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES`\n- Default: `0`\n\nThe relative weight vs. other containers.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SET\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET`\n- Default: none\n\nComma-separated list to limit the specific CPUs or cores a pipeline container can use.\n\nExample: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/20-kubernetes.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Kubernetes\n\nThe Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps.\n\n## Metadata labels\n\nWoodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies.\n\nThe following metadata labels are supported:\n\n- `woodpecker-ci.org/forge-id`\n- `woodpecker-ci.org/repo-forge-id`\n- `woodpecker-ci.org/repo-id`\n- `woodpecker-ci.org/repo-name`\n- `woodpecker-ci.org/repo-full-name`\n- `woodpecker-ci.org/branch`\n- `woodpecker-ci.org/org-id`\n- `woodpecker-ci.org/task-uuid`\n- `woodpecker-ci.org/step`\n\n## Private registries\n\nIn addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML.\n\nPlace these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.\n\n## Step specific configuration\n\n### Resources\n\nThe Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory.\nWe recommend to add a `resources` definition to all steps to ensure efficient scheduling.\n\nHere is an example definition with an arbitrary `resources` definition below the `backend_options` section:\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        resources:\n          requests:\n            memory: 200Mi\n            cpu: 100m\n          limits:\n            memory: 400Mi\n            cpu: 1000m\n```\n\nYou can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis.\n\n### Runtime class\n\n`runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes.\n\n### Service account\n\n`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        # Use the service account `default` in the current namespace.\n        # This usually the same as wherever woodpecker is deployed.\n        serviceAccountName: default\n```\n\nTo give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)\n\n### Node selector\n\n`nodeSelector` specifies the labels which are used to select the node on which the step will be executed.\n\nLabels defined here will be appended to a list which already contains `\"kubernetes.io/arch\"`.\nBy default `\"kubernetes.io/arch\"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`.\nWithout a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures.\n\nTo overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`.\nA practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture.\nIn this case, one must define an arbitrary key in the matrix section of the respective matrix element:\n\n```yaml\nmatrix:\n  include:\n    - NAME: runner1\n      ARCH: arm64\n```\n\nAnd then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var:\n\n```yaml\n[...]\n    backend_options:\n      kubernetes:\n        nodeSelector:\n          kubernetes.io/arch: \"${ARCH}\"\n```\n\nYou can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent\nor [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis.\n\n### Tolerations\n\nWhen you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations.\n\nExample pipeline configuration:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go get\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        serviceAccountName: 'my-service-account'\n        resources:\n          requests:\n            memory: 128Mi\n            cpu: 1000m\n          limits:\n            memory: 256Mi\n        nodeSelector:\n          beta.kubernetes.io/instance-type: Standard_D2_v3\n        tolerations:\n          - key: 'key1'\n            operator: 'Equal'\n            value: 'value1'\n            effect: 'NoSchedule'\n            tolerationSeconds: 3600\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: topology.kubernetes.io/zone\n                      operator: In\n                      values:\n                        - eu-central-1a\n                        - eu-central-1b\n```\n\n### Affinity\n\nKubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods.\n\nYou can configure affinity at two levels:\n\n1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it\n2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden\n\n#### Agent-wide affinity\n\nTo apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  nodeAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n      nodeSelectorTerms:\n        - matchExpressions:\n            - key: node-role.kubernetes.io/worker\n              operator: In\n              values:\n                - \"true\"\n```\n\nBy default, per-step affinity settings are **not allowed** for security reasons. To enable them:\n\n```bash\nWOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true\n```\n\n:::warning\nEnabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications.\n:::\n\nWhen per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged).\n\n#### Example: agent affinity for co-location\n\nThis example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes.\n\nIt uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  podAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      matchLabelKeys:\n        - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n  podAntiAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      mismatchLabelKeys:\n      - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n```\n\n:::note\nThe `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`.\n:::\n\n#### Example: Node affinity for GPU workloads\n\nEnsure a step runs only on GPU-enabled nodes:\n\n```yaml\nsteps:\n  - name: train-model\n    image: tensorflow/tensorflow:latest-gpu\n    backend_options:\n      kubernetes:\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: accelerator\n                      operator: In\n                      values:\n                        - nvidia-tesla-v100\n```\n\n### Volumes\n\nTo mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.\n\nPersistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference.\n\n_If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._\n\nNOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver:\n\n```yaml\naccessModes:\n  - ReadWriteMany\n```\n\nAssuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step:\n\n```yaml\nsteps:\n  - name: \"Restore Cache\"\n    image: meltwater/drone-cache\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    settings:\n      mount:\n        - \"woodpecker-cache\"\n    [...]\n```\n\nOr as follows when using a normal image:\n\n```yaml\nsteps:\n  - name: \"Edit cache\"\n    image: alpine:latest\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    commands:\n      - echo \"Hello World\" > /woodpecker/src/cache/output.txt\n    [...]\n```\n\n### Security context\n\nUse the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:\n\n```yaml\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo Hello world\n    backend_options:\n      kubernetes:\n        securityContext:\n          runAsUser: 999\n          runAsGroup: 999\n          privileged: true\n    [...]\n```\n\nNote that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object.\nBy default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the\nconfiguration shown above will result in something like the following Pod spec:\n\n<!-- cspell:disable -->\n\n```yaml\nkind: Pod\nspec:\n  securityContext:\n    runAsUser: 999\n    runAsGroup: 999\n  containers:\n    - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0\n      image: alpine\n      securityContext:\n        privileged: true\n  [...]\n```\n\n<!-- cspell:enable -->\n\nYou can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      seccompProfile:\n        type: Localhost\n        localhostProfile: profiles/audit.json\n```\n\nor restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      apparmorProfile:\n        type: Localhost\n        localhostProfile: k8s-apparmor-example-deny-write\n```\n\nor configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always')\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      fsGroupChangePolicy: OnRootMismatch\n```\n\n:::note\nThe feature requires Kubernetes v1.30 or above.\n:::\n\n### Annotations and labels\n\nYou can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration:\n\n```yaml\nbackend_options:\n  kubernetes:\n    annotations:\n      workflow-group: alpha\n      io.kubernetes.cri-o.Devices: /dev/fuse\n    labels:\n      environment: ci\n      app.kubernetes.io/name: builder\n```\n\nIn order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent:\n[WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step).\n\n## Tips and tricks\n\n### CRI-O\n\nCRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration:\n\n```yaml\nworkspace:\n  base: '/woodpecker'\n  path: '/'\n```\n\nSee [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details.\n\n### `KUBERNETES_SERVICE_HOST` environment variable\n\nLike the below env vars used for configuration, this can be set in the environment for configuration of the agent.\nIt configures the address of the Kubernetes API server to connect to.\n\nIf running the agent within Kubernetes, this will already be set and you don't have to add it manually.\n\n### Headless services\n\nFor each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created,\nand all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname.\n\nUsing the headless services, the step pod is connected to directly, so any port on the other step pods can be reached.\n\nThis is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service.\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/woodpecker/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    commands:\n      - docker run hello-world\n\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    detached: true\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /woodpecker/dind-certs\n```\n\nIf ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP.\n\n## Environment variables\n\nThese env vars can be set in the `env:` sections of the agent.\n\n---\n\n### BACKEND_K8S_NAMESPACE\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE`\n- Default: `woodpecker`\n\nThe namespace to create worker Pods in.\n\n---\n\n### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION`\n- Default: `false`\n\nEnables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation.\n\nWith this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker.\n\n### BACKEND_K8S_VOLUME_SIZE\n\n- Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE`\n- Default: `10G`\n\nThe volume size of the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS`\n- Default: none\n\nThe storage class to use for the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_RWX\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX`\n- Default: `true`\n\nDetermines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead.\n\n---\n\n### BACKEND_K8S_POD_LABELS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS`\n- Default: none\n\nAdditional labels to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-label\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if additional Pod labels can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS`\n- Default: none\n\nAdditional annotations to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-annotation\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if Pod annotations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS`\n- Default: none\n\nAdditional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{\"effect\":\"NoSchedule\",\"key\":\"jobs\",\"operator\":\"Exists\"}]`.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP`\n- Default: `true`\n\nDetermines if Pod tolerations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_NODE_SELECTOR\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR`\n- Default: none\n\nAdditional node selector to apply to worker pods. Must be a YAML object, e.g. `{\"topology.kubernetes.io/region\":\"eu-central-1\"}`.\n\n---\n\n### BACKEND_K8S_SECCTX_NONROOT <!-- cspell:ignore SECCTX NONROOT -->\n\n- Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT`\n- Default: `false`\n\nDetermines if containers must be required to run as non-root users.\n\n---\n\n### BACKEND_K8S_PULL_SECRET_NAMES\n\n- Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`\n- Default: none\n\nSecret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\n\n---\n\n### BACKEND_K8S_PRIORITY_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS`\n- Default: none, which will use the default priority class configured in Kubernetes\n\nWhich [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/30-local.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Local\n\n:::danger\nThe local backend executes pipelines on the local system without any isolation.\n:::\n\n:::note\nCurrently we do not support [services](../../../20-usage/60-services.md) for this backend.\n[Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095).\n:::\n\nSince the commands run directly in the same context as the agent (same user, same\nfilesystem), a malicious pipeline could be used to access the agent\nconfiguration especially the `WOODPECKER_AGENT_SECRET` variable.\n\nIt is recommended to use this backend only for private setup where the code and\npipeline can be trusted. It should not be used in a public instance where\nanyone can submit code or add new repositories. The agent should not run as a privileged user (root).\n\nThe local backend will use a random directory in `$TMPDIR` to store the cloned\ncode and execute commands.\n\nIn order to use this backend, you need to download (or build) the\n[agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine.\n\n## Step specific configuration\n\n### Shell\n\nThe `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is\nused to run the commands.\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: bash\n    commands: [...]\n```\n\n### Plugins\n\n```yaml\nsteps:\n  - name: build\n    image: /usr/bin/tree\n```\n\nIf no commands are provided, plugins are treated in the usual manner.\nIn the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path.\n\n## Environment variables\n\n### BACKEND_LOCAL_TEMP_DIR\n\n- Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR`\n- Default: default temp directory\n\nDirectory to create folders for workflows.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/50-custom.md",
    "content": "# Custom\n\nIf none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend:\n\n```go\npackage main\n\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n  backendTypes \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc main() {\n  core.RunAgent([]backendTypes.Backend{\n    yourBackend,\n  })\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/_category_.yaml",
    "content": "label: 'Backends'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/11-overview.md",
    "content": "# Forges\n\n## Supported features\n\n| Feature                                                                                                                | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |\n| ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- |\n| Event: Push                                                                                                            | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Tag                                                                                                             | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Pull-Request                                                                                                    | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Release                                                                                                         | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| Event: Deploy¹                                                                                                         | :white_check_mark:     | :x:                  | :x:                      | :x:                    | :x:                          | :x:                                                |\n| [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| [Multiple workflows](../../../20-usage/25-workflows.md)                                                                | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| [when.path filter](../../../20-usage/20-workflow-syntax.md#path)                                                       | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n\n¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks.\n\nIn addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/20-github.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitHub\n\nWoodpecker comes with built-in support for GitHub and GitHub Enterprise.\nTo use Woodpecker with GitHub the following environment variables should be set for the server component:\n\n```ini\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID\nWOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET\n```\n\nYou will get these values from GitHub when you register your OAuth application.\nTo do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App.\n\n:::warning\nDo not use a \"GitHub App\" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically)\n:::\n\n## App Settings\n\n- Name: An arbitrary name for your App\n- Homepage URL: The URL of your Woodpecker instance\n- Callback URL: `https://<your-woodpecker-instance>/authorize`\n- (optional) Upload the Woodpecker Logo: <https://avatars.githubusercontent.com/u/84780935?s=200&v=4>\n\n## Client Secret Creation\n\nAfter your App has been created, you can generate a client secret.\nUse this one for the `WOODPECKER_GITHUB_SECRET` environment variable.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITHUB\n\n- Name: `WOODPECKER_GITHUB`\n- Default: `false`\n\nEnables the GitHub driver.\n\n---\n\n### GITHUB_URL\n\n- Name: `WOODPECKER_GITHUB_URL`\n- Default: `https://github.com`\n\nConfigures the GitHub server address.\n\n---\n\n### GITHUB_CLIENT\n\n- Name: `WOODPECKER_GITHUB_CLIENT`\n- Default: none\n\nConfigures the GitHub OAuth client id to authorize access.\n\n---\n\n### GITHUB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITHUB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath.\n\n---\n\n### GITHUB_SECRET\n\n- Name: `WOODPECKER_GITHUB_SECRET`\n- Default: none\n\nConfigures the GitHub OAuth client secret. This is used to authorize access.\n\n---\n\n### GITHUB_SECRET_FILE\n\n- Name: `WOODPECKER_GITHUB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath.\n\n---\n\n### GITHUB_MERGE_REF\n\n- Name: `WOODPECKER_GITHUB_MERGE_REF`\n- Default: `true`\n\n---\n\n### GITHUB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITHUB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### GITHUB_PUBLIC_ONLY\n\n- Name: `WOODPECKER_GITHUB_PUBLIC_ONLY`\n- Default: `false`\n\nConfigures the GitHub OAuth client to only obtain a token that can manage public repositories.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/30-gitea.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Gitea\n\nWoodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITEA=true\nWOODPECKER_GITEA_URL=YOUR_GITEA_URL\nWOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT\nWOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET\n```\n\n## Gitea on the same host with containers\n\nIf you have Gitea also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `gitea`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea\n```\n\n## Registration\n\nRegister your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook).\n\n![gitea oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITEA\n\n- Name: `WOODPECKER_GITEA`\n- Default: `false`\n\nEnables the Gitea driver.\n\n---\n\n### GITEA_URL\n\n- Name: `WOODPECKER_GITEA_URL`\n- Default: `https://try.gitea.io`\n\nConfigures the Gitea server address.\n\n---\n\n### GITEA_CLIENT\n\n- Name: `WOODPECKER_GITEA_CLIENT`\n- Default: none\n\nConfigures the Gitea OAuth client id. This is used to authorize access.\n\n---\n\n### GITEA_CLIENT_FILE\n\n- Name: `WOODPECKER_GITEA_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath\n\n---\n\n### GITEA_SECRET\n\n- Name: `WOODPECKER_GITEA_SECRET`\n- Default: none\n\nConfigures the Gitea OAuth client secret. This is used to authorize access.\n\n---\n\n### GITEA_SECRET_FILE\n\n- Name: `WOODPECKER_GITEA_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_SECRET` from the specified filepath\n\n---\n\n### GITEA_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITEA_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/35-forgejo.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Forgejo\n\nWoodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_FORGEJO=true\nWOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL\nWOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT\nWOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET\n```\n\n## Forgejo on the same host with containers\n\nIf you have Forgejo also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `forgejo`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo\n```\n\n## Registration\n\nRegister your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\nIf you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).\n\n![forgejo oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### FORGEJO\n\n- Name: `WOODPECKER_FORGEJO`\n- Default: `false`\n\nEnables the Forgejo driver.\n\n---\n\n### FORGEJO_URL\n\n- Name: `WOODPECKER_FORGEJO_URL`\n- Default: `https://next.forgejo.org`\n\nConfigures the Forgejo server address.\n\n---\n\n### FORGEJO_CLIENT\n\n- Name: `WOODPECKER_FORGEJO_CLIENT`\n- Default: none\n\nConfigures the Forgejo OAuth client id. This is used to authorize access.\n\n---\n\n### FORGEJO_CLIENT_FILE\n\n- Name: `WOODPECKER_FORGEJO_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath\n\n---\n\n### FORGEJO_SECRET\n\n- Name: `WOODPECKER_FORGEJO_SECRET`\n- Default: none\n\nConfigures the Forgejo OAuth client secret. This is used to authorize access.\n\n---\n\n### FORGEJO_SECRET_FILE\n\n- Name: `WOODPECKER_FORGEJO_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath\n\n---\n\n### FORGEJO_SKIP_VERIFY\n\n- Name: `WOODPECKER_FORGEJO_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/40-gitlab.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitLab\n\nWoodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITLAB=true\nWOODPECKER_GITLAB_URL=http://gitlab.mycompany.com\nWOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82\nWOODPECKER_GITLAB_SECRET=30f5064039e6b359e075\n```\n\n## Registration\n\nYou must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application.\n\nPlease use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application.\n\nIf you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITLAB\n\n- Name: `WOODPECKER_GITLAB`\n- Default: `false`\n\nEnables the GitLab driver.\n\n---\n\n### GITLAB_URL\n\n- Name: `WOODPECKER_GITLAB_URL`\n- Default: `https://gitlab.com`\n\nConfigures the GitLab server address.\n\n---\n\n### GITLAB_CLIENT\n\n- Name: `WOODPECKER_GITLAB_CLIENT`\n- Default: none\n\nConfigures the GitLab OAuth client id. This is used to authorize access.\n\n---\n\n### GITLAB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITLAB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath\n\n---\n\n### GITLAB_SECRET\n\n- Name: `WOODPECKER_GITLAB_SECRET`\n- Default: none\n\nConfigures the GitLab OAuth client secret. This is used to authorize access.\n\n---\n\n### GITLAB_SECRET_FILE\n\n- Name: `WOODPECKER_GITLAB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath\n\n---\n\n### GITLAB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITLAB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/50-bitbucket.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket\n\nWoodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_BITBUCKET=true\nWOODPECKER_BITBUCKET_CLIENT=... # called \"Key\" in Bitbucket\nWOODPECKER_BITBUCKET_SECRET=...\n```\n\n## Registration\n\nYou must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`).\n\nPlease set a name and set the `Callback URL` like this:\n\n```uri\nhttps://<your-woodpecker-address>/authorize\n```\n\n![bitbucket oauth setup](bitbucket_oauth.png)\n\nPlease also be sure to check the following permissions:\n\n- Account: Email, Read\n- Workspace membership: Read\n- Projects: Read\n- Repositories: Read\n- Pull requests: Read\n- Webhooks: Read and Write\n\n![bitbucket permissions](bitbucket_permissions.png)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET\n\n- Name: `WOODPECKER_BITBUCKET`\n- Default: `false`\n\nEnables the Bitbucket driver.\n\n---\n\n### BITBUCKET_CLIENT\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT`\n- Default: none\n\nConfigures the Bitbucket OAuth client key. This is used to authorize access.\n\n---\n\n### BITBUCKET_CLIENT_FILE\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath\n\n---\n\n### BITBUCKET_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_SECRET`\n- Default: none\n\nConfigures the Bitbucket OAuth client secret. This is used to authorize access.\n\n---\n\n### BITBUCKET_SECRET_FILE\n\n- Name: `WOODPECKER_BITBUCKET_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath\n\n## Known Issues\n\nBitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details.\n\n## Missing Features\n\nPath filters for pull requests are not supported. We are interested in patches to include this functionality.\nIf you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket Datacenter / Server\n\n:::warning\nWoodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash.\n:::\n\nTo enable Bitbucket Server you should configure the Woodpecker container using the following environment variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BITBUCKET_DC=true\n+      - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo\n+      - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy\n+      - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com\n+      - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true\n\n   woodpecker-agent:\n     [...]\n```\n\n## Service Account\n\nWoodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories.\n\n## Registration\n\nWoodpecker must be registered with Bitbucket Datacenter / Server.\nIn the administration section of Bitbucket choose \"Application Links\" and then \"Create link\".\nWoodpecker should be listed as \"External Application\" and the direction should be set to \"Incoming\".\nNote the client id and client secret of the registration to be used in the configuration of Woodpecker.\n\nSee also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html).\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET_DC\n\n- Name: `WOODPECKER_BITBUCKET_DC`\n- Default: `false`\n\nEnables the Bitbucket Server driver.\n\n---\n\n### BITBUCKET_DC_URL\n\n- Name: `WOODPECKER_BITBUCKET_DC_URL`\n- Default: none\n\nConfigures the Bitbucket Server address.\n\n---\n\n### BITBUCKET_DC_CLIENT_ID\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client id.\n\n---\n\n### BITBUCKET_DC_CLIENT_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client secret.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME`\n- Default: none\n\nThis username is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD`\n- Default: none\n\nThe password is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath\n\n---\n\n### BITBUCKET_DC_SKIP_VERIFY\n\n- Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN\n\n- Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN`\n- Default: `false`\n\nWhen enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/_category_.yaml",
    "content": "label: 'Forges'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/30-agent.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Agent\n\nAgents are configured by the command line or environment variables. At the minimum you need the following information:\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\n```\n\nThe following are automatically set and can be overridden:\n\n- `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname\n- `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1\n\n## Workflows per agent\n\nBy default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent.\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\nWOODPECKER_MAX_WORKFLOWS=4\n```\n\n## Agent registration\n\nWhen the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before.\n\nThere are two types of tokens to connect an agent to the server:\n\n### Using system token\n\nA _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents.\n\nIn that case registration process would be as following:\n\n1. The first time the agent communicates with the server, it is using the system token\n1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent\n1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`)\n1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server\n\n### Using agent token\n\nAn _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`.\n\nTo get an _agent token_ you have to register the agent manually in the server using the UI:\n\n1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent`\n   ![Agent creation](./new-agent-registration.png)\n   ![Agent created](./new-agent-created.png)\n1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET`\n1. The agent will connect to the server using the provided token and will update its status in the UI:\n   ![Agent connected](./new-agent-connected.png)\n\n## Environment variables\n\n### SERVER\n\n- Name: `WOODPECKER_SERVER`\n- Default: `localhost:9000`\n\nConfigures gRPC address of the server.\n\n---\n\n### USERNAME\n\n- Name: `WOODPECKER_USERNAME`\n- Default: `x-oauth-basic`\n\nThe gRPC username.\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`\n\n---\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOSTNAME\n\n- Name: `WOODPECKER_HOSTNAME`\n- Default: none\n\nConfigures the agent hostname.\n\n---\n\n### AGENT_CONFIG_FILE\n\n- Name: `WOODPECKER_AGENT_CONFIG_FILE`\n- Default: `/etc/woodpecker/agent.conf`\n\nConfigures the path of the agent config file.\n\n---\n\n### MAX_WORKFLOWS\n\n- Name: `WOODPECKER_MAX_WORKFLOWS`\n- Default: `1`\n\nConfigures the number of parallel workflows.\n\n---\n\n### AGENT_LABELS\n\n- Name: `WOODPECKER_AGENT_LABELS`\n- Default: none\n\nConfigures custom labels for the agent, to let workflows filter by it.\nUse a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard.\nIf you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched.\nBy default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed.\nTo learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels).\n\n---\n\n### HEALTHCHECK\n\n- Name: `WOODPECKER_HEALTHCHECK`\n- Default: `true`\n\nEnable healthcheck endpoint.\n\n---\n\n### HEALTHCHECK_ADDR\n\n- Name: `WOODPECKER_HEALTHCHECK_ADDR`\n- Default: `:3000`\n\nConfigures healthcheck endpoint address.\n\n---\n\n### KEEPALIVE_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_TIME`\n- Default: none\n\nAfter a duration of this time of no activity, the agent pings the server to check if the transport is still alive.\n\n---\n\n### KEEPALIVE_TIMEOUT\n\n- Name: `WOODPECKER_KEEPALIVE_TIMEOUT`\n- Default: `20s`\n\nAfter pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity.\n\n---\n\n### GRPC_SECURE\n\n- Name: `WOODPECKER_GRPC_SECURE`\n- Default: `false`\n\nConfigures if the connection to `WOODPECKER_SERVER` should be made using a secure transport.\n\n---\n\n### GRPC_VERIFY\n\n- Name: `WOODPECKER_GRPC_VERIFY`\n- Default: `true`\n\nConfigures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`.\n\n---\n\n### BACKEND\n\n- Name: `WOODPECKER_BACKEND`\n- Default: `auto-detect`\n\nConfigures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`.\n\n### BACKEND_DOCKER\\_\\*\n\nSee [Docker backend configuration](./11-backends/10-docker.md#environment-variables)\n\n---\n\n### BACKEND_K8S\\_\\*\n\nSee [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables)\n\n---\n\n### BACKEND_LOCAL\\_\\*\n\nSee [Local backend configuration](./11-backends/30-local.md#environment-variables)\n\n### Advanced Settings\n\n:::warning\nOnly change these If you know what you do.\n:::\n\n#### CONNECT_RETRY_COUNT\n\n- Name: `WOODPECKER_CONNECT_RETRY_COUNT`\n- Default: `5`\n\nConfigures number of times agent retries to connect to the server.\n\n#### CONNECT_RETRY_DELAY\n\n- Name: `WOODPECKER_CONNECT_RETRY_DELAY`\n- Default: `2s`\n\nConfigures delay between agent connection retries to the server.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/40-autoscaler.md",
    "content": "# Autoscaler\n\nIf your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler).\n\nPlease note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap).\n\n## Setup\n\n### docker compose\n\nIf you are using docker compose you can add the following to your `docker-compose.yaml` file:\n\n```yaml\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:next\n    [...]\n\n  woodpecker-autoscaler:\n    image: woodpeckerci/autoscaler:next\n    restart: always\n    depends_on:\n      - woodpecker-server\n    environment:\n      - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url\n      - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user\n      - WOODPECKER_MIN_AGENTS=0\n      - WOODPECKER_MAX_AGENTS=3\n      - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time\n      - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include \"https://\" in the value.\n      - WOODPECKER_GRPC_SECURE=true\n      - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents\n      - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below\n      - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/10-configuration/_category_.yaml",
    "content": "label: 'Configuration'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/30-administration/_category_.yaml",
    "content": "label: 'Administration'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/40-cli.md",
    "content": "# CLI\n\n# NAME\n\nwoodpecker-cli - command line utility\n\n# SYNOPSIS\n\nwoodpecker-cli\n\n```\n[--config|-c]=[value]\n[--disable-update-check]\n[--log-file]=[value]\n[--log-level]=[value]\n[--nocolor]\n[--pretty]\n[--server|-s]=[value]\n[--skip-verify]\n[--socks-proxy-off]\n[--socks-proxy]=[value]\n[--token|-t]=[value]\n```\n\n# DESCRIPTION\n\nWoodpecker command line utility\n\n**Usage**:\n\n```\nwoodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]\n```\n\n# GLOBAL OPTIONS\n\n**--config, -c**=\"\": path to config file\n\n**--disable-update-check**: disable update check (default: false)\n\n**--log-file**=\"\": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr)\n\n**--log-level**=\"\": set logging level (default: info)\n\n**--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false)\n\n**--pretty**: enable pretty-printed debug output (default: true)\n\n**--server, -s**=\"\": server address\n\n**--skip-verify**: skip ssl verification (default: false)\n\n**--socks-proxy**=\"\": socks proxy address\n\n**--socks-proxy-off**: socks proxy ignored (default: false)\n\n**--token, -t**=\"\": server auth token\n\n\n# COMMANDS\n\n## admin\n\nmanage server settings\n\n### log-level\n\nretrieve log level from server, or set it with [level]\n\n### org\n\nmanage organizations\n\n#### ls\n\nlist organizations\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nOrganization ID: {{ .ID }}\\n)\n\n### registry\n\nmanage global registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage global secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n### user\n\nmanage users\n\n#### add\n\nadd a user\n\n#### ls\n\nlist all users\n\n**--format**=\"\": format output (default: {{ .Login }})\n\n#### rm\n\nremove a user\n\n#### show\n\nshow user information\n\n**--format**=\"\": format output (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## context, ctx\n\nmanage contexts\n\n### list, ls\n\nlist all contexts\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: do not print headers in output (default: false)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### use\n\nset the current context\n\n### delete, rm\n\ndelete a context\n\n### rename\n\nrename a context\n\n## exec\n\nexecute a local pipeline\n\n**--backend-docker-api-version**=\"\": the version of the API to reach, leave empty for latest.\n\n**--backend-docker-cert**=\"\": path to load the TLS certificates for connecting to docker server\n\n**--backend-docker-host**=\"\": path to docker socket or url to the docker server\n\n**--backend-docker-ipv6**: backend docker enable IPV6 (default: false)\n\n**--backend-docker-limit-cpu-quota**=\"\": impose a cpu quota (default: 0)\n\n**--backend-docker-limit-cpu-set**=\"\": set the cpus allowed to execute containers\n\n**--backend-docker-limit-cpu-shares**=\"\": change the cpu shares (default: 0)\n\n**--backend-docker-limit-mem**=\"\": maximum memory allowed in bytes (default: 0)\n\n**--backend-docker-limit-mem-swap**=\"\": maximum memory used for swap in bytes (default: 0)\n\n**--backend-docker-limit-shm-size**=\"\": docker /dev/shm allowed in bytes (default: 0)\n\n**--backend-docker-network**=\"\": backend docker network\n\n**--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true)\n\n**--backend-docker-volumes**=\"\": backend docker volumes (comma separated)\n\n**--backend-engine**=\"\": backend engine to run pipelines on (default: auto-detect)\n\n**--backend-http-proxy**=\"\": if set, pass the environment variable down as \"HTTP_PROXY\" to steps\n\n**--backend-https-proxy**=\"\": if set, pass the environment variable down as \"HTTPS_PROXY\" to steps\n\n**--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false)\n\n**--backend-k8s-namespace**=\"\": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker)\n\n**--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false)\n\n**--backend-k8s-pod-affinity**=\"\": backend k8s Agent-wide worker pod affinity, in YAML format\n\n**--backend-k8s-pod-affinity-allow-from-step**: whether to allow using affinity from step's backend options (default: false)\n\n**--backend-k8s-pod-annotations**=\"\": backend k8s additional Agent-wide worker pod annotations\n\n**--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false)\n\n**--backend-k8s-pod-image-pull-secret-names**=\"\": backend k8s pull secret names for private registries\n\n**--backend-k8s-pod-labels**=\"\": backend k8s additional Agent-wide worker pod labels\n\n**--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false)\n\n**--backend-k8s-pod-node-selector**=\"\": backend k8s Agent-wide worker pod node selector\n\n**--backend-k8s-pod-tolerations**=\"\": backend k8s Agent-wide worker pod tolerations\n\n**--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true)\n\n**--backend-k8s-priority-class**=\"\": which kubernetes priority class to assign to created job pods\n\n**--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false)\n\n**--backend-k8s-storage-class**=\"\": backend k8s storage class\n\n**--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true)\n\n**--backend-k8s-volume-size**=\"\": backend k8s volume size (default 10G) (default: 10G)\n\n**--backend-local-temp-dir**=\"\": set a different temp dir to clone workflows into (default: system temporary directory)\n\n**--backend-no-proxy**=\"\": if set, pass the environment variable down as \"NO_PROXY\" to steps\n\n**--commit-author-avatar**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_AVATAR\".\n\n**--commit-author-email**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_EMAIL\".\n\n**--commit-author-name**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR\".\n\n**--commit-branch**=\"\": Set the metadata environment variable \"CI_COMMIT_BRANCH\". (default: main)\n\n**--commit-message**=\"\": Set the metadata environment variable \"CI_COMMIT_MESSAGE\".\n\n**--commit-pull-labels**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_LABELS\".\n\n**--commit-pull-milestone**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_MILESTONE\".\n\n**--commit-ref**=\"\": Set the metadata environment variable \"CI_COMMIT_REF\".\n\n**--commit-refspec**=\"\": Set the metadata environment variable \"CI_COMMIT_REFSPEC\".\n\n**--commit-release-is-pre**: Set the metadata environment variable \"CI_COMMIT_PRERELEASE\". (default: false)\n\n**--commit-sha**=\"\": Set the metadata environment variable \"CI_COMMIT_SHA\".\n\n**--env**=\"\": Set the metadata environment variable \"CI_ENV\".\n\n**--forge-type**=\"\": Set the metadata environment variable \"CI_FORGE_TYPE\".\n\n**--forge-url**=\"\": Set the metadata environment variable \"CI_FORGE_URL\".\n\n**--local**: run from local directory (default: true)\n\n**--metadata-file**=\"\": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags\n\n**--netrc-machine**=\"\": \n\n**--netrc-password**=\"\": \n\n**--netrc-username**=\"\": \n\n**--network**=\"\": external networks\n\n**--pipeline-changed-files**=\"\": Set the metadata environment variable \"CI_PIPELINE_FILES\", either json formatted list of strings, or comma separated string list.\n\n**--pipeline-created**=\"\": Set the metadata environment variable \"CI_PIPELINE_CREATED\". (default: 0)\n\n**--pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TASK\".\n\n**--pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TARGET\".\n\n**--pipeline-event**=\"\": Set the metadata environment variable \"CI_PIPELINE_EVENT\". (default: manual)\n\n**--pipeline-number**=\"\": Set the metadata environment variable \"CI_PIPELINE_NUMBER\". (default: 0)\n\n**--pipeline-parent**=\"\": Set the metadata environment variable \"CI_PIPELINE_PARENT\". (default: 0)\n\n**--pipeline-started**=\"\": Set the metadata environment variable \"CI_PIPELINE_STARTED\". (default: 0)\n\n**--pipeline-url**=\"\": Set the metadata environment variable \"CI_PIPELINE_FORGE_URL\".\n\n**--plugins-privileged**=\"\": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none\n\n**--prev-commit-author-avatar**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_AVATAR\".\n\n**--prev-commit-author-email**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_EMAIL\".\n\n**--prev-commit-author-name**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR\".\n\n**--prev-commit-branch**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_BRANCH\".\n\n**--prev-commit-message**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_MESSAGE\".\n\n**--prev-commit-ref**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REF\".\n\n**--prev-commit-refspec**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REFSPEC\".\n\n**--prev-commit-sha**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_SHA\".\n\n**--prev-pipeline-created**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_CREATED\". (default: 0)\n\n**--prev-pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TASK\".\n\n**--prev-pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TARGET\".\n\n**--prev-pipeline-event**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_EVENT\".\n\n**--prev-pipeline-finished**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FINISHED\". (default: 0)\n\n**--prev-pipeline-number**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_NUMBER\". (default: 0)\n\n**--prev-pipeline-started**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STARTED\". (default: 0)\n\n**--prev-pipeline-status**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STATUS\".\n\n**--prev-pipeline-url**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FORGE_URL\".\n\n**--repo**=\"\": Set the full name to derive metadata environment variables \"CI_REPO\", \"CI_REPO_NAME\" and \"CI_REPO_OWNER\".\n\n**--repo-clone-ssh-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_SSH_URL\".\n\n**--repo-clone-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_URL\".\n\n**--repo-default-branch**=\"\": Set the metadata environment variable \"CI_REPO_DEFAULT_BRANCH\". (default: main)\n\n**--repo-path**=\"\": path to local repository\n\n**--repo-private**=\"\": Set the metadata environment variable \"CI_REPO_PRIVATE\".\n\n**--repo-remote-id**=\"\": Set the metadata environment variable \"CI_REPO_REMOTE_ID\".\n\n**--repo-trusted-network**: Set the metadata environment variable \"CI_REPO_TRUSTED_NETWORK\". (default: false)\n\n**--repo-trusted-security**: Set the metadata environment variable \"CI_REPO_TRUSTED_SECURITY\". (default: false)\n\n**--repo-trusted-volumes**: Set the metadata environment variable \"CI_REPO_TRUSTED_VOLUMES\". (default: false)\n\n**--repo-url**=\"\": Set the metadata environment variable \"CI_REPO_URL\".\n\n**--secrets**=\"\": map of secrets, ex. 'secret=\"val\",secret2=\"value2\"'\n\n**--secrets-file**=\"\": path to yaml file with secrets map\n\n**--system-host**=\"\": Set the metadata environment variable \"CI_SYSTEM_HOST\".\n\n**--system-name**=\"\": Set the metadata environment variable \"CI_SYSTEM_NAME\". (default: woodpecker)\n\n**--system-platform**=\"\": Set the metadata environment variable \"CI_SYSTEM_PLATFORM\".\n\n**--system-url**=\"\": Set the metadata environment variable \"CI_SYSTEM_URL\". (default: https://github.com/woodpecker-ci/woodpecker)\n\n**--timeout**=\"\": pipeline timeout (default: 1h0m0s)\n\n**--volumes**=\"\": pipeline volumes\n\n**--workflow-name**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NAME\".\n\n**--workflow-number**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NUMBER\". (default: 0)\n\n**--workspace-base**=\"\":  (default: /woodpecker)\n\n**--workspace-path**=\"\":  (default: src)\n\n## info\n\nshow information about the current user\n\n**--format**=\"\": format output (deprecated) (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## lint\n\nlint a pipeline configuration file\n\n**--plugins-privileged**=\"\": allow plugins to run in privileged mode, if set empty, there is no\n\n**--plugins-trusted-clone**=\"\": plugins that are trusted to handle Git credentials in cloning steps (default: \"docker.io/woodpeckerci/plugin-git:2.8.0\", \"docker.io/woodpeckerci/plugin-git\", \"quay.io/woodpeckerci/plugin-git\")\n\n**--strict**: treat warnings as errors (default: false)\n\n## org\n\nmanage organizations\n\n### registry\n\nmanage organization registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these event\n\n**--image**=\"\": limit secret to these image\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n## pipeline\n\nmanage pipelines\n\n### approve\n\napprove a pipeline\n\n### create\n\ncreate new pipeline\n\n**--branch**=\"\": branch to create pipeline from\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--var**=\"\": key=value\n\n### decline\n\ndecline a pipeline\n\n### deploy\n\ntrigger a pipeline with the 'deployment' event\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter (default: push)\n\n**--format**=\"\": format output (default: Number: {{ .Number }}\\nStatus: {{ .Status }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nMessage: {{ .Message }}\\nAuthor: {{ .Author }}\\nTarget: {{ .Deploy }}\\n)\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n**--status**=\"\": status filter (default: success)\n\n### last\n\nshow latest pipeline information\n\n**--branch**=\"\": branch name (default: main)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### ls\n\nshow pipeline history\n\n**--after**=\"\": only return pipelines after this date (RFC3339)\n\n**--before**=\"\": only return pipelines before this date (RFC3339)\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter\n\n**--limit**=\"\": limit the list size (default: 25)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--status**=\"\": status filter\n\n### log\n\nmanage logs\n\n#### purge\n\npurge a log\n\n#### show\n\nshow pipeline logs\n\n### ps\n\nshow pipeline steps\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\\x1b[0m\\nStep: {{ .step.Name }}\\nStarted: {{ .step.Started }}\\nStopped: {{ .step.Stopped }}\\nType: {{ .step.Type }}\\nState: {{ .step.State }}\\n)\n\n### purge\n\npurge pipelines\n\n**--branch**=\"\": remove pipelines of this branch only\n\n**--dry-run**: disable non-read api calls (default: false)\n\n**--keep-min**=\"\": minimum number of pipelines to keep (default: 10)\n\n**--older-than**=\"\": remove pipelines older than the specified time limit (default: 0s)\n\n### queue\n\nshow pipeline queue\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }} #{{ .Number }} \\x1b[0m\\nStatus: {{ .Status }}\\nEvent: {{ .Event }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\\nMessage: {{ .Message }}\\n)\n\n### show\n\nshow pipeline information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### start\n\nstart a pipeline\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n### stop\n\nstop a pipeline\n\n## repo\n\nmanage repositories\n\n### add\n\nadd a repository\n\n### chown\n\nassume ownership of a repository\n\n### cron\n\nmanage cron jobs\n\n#### add\n\nadd a cron job\n\n**--branch**=\"\": cron branch\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n#### rm\n\nremove a cron job\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist cron jobs\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow cron job information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a cron job\n\n**--branch**=\"\": cron branch\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n### ls\n\nlist all repos\n\n**--all**: query all repos, including inactive ones (default: false)\n\n**--format**=\"\": format output (deprecated)\n\n**--org**=\"\": filter by organization\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### registry\n\nmanage registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n### rm\n\nremove a repository\n\n### repair\n\nrepair repository webhooks\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n### show\n\nshow repository information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### sync\n\nsynchronize the repository list\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }}\\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }}))\n\n### update\n\nupdate a repository\n\n**--config**=\"\": repository configuration path. Example: .woodpecker.yml\n\n**--pipeline-counter**=\"\": repository starting pipeline number (default: 0)\n\n**--require-approval**=\"\": repository requires approval for\n\n**--timeout**=\"\": repository timeout (default: 0s)\n\n**--trusted-network**: repository is network trusted (default: false)\n\n**--trusted-security**: repository is security trusted (default: false)\n\n**--trusted-volumes**: repository is volumes trusted (default: false)\n\n**--unsafe**: allow unsafe operations (default: false)\n\n**--visibility**=\"\": repository visibility\n\n## setup\n\nsetup the woodpecker-cli for the first time\n\n**--context, --ctx**=\"\": name for the context (defaults to 'default')\n\n**--server**=\"\": URL of the woodpecker server\n\n**--token**=\"\": token to authenticate with the woodpecker server\n\n## update\n\nupdate the woodpecker-cli to the latest version\n\n**--force**: force update even if the latest version is already installed (default: false)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/01-getting-started.md",
    "content": "# Getting started\n\nYou can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea).\n\n## Gitpod\n\nIf you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing:\n\n- An IDE in the browser or bridged to your local VS-Code or Jetbrains\n- A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge\n- A pre-configured Woodpecker server\n- A single pre-configured Woodpecker agent node\n- Our docs preview server\n\nStart Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`.\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker)\n\n## Preparation for local development\n\n### Install Go\n\nInstall Golang as described by [this guide](https://go.dev/doc/install).\n\n### Install make\n\n> GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (<https://www.gnu.org/software/make/>).\n\nInstall make on:\n\n- Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/)\n- [Windows](https://stackoverflow.com/a/32127632/8461267)\n- Mac OS: `brew install make`\n\n### Install Node.js & `pnpm`\n\nInstall [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation.\n\nFor dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.\n[This guide](https://pnpm.io/installation) describes the installation of `pnpm`.\n\n### Install `pre-commit` (optional)\n\nWoodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code.\nTo apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage).\n\n### Create a `.env` file with your development configuration\n\nSimilar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it.\n\nA common config for debugging would look like this:\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ADMIN=your-username\n\nWOODPECKER_HOST=http://localhost:8000\n\n# github (sample for a forge config - see /docs/administration/forge/overview for other forges)\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=<redacted>\nWOODPECKER_GITHUB_SECRET=<redacted>\n\n# agent\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system\nWOODPECKER_MAX_WORKFLOWS=1\n\n# enable if you want to develop the UI\n# WOODPECKER_DEV_WWW_PROXY=http://localhost:8010\n\n# if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server\nWOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com\n\n# disable health-checks while debugging (normally not needed while developing)\nWOODPECKER_HEALTHCHECK=false\n\n# WOODPECKER_LOG_LEVEL=debug\n# WOODPECKER_LOG_LEVEL=trace\n```\n\n### Setup OAuth\n\nCreate an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md).\n\n## Developing with VS Code\n\nYou can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it.\n\nTo launch all needed services for local development, you can use \"Woodpecker CI\" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it.\n\nAs a starting guide for programming Go with VS Code, you can use this video guide:\n[![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80)\n\n### Debugging Woodpecker\n\nThe Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points.\n\n![Woodpecker debugging with VS Code](./vscode-debug.png)\n\n## Testing & linting code\n\nTo test or lint parts of Woodpecker, you can run one of the following commands:\n\n```bash\n# test server code\nmake test-server\n\n# test agent code\nmake test-agent\n\n# test cli code\nmake test-cli\n\n# test datastore / database related code like migrations of the server\nmake test-server-datastore\n\n# lint go code\nmake lint\n\n# lint UI code\nmake lint-frontend\n\n# test UI code\nmake test-frontend\n```\n\nIf you want to test a specific Go file, you can also use:\n\n```bash\ngo test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/<path-to-the-package-or-file-to-test>\n```\n\nOr you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands:\n\n![Run test via VS-Code](./vscode-run-test.png)\n\n## Run applications from terminal\n\nIf you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor.\n\n```bash title=\"start server\"\ngo run ./cmd/server\n```\n\n```bash title=\"start agent\"\ngo run ./cmd/agent\n```\n\n```bash title=\"execute cli command\"\ngo run ./cmd/cli [command]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/02-core-ideas.md",
    "content": "# Core ideas\n\n- A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂).\n- If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle).\n- What is used most often should be default.\n- Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md).\n\n## Addons and extensions\n\nIf you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an\n[addon](../30-administration/10-configuration/100-addons.md), [extension](../30-administration/10-configuration/10-server.md#external-configuration-api) or an\n[external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points:\n\n- Is your change very specific to your setup and unlikely to be used by anyone else?\n- Does your change violate the [guidelines](#guidelines)?\n\nBoth should be false when you open a pull request to get your change into the core repository.\n\n### Guidelines\n\n#### Forges\n\nA new forge must support these features:\n\n- OAuth2\n- Webhooks\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/03-ui.md",
    "content": "# UI Development\n\nTo develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api.\n\n## Setup\n\nThe UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed).\n\nTesting UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files.\n\n![UI Proxy architecture](./ui-proxy.svg)\n\nStart the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.\nAfter starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000).\n\n### Usage with remote server\n\nIf you would like to test your UI changes on a \"real-world\" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables:\n\n- `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org`\n- `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser\n\nThen, open the UI at `http://localhost:8010`.\n\n## Tools and frameworks\n\nThe following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing.\n\n- [Vue 3](https://v3.vuejs.org/)\n  - use `setup` and composition api\n  - place (re-usable) components in `web/src/components/`\n  - views should have a route in `web/src/router.ts` and are located in `web/src/views/`\n- [Tailwind CSS](https://tailwindcss.com/)\n  - use Tailwind classes where possible\n  - if needed extend the Tailwind config to use new classes\n  - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier)\n- [Vite](https://vitejs.dev/) (similar to Webpack)\n- [Typescript](https://www.typescriptlang.org/)\n  - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:)\n- [eslint](https://eslint.org/)\n- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file\n  - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)\n\n## Messages and Translations\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.\nYou must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet)\n\nFor more information about translations see [Translations](./08-translations.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/04-docs.md",
    "content": "# Documentation\n\nThe documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/).\n\nIf you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands:\n\n```bash\ncd docs/\n\npnpm install\n\n# build plugins used by the docs\npnpm build:woodpecker-plugins\n\n# start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually\npnpm start\n\n# or build the docs to deploy it to some static page hosting\npnpm build\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/05-architecture.md",
    "content": "# Architecture\n\n## Package architecture\n\n![Woodpecker architecture](./woodpecker-architecture.png)\n\n## System architecture\n\n### main package hierarchy\n\n| package            | meaning                                                        | imports                               |\n| ------------------ | -------------------------------------------------------------- | ------------------------------------- |\n| `cmd/**`           | parse command-line args & environment to stat server/cli/agent | all other                             |\n| `agent/**`         | code only agent (remote worker) will need                      | `pipeline`, `shared`                  |\n| `cli/**`           | code only cli tool does need                                   | `pipeline`, `shared`, `woodpecker-go` |\n| `server/**`        | code only server will need                                     | `pipeline`, `shared`                  |\n| `shared/**`        | code shared for all three main tools (go help utils)           | only std and external libs            |\n| `woodpecker-go/**` | go client for server rest api                                  | std                                   |\n\n### Server\n\n| package              | meaning                                                                             | imports                                                                                                                                                                               |\n| -------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `server/api/**`      | handle web requests from `server/router`                                            | `pipeline`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) |\n| `server/badges/**`   | generate svg badges for pipelines                                                   | `../model`                                                                                                                                                                            |\n| `server/ccmenu/**`   | generate xml ccmenu for pipelines                                                   | `../model`                                                                                                                                                                            |\n| `server/grpc/**`     | gRPC server agents can connect to                                                   | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store`                                                                           |\n| `server/logging/**`  | logging lib for gPRC server to stream logs while running                            | std                                                                                                                                                                                   |\n| `server/model/**`    | structs for store (db) and api (json)                                               | std                                                                                                                                                                                   |\n| `server/plugins/**`  | plugins for server                                                                  | `../model`, `../forge`                                                                                                                                                                |\n| `server/pipeline/**` | orchestrate pipelines                                                               | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins`                                                                                                 |\n| `server/pubsub/**`   | pubsub lib for server to push changes to the WebUI                                  | std                                                                                                                                                                                   |\n| `server/queue/**`    | queue lib for server where agents pull new pipelines from via gRPC                  | `server/model`                                                                                                                                                                        |\n| `server/forge/**`    | forge lib for server to connect and handle forge specific stuff                     | `shared`, `server/model`                                                                                                                                                              |\n| `server/router/**`   | handle requests to REST API (and all middleware) and serve UI and WebUI config      | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web`                                                                                                                      |\n| `server/store/**`    | handle database                                                                     | `server/model`                                                                                                                                                                        |\n| `server/shared/**`   | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) |                                                                                                                                                                                       |\n| `server/web/**`      | server SPA                                                                          |                                                                                                                                                                                       |\n\n- `../` = `server/`\n\n### Agent\n\nTODO\n\n### CLI\n\nTODO\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/06-conventions.md",
    "content": "# Conventions\n\n## Database naming\n\nDatabase tables are named plural, columns don't have any prefix.\n\nExample: Model name `Agent` with table name `agents` and columns `id`, `name`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/07-guides.md",
    "content": "# Guides\n\n## ORM\n\nWoodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection.\n\n## Add a new migration\n\nWoodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`.\n\n:::info\nAdding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created.\n:::\n\n:::warning\nYou should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager.\n:::\n\nTo automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start.\n\n## Constants of official images\n\nAll official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag.\n\n## Building images locally\n\n### Server\n\n```sh\n### build web component\nmake vendor\ncd web/\npnpm install --frozen-lockfile\npnpm build\ncd ..\n\n### define the platforms to build for (e.g. linux/amd64)\n# (the | is not a typo here)\nexport PLATFORMS='linux|amd64'\nmake cross-compile-server\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push .\n```\n\n:::info\nThe `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)).\nYou can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS).\n:::\n\n### Agent\n\n```sh\n### build the agent\nmake build-agent\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push .\n```\n\n### CLI\n\n```sh\n### build the CLI\nmake build-cli\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push .\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/08-translations.md",
    "content": "# Translations\n\nTo translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.**\n\n<a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n  <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/09-openapi.md",
    "content": "# Swagger, API Spec and Code Generation\n\nWoodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically\ngenerate Swagger v2 API specifications and a nice looking Web UI from the source code.\nAlso, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger)\nand then being using on the community's website documentation.\n\nIt's paramount important to keep the gin handler function's godoc documentation up-to-date,\nto always have accurate API documentation.\nWhenever you change, add or enhance an API endpoint, please update the godoc.\n\nYou don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools.\n\n## Gin-Handler API documentation guideline\n\nHere's a typical example of how annotations for Swagger documentation look like...\n\n```go title=\"server/api/user.go\"\n// @Summary  Get a user\n// @Description Returns a user with the specified login name. Requires admin rights.\n// @Router   /users/{login} [get]\n// @Produce  json\n// @Success  200 {object} User\n// @Tags   Users\n// @Param   Authorization header string true \"Insert your personal access token\" default(Bearer <personal access token>)\n// @Param   login   path string true \"the user's login name\"\n// @Param   foobar  query   string false \"optional foobar parameter\"\n// @Param   page    query int  false \"for response pagination, page offset number\" default(1)\n// @Param   perPage query int  false \"for response pagination, max items per page\" default(50)\n```\n\n```go title=\"server/model/user.go\"\ntype User struct {\n  ID int64 `json:\"id\" xorm:\"pk autoincr 'user_id'\"`\n// ...\n} // @name User\n```\n\nThese guidelines aim to have consistent wording in the OpenAPI doc:\n\n- first word after `@Summary` and `@Summary` are always uppercase\n- `@Summary` has no `.` (dot) at the end of the line\n- model structs shall use custom short names, to ease life for API consumers, using `@name`\n- `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI\n- when pagination is used, `@Param page` and `@Param perPage` must be added manually\n- `@Param Authorization` is almost always present, there are just a few un-protected endpoints\n\nThere are many examples in the `server/api` package, which you can use a blueprint.\nMore enhanced information you can find here <https://github.com/swaggo/swag/blob/master/README.md#declarative-comments-format>\n\n### Manual code generation\n\n```bash title=\"generate the server's Go code containing the OpenAPI\"\nmake generate-openapi\n```\n\n```bash title=\"update the Markdown in the ./docs folder\"\nmake generate-docs\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/09-testing.md",
    "content": "# Testing\n\n## Backend\n\n### Unit Tests\n\n[We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test)\nwith [`\"github.com/stretchr/testify/assert\"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing.\n\n### Integration Tests\n\n### Dummy backend\n\nThere is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave.\nTo enable it you need to build the agent or cli with the `test` build tag.\n\nAn example pipeline config would be:\n\n```yaml\nwhen:\n  event: manual\n\nsteps:\n  - name: echo\n    image: dummy\n    commands: echo \"hello woodpecker\"\n    environment:\n      SLEEP: '1s'\n\nservices:\n  echo:\n    image: dummy\n    commands: echo \"i am a service\"\n```\n\nThis could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`:\n\n<!-- cspell:disable -->\n\n```none\n9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: service\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: commands\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n```\n\n<!-- cspell:enable -->\n\nThere are also environment variables to alter step behavior:\n\n- `SLEEP: 10` will let the step wait 10 seconds\n- `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands`\n- `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled)\n- `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs\n- `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0\n- `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains\n\nYou can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/10-packaging.md",
    "content": "# Packaging\n\nIf you repackage it, we encourage to build from source, which requires internet connection.\n\nFor offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI\non the [release page](https://github.com/woodpecker-ci/woodpecker/releases).\n\n## Distribute web UI in own directory\n\nIf you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary.\n\nAdd `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path.\n\nExample: <!-- cspell:ignore webui -->\n\n```sh\ngo build -tags 'external_web' -ldflags '-s -w -extldflags \"-static\" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/100-addons.md",
    "content": "# Addons\n\nThe Woodpecker server supports addons for forges and the log store.\n\n:::warning\nAddons are still experimental. Their implementation can change and break at any time.\n:::\n\n## Bug reports\n\nIf you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name.\n\n## Creating addons\n\nAddons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin).\n\n### Writing your code\n\nThis example will use the Go language.\n\nDirectly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there.\n\nIn the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument.\nThis will take care of connecting the addon forge to the server.\n\n:::note\nIt is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process.\n:::\n\n### Example structure\n\nThis is an example for a forge addon.\n\n```go\npackage main\n\nimport (\n  \"context\"\n  \"net/http\"\n\n  \"go.woodpecker-ci.org/woodpecker/v3/server/forge/addon\"\n  forgeTypes \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n  \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc main() {\n  addon.Serve(config{})\n}\n\ntype config struct {\n}\n\n// `config` must implement `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`. You must directly use Woodpecker's packages - see imports above.\n```\n\n### Addon types\n\n| Type      | Addon package                                                 | Service interface                                                 |\n| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- |\n| Forge     | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon`       | `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`         |\n| Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `\"go.woodpecker-ci.org/woodpecker/v3/server/service/log\".Service` |\n"
  },
  {
    "path": "docs/versioned_docs/version-3.13/92-development/_category_.yaml",
    "content": "label: 'Development'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/10-intro/index.md",
    "content": "# Welcome to Woodpecker\n\nWoodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics.\n\n## Have you ever heard of CI/CD or pipelines?\n\nDon't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of\nchecks, tests and routines along the way. A typical pipeline might include the following steps:\n\n1. Running tests\n2. Building your application\n3. Deploying your application\n\n[Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd)\n\n## Do you know containers?\n\nIf you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/).\n\n## Already have access to a Woodpecker instance?\n\nThen you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md).\n\n## Want to start from scratch and deploy your own Woodpecker instance?\n\nWoodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/10-intro.md",
    "content": "# Your first pipeline\n\nLet's get started and create your first pipeline.\n\n## 1. Repository Activation\n\nTo activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click.\n\n![new repository list](repo-new.png)\n\nTo enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something\nthat is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.).\n\n## 2. Define first workflow\n\nAfter enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository:\n\n```yaml title=\".woodpecker/my-first-workflow.yaml\"\nwhen:\n  - event: push\n    branch: main\n\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"This is the build step\"\n      - echo \"binary-data-123\" > executable\n  - name: a-test-step\n    image: golang:1.16\n    commands:\n      - echo \"Testing ...\"\n      - ./executable\n```\n\n**So what did we do here?**\n\n1. We defined your first workflow file `my-first-workflow.yaml`.\n2. This workflow will be executed when a push event happens on the `main` branch,\n   because we added a filter using the `when` section:\n\n   ```diff\n   + when:\n   +   - event: push\n   +     branch: main\n\n   ...\n   ```\n\n3. We defined two steps: `build` and `a-test-step`\n\nThe steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`.\n\nIn the `build` step we use the `debian` image and build a \"binary file\" called `executable`.\n\nIn the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it.\n\nYou can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to:\n\n```diff\n steps:\n   - name: build\n-    image: debian\n+    image: my-company/image-with-aws_cli\n     commands:\n       - aws help\n```\n\n## 3. Push the file and trigger first pipeline\n\nIf you push this file to your repository now, Woodpecker will already execute your first pipeline.\n\nYou can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository.\n\n![pipeline view](./pipeline.png)\n\nAs you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps.\n\nThis for example allows the first step to build your application using your source code and as the second step will receive\nthe same workspace it can use the previously built binary and test it.\n\n## 4. Use a plugin for reusable tasks\n\nSometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md).\n\nIf you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline:\n\n```yaml\nsteps:\n  # ...\n  - name: upload\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      access_key: a50d28f4dd477bc184fbd10b376de753\n      secret_key:\n        from_secret: aws_secret_key\n      source: public/**/*\n      target: /target/location\n```\n\nTo configure a plugin you can use the `settings` section.\n\nSometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md).\n\nSimilar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed.\n\nLearn more about [plugins](./51-plugins/51-overview.md).\n\nAs you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/100-troubleshooting.md",
    "content": "# Troubleshooting\n\n## How to debug clone issues\n\n(And what to do with an error message like `fatal: could not read Username for 'https://<url>': No such device or address`)\n\nThis error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`:\n\n```ini\nWOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true\n```\n\nIf that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container \"hang\":\n\n```yaml\nskip_clone: true\n\nsteps:\n  build:\n    image: debian:stable-backports\n    commands:\n      - apt update\n      - apt install -y inetutils-ping wget\n      - ping -c 4 git.example.com\n      - wget git.example.com\n      - sleep 9999999\n```\n\nGet the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf  bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline:\n\n```bash\ngit init\ngit remote add origin https://git.example.com/username/repo.git\ngit fetch --no-tags origin +refs/heads/branch:\n```\n\n(replace the url AND the branch with the correct values, use your username and password as log in values)\n\n## SELinux Issues\n\nWhen running Woodpecker on systems with SELinux enabled (such as RHEL, CentOS, Fedora, or other Enterprise Linux distributions), SELinux may prevent the agent from accessing the Docker socket.\n\n### Symptoms\n\nIf SELinux is blocking access, you may see errors like:\n\n```text\npermission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock\n```\n\n### Solutions\n\nThere are several ways to resolve this:\n\n#### Option 1: Set SELinux to Permissive Mode (For Testing Only)\n\nSet SELinux to permissive mode temporarily to verify it's the issue:\n\n```bash\nsetenforce 0\n```\n\nTo permanently set SELinux to permissive mode:\n\n```bash\n# Edit /etc/selinux/config\nSELINUX=permissive\n```\n\n#### Option 2: Configure SELinux Policy (Recommended)\n\nCreate a custom SELinux policy to allow Woodpecker agent to access Docker:\n\n```bash\n# Generate the policy module\nausearch -c 'docker' -avc | audit2allow -R -o woodpecker-docker.te\n# Build the policy module\ncheckmodule -M -m -o woodpecker-docker.mod woodpecker-docker.te\nsemodule_package -o woodpecker-docker.pp -m woodpecker-docker.mod\n# Load the policy module\nsemodule -i woodpecker-docker.pp\n```\n\n#### Option 3: Use Docker Volume with SELinux Options\n\nWhen using Docker Compose or Docker, add the `:z` or `:Z` option to volume mounts:\n\n```yaml\nvolumes:\n  - /var/run/docker.sock:/var/run/docker.sock:z\n```\n\nThe `:z` option tells Docker to automatically relabel the volume content for SELinux. Use `:Z` with caution as it relabels the volume exclusively for this container.\n\n#### Option 4: Use Podman (Alternative)\n\nIf you prefer to avoid SELinux configuration issues, consider using Podman instead of Docker, as it has better SELinux integration.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/15-terminology/architecture.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 226,\n      \"versionNonce\": 1002880859,\n      \"isDeleted\": false,\n      \"id\": \"UczUX5VuNnCB1rVvUJVfm\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.098092529257,\n      \"y\": 320.8758615860986,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 472.8823858375721,\n      \"height\": 183.19688715994928,\n      \"seed\": 917720693,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 161,\n      \"versionNonce\": 286006267,\n      \"isDeleted\": false,\n      \"id\": \"sKPZmBSWUdAYfBs4ByItH\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 539.5451038202509,\n      \"y\": 345.2419383247636,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 82.46875,\n      \"height\": 32.199999999999996,\n      \"seed\": 1485551573,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Server\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Server\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 333,\n      \"versionNonce\": 448586907,\n      \"isDeleted\": false,\n      \"id\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 649.8080506852966,\n      \"y\": 427.60908869342575,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 136,\n      \"height\": 60,\n      \"seed\": 1783625013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"r90dckf8trHemYzEwCgCW\"\n        },\n        {\n          \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 298,\n      \"versionNonce\": 1244067771,\n      \"isDeleted\": false,\n      \"id\": \"r90dckf8trHemYzEwCgCW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 703.8080506852966,\n      \"y\": 441.5090886934257,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 28,\n      \"height\": 32.199999999999996,\n      \"seed\": 660965013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113383,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"UI\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"_A8uznhnpXuQBYzjP-iVx\",\n      \"originalText\": \"UI\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 105,\n      \"versionNonce\": 265992667,\n      \"isDeleted\": false,\n      \"id\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 800.9240766836483,\n      \"y\": 421.4987043996123,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135.3671503686619,\n      \"height\": 62.2689029398432,\n      \"seed\": 1115810805,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"svsVhxCbatcLj7lQLch0P\"\n        },\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 83,\n      \"versionNonce\": 1706870395,\n      \"isDeleted\": false,\n      \"id\": \"svsVhxCbatcLj7lQLch0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 828.1594096804793,\n      \"y\": 436.53315586953386,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.896484375,\n      \"height\": 32.199999999999996,\n      \"seed\": 2074781013,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"GRPC\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n      \"originalText\": \"GRPC\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 270,\n      \"versionNonce\": 418660123,\n      \"isDeleted\": false,\n      \"id\": \"hSrrwwnm9y7R-_CnJtaK1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.567103519039,\n      \"y\": 556.4146894573112,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 1983197877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113380,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 154,\n      \"versionNonce\": 871605179,\n      \"isDeleted\": false,\n      \"id\": \"8tsYgVssKnBd_Zw1QuqNz\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1298.4367898442752,\n      \"y\": 566.567242947784,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 96.5234375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1321669653,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent 1\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent 1\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 1323136091,\n      \"isDeleted\": false,\n      \"id\": \"eeugZg73_yD_6uLBBgmcX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 404.5001910129067,\n      \"y\": 707.1233710221009,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 210.068359375,\n      \"height\": 32.199999999999996,\n      \"seed\": 1901447541,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"User => Browser\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"User => Browser\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"ellipse\",\n      \"version\": 106,\n      \"versionNonce\": 1501835515,\n      \"isDeleted\": false,\n      \"id\": \"mlDhl4OOc-H1tNgh77AAW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 482.5857164810477,\n      \"y\": 602.4394551739279,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 46.024748503793035,\n      \"height\": 44.21988070606176,\n      \"seed\": 791073493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"line\",\n      \"version\": 166,\n      \"versionNonce\": 627726747,\n      \"isDeleted\": false,\n      \"id\": \"ADEXzdYAhvj-_wVRftTIg\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 459.12202200277807,\n      \"y\": 697.1964604319912,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 80.31792517362464,\n      \"height\": 31.585599568061298,\n      \"seed\": 349155381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          42.415150610916044,\n          -28.87829787146393\n        ],\n        [\n          80.31792517362464,\n          2.7073016965973693\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 231,\n      \"versionNonce\": 801271355,\n      \"isDeleted\": false,\n      \"id\": \"xmz4J-rxLIjfUQ4q19PjD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 516.8788931508789,\n      \"y\": 870.4664542146543,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#fff4e6\",\n      \"width\": 385.34512717560705,\n      \"height\": 60.464035142111264,\n      \"seed\": 3531157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 93,\n      \"versionNonce\": 728690395,\n      \"isDeleted\": false,\n      \"id\": \"gSbpry_947XArfI7b6AAL\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 636.1468430141358,\n      \"y\": 878.5884970070326,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 132.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1989076725,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Autoscaler\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Autoscaler\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 118,\n      \"versionNonce\": 1258445691,\n      \"isDeleted\": false,\n      \"id\": \"WVy0mdTGbUx08RuxdQUH8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 523.3741602213286,\n      \"y\": 907.372811672524,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 369.1484375,\n      \"height\": 18.4,\n      \"seed\": 979386453,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Starts agents based on amount of pending pipelines\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Starts agents based on amount of pending pipelines\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 373,\n      \"versionNonce\": 1254044699,\n      \"isDeleted\": false,\n      \"id\": \"0Y1RcqzVFBFqh-wy-APMI\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1232.1955835481922,\n      \"y\": 605.8737363119278,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 292.6171875,\n      \"height\": 18.4,\n      \"seed\": 561999285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Executes pending workflows of a pipeline\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Executes pending workflows of a pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 630,\n      \"versionNonce\": 983038139,\n      \"isDeleted\": false,\n      \"id\": \"lGumbhMs3xx1vU2632hli\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 505.62283787078286,\n      \"y\": 383.42044095379515,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.015625,\n      \"height\": 36.8,\n      \"seed\": 722595605,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Central unit of a \\nWoodpecker instance \",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Central unit of a \\nWoodpecker instance \",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 131,\n      \"versionNonce\": 137308507,\n      \"isDeleted\": false,\n      \"id\": \"PbSQXehWVLYcQGXYFpd-B\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 971.7123256059622,\n      \"y\": 171.06951064323448,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 274.3443117379593,\n      \"height\": 74.90311522655017,\n      \"seed\": 1435321461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 96,\n      \"versionNonce\": 1222067707,\n      \"isDeleted\": false,\n      \"id\": \"2P2tz29C_2sUzVNSpaG17\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1065.5206131439782,\n      \"y\": 183.12082907329545,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 73.14453125,\n      \"height\": 32.199999999999996,\n      \"seed\": 884403669,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Forge\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Forge\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 141,\n      \"versionNonce\": 1133694619,\n      \"isDeleted\": false,\n      \"id\": \"0eYhFYPuRanZ7wkR2OlHO\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 986.864582863368,\n      \"y\": 225.1223531590797,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 247.234375,\n      \"height\": 18.4,\n      \"seed\": 1201957685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Github, Gitea, Github, Bitbucket, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 55,\n      \"versionNonce\": 991183675,\n      \"isDeleted\": false,\n      \"id\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 820.419424341303,\n      \"y\": 340.29123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 247151765,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"bcUL-u4zkLA9CLG2YdaeN\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 38,\n      \"versionNonce\": 2008949723,\n      \"isDeleted\": false,\n      \"id\": \"bcUL-u4zkLA9CLG2YdaeN\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 831.853994653803,\n      \"y\": 358.79123237109366,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 94.130859375,\n      \"height\": 23,\n      \"seed\": 1638982133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Webhooks\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"dihpRzuIc-UoRSsOI33SZ\",\n      \"originalText\": \"Webhooks\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 93,\n      \"versionNonce\": 295891067,\n      \"isDeleted\": false,\n      \"id\": \"Bphhue86mMXHN4klGamM3\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 697.3018309300141,\n      \"y\": 339.607928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 117,\n      \"height\": 60,\n      \"seed\": 92986197,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"0YxY2hEPyDWFqR8_-f6bn\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 87,\n      \"versionNonce\": 2055547163,\n      \"isDeleted\": false,\n      \"id\": \"0YxY2hEPyDWFqR8_-f6bn\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 727.4522215550141,\n      \"y\": 358.107928999312,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 56.69921875,\n      \"height\": 23,\n      \"seed\": 43952309,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"OAuth\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Bphhue86mMXHN4klGamM3\",\n      \"originalText\": \"OAuth\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 284,\n      \"versionNonce\": 1205292475,\n      \"isDeleted\": false,\n      \"id\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1205.6453201409104,\n      \"y\": 250.4849674923464,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 272.1094712799886,\n      \"height\": 94.31865813977868,\n      \"seed\": 982632981,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"0eYhFYPuRanZ7wkR2OlHO\",\n        \"focus\": -0.8418551162334328,\n        \"gap\": 6.962614333266799\n      },\n      \"endBinding\": null,\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          -69.68740859223726,\n          65.87860410965993\n        ],\n        [\n          -272.1094712799886,\n          94.31865813977868\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 53,\n      \"versionNonce\": 1803962459,\n      \"isDeleted\": false,\n      \"id\": \"uDIWJ5K5mEBL9QaiNk3cS\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1050.575099048673,\n      \"y\": 297.96357160200637,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 170.765625,\n      \"height\": 36.8,\n      \"seed\": 1046069109,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"sends events like push, \\ntag, ...\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"HK1jmIcPmM6Us6Jrynobb\",\n      \"originalText\": \"sends events like push, tag, ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 487,\n      \"versionNonce\": 335895291,\n      \"isDeleted\": false,\n      \"id\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 792.0835609101814,\n      \"y\": 316.38601649373913,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 176.92139414789008,\n      \"height\": 122.73778943055902,\n      \"seed\": 1681656021,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yvJTQ64RU50N6-hxEQlkl\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"UczUX5VuNnCB1rVvUJVfm\",\n        \"focus\": -0.03867359238356983,\n        \"gap\": 4.489845092359474\n      },\n      \"endBinding\": {\n        \"elementId\": \"PbSQXehWVLYcQGXYFpd-B\",\n        \"focus\": 0.7798878042817562,\n        \"gap\": 2.707370547890605\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          60.422360349016344,\n          -71.97786730696657\n        ],\n        [\n          176.92139414789008,\n          -122.73778943055902\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 62,\n      \"versionNonce\": 301106427,\n      \"isDeleted\": false,\n      \"id\": \"yvJTQ64RU50N6-hxEQlkl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 773.7910775091977,\n      \"y\": 226.00814918677256,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 157.4296875,\n      \"height\": 36.8,\n      \"seed\": 500049461,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113385,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"allows users to login \\nusing existing account\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"Kqbwk_qfkALJfhtCIr2eS\",\n      \"originalText\": \"allows users to login using existing account\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 393,\n      \"versionNonce\": 598459861,\n      \"isDeleted\": false,\n      \"id\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 936.9267543177084,\n      \"y\": 458.95033086418084,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 215.17788326846676,\n      \"height\": 93.99151368376693,\n      \"seed\": 234198933,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"rFf6NIofw6UBOyAFwg0Kn\"\n        }\n      ],\n      \"updated\": 1697530127259,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.30339107267010673,\n        \"gap\": 1\n      },\n      \"endBinding\": {\n        \"elementId\": \"hSrrwwnm9y7R-_CnJtaK1\",\n        \"focus\": -0.14057158065513534,\n        \"gap\": 3.4728449093634026\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          130.0760301643047,\n          42.90930518030268\n        ],\n        [\n          215.17788326846676,\n          93.99151368376693\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 8,\n      \"versionNonce\": 1693330843,\n      \"isDeleted\": false,\n      \"id\": \"rFf6NIofw6UBOyAFwg0Kn\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 997.4942845557462,\n      \"y\": 473.9409015069133,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 1592253685,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"TvtonmlV0W8__pnTG-wVZ\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 270,\n      \"versionNonce\": 1855882619,\n      \"isDeleted\": false,\n      \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 886.0581619083632,\n      \"y\": 485.67004123832135,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 174.09447592006472,\n      \"height\": 326.4905563076211,\n      \"seed\": 1479177813,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"apyMCAv2GIN_yzHXwX4tY\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"v2eEwSOSRQBZ79O6wyzGf\",\n        \"focus\": -0.1341191028023529,\n        \"gap\": 1.9024338988657519\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"focus\": -0.7088661407505865,\n        \"gap\": 4.060573862784622\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          44.14165353942735,\n          196.18483635907205\n        ],\n        [\n          174.09447592006472,\n          326.4905563076211\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 66,\n      \"versionNonce\": 2007745083,\n      \"isDeleted\": false,\n      \"id\": \"apyMCAv2GIN_yzHXwX4tY\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 849.4927841977906,\n      \"y\": 663.4548775973934,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 161.4140625,\n      \"height\": 36.8,\n      \"seed\": 882041781,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113386,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"receives workflows & \\nreturns logs + statuses\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"5tl702dfcvJDLz9aIFU0P\",\n      \"originalText\": \"receives workflows & returns logs + statuses\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 32\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 347,\n      \"versionNonce\": 1353818811,\n      \"isDeleted\": false,\n      \"id\": \"XxfJWnHonmvNOJzMFSlie\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 534.9278465333664,\n      \"y\": 595.2199151317081,\n      \"strokeColor\": \"#c2255c\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 113.88020415193023,\n      \"height\": 119.81968366814112,\n      \"seed\": 944153877,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"_A8uznhnpXuQBYzjP-iVx\",\n        \"focus\": 0.5397285671082249,\n        \"gap\": 1\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          113.88020415193023,\n          -119.81968366814112\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 61,\n      \"versionNonce\": 1099141979,\n      \"isDeleted\": false,\n      \"id\": \"j56ZKRwmXk72nHrZzLz_1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1081.8110514012087,\n      \"y\": 652.5253283508498,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 566.7373014532342,\n      \"height\": 68.58600908319681,\n      \"seed\": 112933493,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 82,\n      \"versionNonce\": 1879994363,\n      \"isDeleted\": false,\n      \"id\": \"cAVYXfBRnfuGAv7QTQVow\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1300.6584159706863,\n      \"y\": 658.8425033454967,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 77.83203125,\n      \"height\": 23,\n      \"seed\": 951460821,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Backend\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Backend\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 376,- add some images explaining the architecture & terminology with\npipeline -> workflow -> step\n- combine advanced config usage\n- rename pipeline syntax to workflow syntax (and most references to\npipeline steps etc as well)\n- update agent registration part\n- add bug note to secrets encryption setting\n- remove usage from readme to point to up-to-date docs page\n- typos\n- closes #1408\n\n---------\n      \"angle\": 0,\n      \"x\": 1094.1972977313717,\n      \"y\": 681.8988272758752,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530.9453125,\n      \"height\": 55.199999999999996,\n      \"seed\": 843899189,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The backend is the environment (exp. Docker / Kubernetes / local) used to \\nexecute workflows in.\\n\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 50\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 384,\n      \"versionNonce\": 1778969915,\n      \"isDeleted\": false,\n      \"id\": \"pxF49EKDNO6IZq_34i7bY\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1064.2132116912126,\n      \"y\": 754.5018564383092,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#ebfbee\",\n      \"width\": 601.932705468054,\n      \"height\": 175.07489600604117,\n      \"seed\": 954528405,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"5tl702dfcvJDLz9aIFU0P\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 154,\n      \"versionNonce\": 1988988379,\n      \"isDeleted\": false,\n      \"id\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 904.0288881242177,\n      \"y\": 882.4966027880746,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 158.83070714434325,\n      \"height\": 32.735025983189644,\n      \"seed\": 1228134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"yNxAOEPZu_Jl7mnI01OXs\"\n        }\n      ],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"xmz4J-rxLIjfUQ4q19PjD\",\n        \"gap\": 1.8048677977312764,\n        \"focus\": 0.31250963573550006\n      },\n      \"endBinding\": {\n        \"elementId\": \"pxF49EKDNO6IZq_34i7bY\",\n        \"gap\": 1.353616422651612,\n        \"focus\": 0.36496042109885213\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": \"triangle\",\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          158.83070714434325,\n          -32.735025983189644\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 25,\n      \"versionNonce\": 1393410779,\n      \"isDeleted\": false,\n      \"id\": \"yNxAOEPZu_Jl7mnI01OXs\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 963.8856479463893,\n      \"y\": 856.9290897964797,\n      \"strokeColor\": \"#f08c00\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 39.1171875,\n      \"height\": 18.4,\n      \"seed\": 759107925,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113387,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 16,\n      \"fontFamily\": 2,\n      \"text\": \"starts\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"05EJzh4NLXxemaKAmdi5n\",\n      \"originalText\": \"starts\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 14\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 187,\n      \"versionNonce\": 671224603,\n      \"isDeleted\": false,\n      \"id\": \"sSj4Pda-fo-BBYM_dzml6\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 1296.0854928322988,\n      \"y\": 776.6118140041631,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 104.2890625,\n      \"height\": 32.199999999999996,\n      \"seed\": 1381768885,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530113381,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Agent ...\",\n      \"textAlign\": \"right\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Agent ...\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/15-terminology/index.md",
    "content": "# Terminology\n\n## Glossary\n\n- **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC.\n- **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines.\n- **Code**: Refers to the files tracked by the version control system used by the [forge][Forge].\n- **Commit**: A defined state of the code, usually associated with a version control system like Git.\n- **Container**: A lightweight and isolated environment where commands are executed.\n- **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel.\n- **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI.\n- **[Extension][Extension]**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through extensions.\n- **[Forge][Forge]**: The hosting platform or service where the repositories are hosted.\n- **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix.\n- **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events.\n- **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings.\n- **Repos**: Short for repositories, these are storage locations where code is stored.\n- **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration.\n- **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow].\n- **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge].\n- **Steps**: Individual commands, actions or tasks within a [workflow][Workflow].\n- **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue.\n- **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code.\n- **Woodpecker CI**: The project name around Woodpecker.\n- **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker).\n- **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps.\n- **YAML File**: A file format used to define and configure [workflows][Workflow].\n\n## Woodpecker architecture\n\n![Woodpecker architecture](architecture.svg)\n\n## Pipeline, workflow & step\n\n![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg)\n\n## Conventions\n\nSometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker:\n\n- Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()`\n- Use the term **pipelines** instead of the previous **builds**\n- Use the term **steps** instead of the previous **jobs**\n- Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users\n\n<!-- References -->\n\n[Event]: ../20-workflow-syntax.md#event\n[Pipeline]: ../20-workflow-syntax.md\n[Workflow]: ../25-workflows.md\n[Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md\n[Plugin]: ../51-plugins/51-overview.md\n[Workspace]: ../20-workflow-syntax.md#workspace\n[Matrix]: ../30-matrix-workflows.md\n[Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md\n[Local]: ../../30-administration/10-configuration/11-backends/30-local.md\n[Extension]: ../72-extensions/index.md\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/15-terminology/pipeline-workflow-step.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 97,\n      \"versionNonce\": 257762037,\n      \"isDeleted\": false,\n      \"id\": \"Y3hYdpX9r1qWfyHWs7AXT\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 393.622323134362,\n      \"y\": 336.02197155458475,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#e7f5ff\",\n      \"width\": 366.3936710429598,\n      \"height\": 499.95605689083004,\n      \"seed\": 875444373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 67,\n      \"versionNonce\": 369556565,\n      \"isDeleted\": false,\n      \"id\": \"g1Eb010Kx_KFryVqNYWBQ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 520.0116988873679,\n      \"y\": 363.32095846456355,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 99.626953125,\n      \"height\": 32.199999999999996,\n      \"seed\": 1466195445,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 28,\n      \"fontFamily\": 2,\n      \"text\": \"Pipeline\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Pipeline\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 25\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 314,\n      \"versionNonce\": 1983028731,\n      \"isDeleted\": false,\n      \"id\": \"9o-DNP0YdlIGVz1kEm_hW\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 407.1590381712276,\n      \"y\": 410.9252244837219,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1869535061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 156,\n      \"versionNonce\": 1495247317,\n      \"isDeleted\": false,\n      \"id\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 490.3194993196821,\n      \"y\": 473.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 111355061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"ya0JzDo-4oscHIq87TZ_D\"\n        },\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 156,\n      \"versionNonce\": 1469425461,\n      \"isDeleted\": false,\n      \"id\": \"ya0JzDo-4oscHIq87TZ_D\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 566.0118821321821,\n      \"y\": 478.52959018719525,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1084671509,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"q4TKpiq2KAwPaz19GdhtK\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 236,\n      \"versionNonce\": 1535319541,\n      \"isDeleted\": false,\n      \"id\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 491.2218643672577,\n      \"y\": 519.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 812596085,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"FRby8A9aUiKvHpM5mCdDN\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 231,\n      \"versionNonce\": 28677973,\n      \"isDeleted\": false,\n      \"id\": \"FRby8A9aUiKvHpM5mCdDN\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 583.0324112422577,\n      \"y\": 524.7800332298218,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1849820373,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"AOJLQFldoHd2vxVtB2jrS\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 291,\n      \"versionNonce\": 571598005,\n      \"isDeleted\": false,\n      \"id\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 489.6426911083554,\n      \"y\": 567.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1840554549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"UOwxmKIS0W62CFt_ffEy4\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 4032021,\n      \"isDeleted\": false,\n      \"id\": \"UOwxmKIS0W62CFt_ffEy4\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 581.4532379833554,\n      \"y\": 572.609787233933,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 330077077,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"2WwuMWX7YawqK0i1rDPJo\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 296,\n      \"versionNonce\": 1539516059,\n      \"isDeleted\": false,\n      \"id\": \"9laL3864YWOna6NQlVDqq\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 630.0635849044402,\n      \"y\": 383.14314287821776,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 294.3024370154917,\n      \"height\": 36.656016722015465,\n      \"seed\": 207575285,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -1.000156025347643,\n        \"gap\": 27.782081605504118\n      },\n      \"endBinding\": {\n        \"elementId\": \"vS2PNUbmeBe3EPxl-dID8\",\n        \"focus\": 0.7761987167055517,\n        \"gap\": 8.978940924346716\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          294.3024370154917,\n          -36.656016722015465\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 249,\n      \"versionNonce\": 2076402229,\n      \"isDeleted\": false,\n      \"id\": \"vS2PNUbmeBe3EPxl-dID8\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 933.3449628442786,\n      \"y\": 336.02200598023114,\n      \"strokeColor\": \"#1971c2\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 301.298828125,\n      \"height\": 46,\n      \"seed\": 1632793173,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"9laL3864YWOna6NQlVDqq\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"A pipeline is triggered by an event\\nlike a push, tag, manual\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 751,\n      \"versionNonce\": 1371044827,\n      \"isDeleted\": false,\n      \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 751.1619011845514,\n      \"y\": 440.8355079324799,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 160.46519124360202,\n      \"height\": 2.2452348338335923,\n      \"seed\": 1331388341,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"9o-DNP0YdlIGVz1kEm_hW\",\n        \"focus\": -0.6591700594229558,\n        \"gap\": 3.8807513696519322\n      },\n      \"endBinding\": {\n        \"elementId\": \"wfFvnFZuh0npL9hh0ez7o\",\n        \"focus\": 0.7652411053273549,\n        \"gap\": 20.75618622779257\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          160.46519124360202,\n          -2.2452348338335923\n        ]\n      ]\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 819540565,\n      \"isDeleted\": false,\n      \"id\": \"TbejdIYo_qNDw15yLP2IB\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 406.0812257713851,\n      \"y\": 626.8305540252475,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#f8f0fc\",\n      \"width\": 340.12211164367193,\n      \"height\": 199,\n      \"seed\": 1553965333,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 466,\n      \"versionNonce\": 663477,\n      \"isDeleted\": false,\n      \"id\": \"wfFvnFZuh0npL9hh0ez7o\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 932.383278655946,\n      \"y\": 424.0107569968011,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 481.2890625,\n      \"height\": 115,\n      \"seed\": 781497973,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"FU4jk6Tz6duLaaZE0Z55A\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Every pipeline consists of multiple workflows.\\nEach defined by a separate YAML file and is named \\nafter the filename.\\nEach workflow has its own workspace (folder) which is\\nused by all steps of that workflow.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 110\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 464,\n      \"versionNonce\": 734626075,\n      \"isDeleted\": false,\n      \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 741.0645380446722,\n      \"y\": 492.31283255558515,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 178.4459423531871,\n      \"height\": 83.08707392565111,\n      \"seed\": 536879061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"q4TKpiq2KAwPaz19GdhtK\",\n        \"focus\": -0.7697471991854113,\n        \"gap\": 3.7450387249900814\n      },\n      \"endBinding\": {\n        \"elementId\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n        \"focus\": -0.7822252364700005,\n        \"gap\": 8.360835317635974\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          178.4459423531871,\n          83.08707392565111\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 327,\n      \"versionNonce\": 371646421,\n      \"isDeleted\": false,\n      \"id\": \"Vu0JJ6ZWuEhEyCfxeHPtc\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 927.8713157154953,\n      \"y\": 563.2132686484658,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 491.357421875,\n      \"height\": 46,\n      \"seed\": 385310005,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"1ZbDRqbETCkEx62nCmnpJ\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"The default first step of each workflow is the clone step.\\nIts fetches the specific code version for a pipeline.\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 91,\n      \"versionNonce\": 1180085909,\n      \"isDeleted\": false,\n      \"id\": \"0tGx2VdJLNf7W6HD76dtO\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 427.6895298601876,\n      \"y\": 432.3583566254258,\n      \"strokeColor\": \"#9c36b5\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 143.876953125,\n      \"height\": 23,\n      \"seed\": 450883221,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"build\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"build\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 338,\n      \"versionNonce\": 957223925,\n      \"isDeleted\": false,\n      \"id\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.7251825950889,\n      \"y\": 685.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 711939061,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"8EqaPnZX2CgLaF08UNZZg\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 340,\n      \"versionNonce\": 510774613,\n      \"isDeleted\": false,\n      \"id\": \"8EqaPnZX2CgLaF08UNZZg\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 563.4175654075889,\n      \"y\": 690.3516128043414,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 95.615234375,\n      \"height\": 23,\n      \"seed\": 1370164565,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Clone step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"LQ2h2aO9uzDWyLG6OLn70\",\n      \"originalText\": \"Clone step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 421,\n      \"versionNonce\": 97999541,\n      \"isDeleted\": false,\n      \"id\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 488.62754764266447,\n      \"y\": 731.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 2145950389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"DX10t075MMDu7BLtuUaij\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 417,\n      \"versionNonce\": 2011446293,\n      \"isDeleted\": false,\n      \"id\": \"DX10t075MMDu7BLtuUaij\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 580.4380945176645,\n      \"y\": 736.6020558469675,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 500005909,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"1. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"St9t4nwHuXXVlmjDqfn_Z\",\n      \"originalText\": \"1. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 475,\n      \"versionNonce\": 1284370805,\n      \"isDeleted\": false,\n      \"id\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 487.04837438376217,\n      \"y\": 779.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 247,\n      \"height\": 33,\n      \"seed\": 1666134389,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 3\n      },\n      \"boundElements\": [\n        {\n          \"type\": \"text\",\n          \"id\": \"-xogFSFcP-Vv5cuOSFm8T\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 476,\n      \"versionNonce\": 1092221653,\n      \"isDeleted\": false,\n      \"id\": \"-xogFSFcP-Vv5cuOSFm8T\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 578.8589212587622,\n      \"y\": 784.4318098510787,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 63.37890625,\n      \"height\": 23,\n      \"seed\": 1840462549,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"2. Step\",\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"middle\",\n      \"containerId\": \"XVGBz_X5yN6xjWTosVH2n\",\n      \"originalText\": \"2. Step\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 125,\n      \"versionNonce\": 1310578741,\n      \"isDeleted\": false,\n      \"id\": \"N1a9yL7Pts16hUKY9-vhw\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 424.78852030984035,\n      \"y\": 646.2446482189896,\n      \"strokeColor\": \"#be4bdb\",\n      \"backgroundColor\": \"#a5d8ff\",\n      \"width\": 133.857421875,\n      \"height\": 23,\n      \"seed\": 361699381,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Workflow \\\"test\\\"\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Workflow \\\"test\\\"\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 18\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 184,\n      \"versionNonce\": 2127603131,\n      \"isDeleted\": false,\n      \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 737.454940151797,\n      \"y\": 535.9141784615474,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 190.41665096887027,\n      \"height\": 112.96427727851824,\n      \"seed\": 80234901,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": null,\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.8392895251910331,\n        \"gap\": 2.0300115262207328\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          190.41665096887027,\n          112.96427727851824\n        ]\n      ]\n    },\n    {\n      \"type\": \"arrow\",\n      \"version\": 327,\n      \"versionNonce\": 780710651,\n      \"isDeleted\": false,\n      \"id\": \"379hO6Dc5rygB38JgDbVo\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 738.8084877231549,\n      \"y\": 591.3526691276127,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 186.8066399682357,\n      \"height\": 57.68023784868956,\n      \"seed\": 211046133,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": {\n        \"type\": 2\n      },\n      \"boundElements\": [],\n      \"updated\": 1697530083624,\n      \"link\": null,\n      \"locked\": false,\n      \"startBinding\": {\n        \"elementId\": \"2WwuMWX7YawqK0i1rDPJo\",\n        \"focus\": -0.5776522830934517,\n        \"gap\": 2.1657966147995467\n      },\n      \"endBinding\": {\n        \"elementId\": \"0TjxOfERekC91N3yciQIq\",\n        \"focus\": -0.7269489945238884,\n        \"gap\": 4.286474955497397\n      },\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": \"triangle\",\n      \"endArrowhead\": null,\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          186.8066399682357,\n          57.68023784868956\n        ]\n      ]\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 285,\n      \"versionNonce\": 1165977685,\n      \"isDeleted\": false,\n      \"id\": \"0TjxOfERekC91N3yciQIq\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 4,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 0,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 929.901602646888,\n      \"y\": 632.4760859429873,\n      \"strokeColor\": \"#2f9e44\",\n      \"backgroundColor\": \"#b2f2bb\",\n      \"width\": 518.076171875,\n      \"height\": 46,\n      \"seed\": 997763157,\n      \"groupIds\": [],\n      \"frameId\": null,\n      \"roundness\": null,\n      \"boundElements\": [\n        {\n          \"id\": \"O-YmtRLb8uFNqCAz22EoG\",\n          \"type\": \"arrow\"\n        },\n        {\n          \"id\": \"379hO6Dc5rygB38JgDbVo\",\n          \"type\": \"arrow\"\n        }\n      ],\n      \"updated\": 1697530083427,\n      \"link\": null,\n      \"locked\": false,\n      \"fontSize\": 20,\n      \"fontFamily\": 2,\n      \"text\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\",\n      \"containerId\": null,\n      \"originalText\": \"Additional steps are used to execute commands or plugins\\nlike `make install` or release-to-github\",\n      \"lineHeight\": 1.15,\n      \"baseline\": 41\n    }\n  ],\n  \"appState\": {\n    \"gridSize\": null,\n    \"viewBackgroundColor\": \"#ffffff\"\n  },\n  \"files\": {}\n}\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/20-workflow-syntax.md",
    "content": "# Workflow syntax\n\nThe Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status.\n\n:::note\nAn exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run.\n:::\n\n:::note\nWe support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility.\nRead more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3)\n:::\n\nExample steps:\n\n```yaml\nsteps:\n  - name: backend\n    image: golang\n    commands:\n      - go build\n      - go test\n  - name: frontend\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\nIn the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary.\n\nThe name is optional, if not added the steps will be numerated.\n\nAnother way to name a step is by using dictionaries:\n\n```yaml\nsteps:\n  backend:\n    image: golang\n    commands:\n      - go build\n      - go test\n  frontend:\n    image: node\n    commands:\n      - npm install\n      - npm run test\n      - npm run build\n```\n\n## Skip Commits\n\nWoodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive.\n\n```bash\ngit commit -m \"updated README [CI SKIP]\"\n```\n\n## Steps\n\nEvery step of your workflow executes commands inside a specified container.<br>\nThe defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).<br>\nThe associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\n### File changes are incremental\n\n- Woodpecker clones the source code in the beginning of the workflow\n- Changes to files are persisted through steps as the same volume is mounted to all steps\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: debian\n    commands:\n      - echo \"test content\" > myfile\n  - name: a-test-step\n    image: debian\n    commands:\n      - cat myfile\n```\n\n### `image`\n\nWoodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers.\n\nWhen using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands.\n\n```diff\n steps:\n   - name: build\n+    image: golang:1.6\n     commands:\n       - go build\n       - go test\n\n   - name: prettier\n+    image: woodpeckerci/plugin-prettier\n\n services:\n   - name: database\n+    image: mysql\n```\n\nWoodpecker supports any valid Docker image from any Docker registry:\n\n```yaml\nimage: golang\nimage: golang:1.7\nimage: library/golang:1.7\nimage: index.docker.io/library/golang\nimage: index.docker.io/library/golang:1.7\n```\n\nLearn more how you can use images from [different registries](./41-registries.md).\n\n### `pull`\n\nBy default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present.\n\nTo always pull the latest image when updates are available, use the `pull` option:\n\n```diff\n steps:\n   - name: build\n     image: golang:latest\n+    pull: true\n```\n\n### `commands`\n\nCommands of every step are executed serially as if you would enter them into your local shell.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n+      - go build\n+      - go test\n```\n\nThere is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script:\n\n```bash\n#!/bin/sh\nset -e\n\ngo build\ngo test\n```\n\nThe above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed:\n\n```bash\ndocker run --entrypoint=build.sh golang\n```\n\n:::note\nOnly build steps can define commands. You cannot use commands with plugins or services.\n:::\n\n### `entrypoint`\n\nAllows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `[\"/bin/sh\", \"-c\"]`).\n\nIf you define [`commands`](#commands), the default entrypoint will be `[\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"]`.\nYou can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`.\n\n### `environment`\n\nWoodpecker provides the ability to pass environment variables to individual steps.\n\nFor more details, check the [environment docs](./50-environment.md).\n\n### `failure`\n\nSome of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow.\n\n```diff\n steps:\n   - name: backend\n     image: golang\n     commands:\n       - go build\n       - go test\n+    failure: ignore\n```\n\nIf you would like to cancel the full pipeline once the step fails, you can set `failure: cancel`.\n\n### `when` - Conditional Execution\n\nWoodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true.\nA condition can be a check like:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - event: pull_request\n+        repo: test/test\n+      - event: push\n+        branch: main\n```\n\nThe `prettier` step is executed if one of these conditions is met:\n\n1. The pipeline is executed from a pull request in the repo `test/test`\n2. The pipeline is executed from a push to `main`\n\n#### `repo`\n\nExample conditional execution by repository:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - repo: test/test\n```\n\n#### `branch`\n\n:::note\nBranch conditions are not applied to tags.\n:::\n\nExample conditional execution by branch:\n\n```diff\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n+    when:\n+      - branch: main\n```\n\n> The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only.\n\nExecute a step if the branch is `main` or `develop`:\n\n```yaml\nwhen:\n  - branch: [main, develop]\n```\n\nExecute a step if the branch starts with `prefix/*`:\n\n```yaml\nwhen:\n  - branch: prefix/*\n```\n\nThe branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples:\n\n- `*\\\\/*` to match patterns with exactly 1 `/`\n- `*\\\\/**` to match patters with at least 1 `/`\n- `*` to match patterns without `/`\n- `**` to match everything\n\nExecute a step using custom include and exclude logic:\n\n```yaml\nwhen:\n  - branch:\n      include: [main, release/*]\n      exclude: [release/1.0.0, release/1.1.*]\n```\n\n#### `event`\n\nThe available events are:\n\n- `push`: triggered when a commit is pushed to a branch.\n- `pull_request`: triggered when a pull request is opened or a new commit is pushed to it.\n- `pull_request_closed`: triggered when a pull request is closed or merged.\n- `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...).\n- `tag`: triggered when a tag is pushed.\n- `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).)\n- `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.)\n- `cron`: triggered when a cron job is executed.\n- `manual`: triggered when a user manually triggers a pipeline.\n\nExecute a step if the build event is a `tag`:\n\n```yaml\nwhen:\n  - event: tag\n```\n\nExecute a step if the pipeline event is a `push` to a specified branch:\n\n```diff\nwhen:\n  - event: push\n+   branch: main\n```\n\nExecute a step for multiple events:\n\n```yaml\nwhen:\n  - event: [push, tag, deployment]\n```\n\n#### `cron`\n\nThis filter **only** applies to cron events and filters based on the name of a cron job.\n\nMake sure to have a `event: cron` condition in the `when`-filters as well.\n\n```yaml\nwhen:\n  - event: cron\n    cron: sync_* # name of your cron job\n```\n\n[Read more about cron](./45-cron.md)\n\n#### `ref`\n\nThe `ref` filter compares the git reference against which the workflow is executed.\nThis allows you to filter, for example, tags that must start with **v**:\n\n```yaml\nwhen:\n  - event: tag\n    ref: refs/tags/v*\n```\n\n#### `status`\n\nBy default, steps only run when the workflow has succeeded up to that point,<br>\nwhich is equivalent to `status: [ success ]`.\n\nThe `status` filter lets you override this behavior.\nThe only accepted values are `success` and `failure`.\n\nA common use case is executing a step on failure, such as sending notifications for a failed workflow/pipeline.\nTo run a step regardless of outcome, list both values:\n\n```diff\n steps:\n   - name: notify\n     image: alpine\n+    when:\n+      - status: [ success, failure ]\n```\n\nThe filter is aware of the other filters. If you want to run on failures if the event is `tag`, but if it's a `pull_request`, run it on both success and failure:\n\n```diff\n when:\n+  - event: tag\n+    status: [ failure ]\n+  - event: pull_request\n+    status: [ success, failure ]\n```\n\nIf there's no matching filter at all or all matching filters don't have set `status`, it will use the default, which means it runs on success only. In the example above this will happen if the event is neither `tag` nor `pull_request`.\n\n#### `platform`\n\n:::note\nThis condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch.\n:::\n\nExecute a step for a specific platform:\n\n```yaml\nwhen:\n  - platform: linux/amd64\n```\n\nExecute a step for a specific platform using wildcards:\n\n```yaml\nwhen:\n  - platform: [linux/*, windows/amd64]\n```\n\n#### `matrix`\n\nExecute a step for a single matrix permutation:\n\n```yaml\nwhen:\n  - matrix:\n      GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n```\n\n#### `instance`\n\nExecute a step only on a certain Woodpecker instance matching the specified hostname:\n\n```yaml\nwhen:\n  - instance: stage.woodpecker.company.com\n```\n\n#### `path`\n\n:::info\nPath conditions are applied only to **push** and **pull_request** events.\n:::\n\nExecute a step only on a pipeline with certain files being changed:\n\n```yaml\nwhen:\n  - path: 'src/*'\n```\n\nYou can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`.\n\nFor pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases.\n\n```yaml\nwhen:\n  - path:\n      include: ['.woodpecker/*.yaml', '*.ini']\n      exclude: ['*.md', 'docs/**']\n      ignore_message: '[ALL]'\n      on_empty: true\n```\n\n:::info\nPassing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting.\n:::\n\n#### `evaluate`\n\nExecute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression.\n\nThe expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library.\n\nRun on pushes to the default branch for the repository `owner/repo`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH'\n```\n\nRun on commits created by user `woodpecker-ci`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n```\n\nSkip all commits containing `please ignore me` in the commit message:\n\n```yaml\nwhen:\n  - evaluate: 'not (CI_COMMIT_MESSAGE contains \"please ignore me\")'\n```\n\nRun on pull requests with the label `deploy`:\n\n```yaml\nwhen:\n  - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains \"deploy\"'\n```\n\nSkip step only if `SKIP=true`, run otherwise or if undefined:\n\n```yaml\nwhen:\n  - evaluate: 'SKIP != \"true\"'\n```\n\n### `depends_on`\n\nNormally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`:\n\n```diff\n steps:\n   - name: build # build will be executed immediately\n     image: golang\n     commands:\n       - go build\n\n   - name: deploy\n     image: woodpeckerci/plugin-s3\n     settings:\n       bucket: my-bucket-name\n       source: some-file-name\n       target: /target/some-file\n+    depends_on: [build, test] # deploy will be executed after build and test finished\n\n   - name: test # test will be executed immediately as no dependencies are set\n     image: golang\n     commands:\n       - go test\n```\n\n:::note\nYou can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified.\n\n```yaml\nsteps:\n  - name: check code format\n    image: mstruebing/editorconfig-checker\n    depends_on: [] # enable parallel steps\n  ...\n```\n\n:::\n\n### `volumes`\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\nFor more details check the [volumes docs](./70-volumes.md).\n\n### `detach`\n\nWoodpecker gives the ability to detach steps to run them in background until the workflow finishes.\n\nFor more details check the [service docs](./60-services.md#detachment).\n\n### `directory`\n\nUsing `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run.\n\n### `backend_options`\n\nWith `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes.\n\nFurther details can be found in the documentation of the used backend:\n\n- [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration)\n- [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration)\n\n## `services`\n\nWoodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow.\n\nFor more details check the [services docs](./60-services.md).\n\n## `workspace`\n\nThe workspace defines the shared volume and working directory shared by all workflow steps.\nThe default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`).\nSo an example would be `/woodpecker/src/github.com/octocat/hello-world`.\n\nThe workspace can be customized using the workspace block in the YAML file:\n\n```diff\n+workspace:\n+  base: /go\n+  path: src/github.com/octocat/hello-world\n\n steps:\n   - name: build\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n```\n\n:::note\nPlugins will always have the workspace base at `/woodpecker`\n:::\n\nThe base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps.\n\n```diff\n workspace:\n+  base: /go\n   path: src/github.com/octocat/hello-world\n\n steps:\n   - name: deps\n     image: golang:latest\n     commands:\n       - go get\n       - go test\n   - name: build\n     image: node:latest\n     commands:\n       - go build\n```\n\nThis would be equivalent to the following docker commands:\n\n```bash\ndocker volume create my-named-volume\n\ndocker run --volume=my-named-volume:/go golang:latest\ndocker run --volume=my-named-volume:/go node:latest\n```\n\nThe path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path.\n\n```diff\n workspace:\n   base: /go\n+  path: src/github.com/octocat/hello-world\n```\n\n```bash\ngit clone https://github.com/octocat/hello-world \\\n  /go/src/github.com/octocat/hello-world\n```\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `matrix`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations.\n\nFor more details check the [matrix build docs](./30-matrix-workflows.md).\n\n## `labels`\n\nYou can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent.\n\nTo specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo.\n\nWorkflow labels with an empty value are ignored.\nBy default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`.\n\n:::warning\nLabels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition.\n:::\n\nYou can add additional labels as a key value map:\n\n```diff\n+labels:\n+  location: europe # only agents with `location=europe` or `location=*` will be used\n+  weather: sun\n+  hostname: \"\" # this label will be ignored as it is empty\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\n### Filter by platform\n\nTo configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key.\nHave a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`.\n\nExample:\n\nAssuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`.\n\n```diff\n+labels:\n+  platform: linux/arm64\n\n steps:\n   [...]\n```\n\n## `variables`\n\nWoodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration.\n\nFor more details and examples check the [Advanced usage docs](./90-advanced-usage.md)\n\n## `clone`\n\nWoodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step.\n\nYou can manually configure the clone step in your workflow to customize it:\n\n```diff\n+clone:\n+  git:\n+    image: woodpeckerci/plugin-git\n\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n```\n\nExample configuration to override the depth:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n+    settings:\n+      partial: false\n+      depth: 50\n```\n\nExample configuration to use a custom clone plugin:\n\n```diff\n clone:\n   - name: git\n+    image: octocat/custom-git-plugin\n```\n\n### Git Submodules\n\nTo use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`:\n\n```diff\n [submodule \"my-module\"]\n path = my-module\n-url = git@github.com:octocat/my-module.git\n+url = https://github.com/octocat/my-module.git\n```\n\nTo use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`:\n\n```diff\n clone:\n   - name: git\n     image: woodpeckerci/plugin-git\n     settings:\n       recursive: true\n+      submodule_override:\n+        my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  ...\n```\n\n## `skip_clone`\n\n:::warning\nThe default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`.\n:::\n\nBy default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using:\n\n```yaml\nskip_clone: true\n```\n\n## `when` - Global workflow conditions\n\nWoodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue.\n\nFor more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution).\n\nExample conditional execution by branch:\n\n```diff\n+when:\n+  branch: main\n+\n steps:\n   - name: prettier\n     image: woodpeckerci/plugin-prettier\n```\n\nThe workflow now triggers on `main`, but also if the target branch of a pull request is `main`.\n\n<!-- markdownlint-disable no-duplicate-heading -->\n\n## `depends_on`\n\n<!-- markdownlint-enable no-duplicate-heading -->\n\nWoodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword.\n\n## Advanced network options for steps\n\n:::warning\nOnly allowed if 'Trusted Network' option is enabled in repo settings by an admin.\n:::\n\n### `dns`\n\nIf the backend engine understands to change the DNS server and lookup domain,\nthis options will be used to alter the default DNS config to a custom one for a specific step.\n\n```yaml\nsteps:\n  - name: build\n    image: plugin/abc\n    dns: 1.2.3.4\n    dns_search: 'internal.company'\n```\n\n## Privileged mode\n\nWoodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities.\n\n:::info\nPrivileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     environment:\n       - DOCKER_HOST=tcp://docker:2375\n     commands:\n       - docker --tls=false ps\n\n services:\n   - name: docker\n     image: docker:dind\n     commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false\n+    privileged: true\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/25-workflows.md",
    "content": "# Workflows\n\nA pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps.\n\nIn case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow.\n\nBy placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored.\n\nYou can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md).\n\n## Benefits of using workflows\n\n- faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote\n- better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying\n- utilizing more agents to speed up the execution of the whole pipeline\n\n## Example workflow definition\n\n:::warning\nPlease note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow.\nIf you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket).\n:::\n\n```bash\n.woodpecker/\n├── build.yaml\n├── deploy.yaml\n├── lint.yaml\n└── test.yaml\n```\n\n```yaml title=\".woodpecker/build.yaml\"\nsteps:\n  - name: build\n    image: debian:stable-slim\n    commands:\n      - echo building\n      - sleep 5\n```\n\n```yaml title=\".woodpecker/deploy.yaml\"\nsteps:\n  - name: deploy\n    image: debian:stable-slim\n    commands:\n      - echo deploying\n\ndepends_on:\n  - lint\n  - build\n  - test\n```\n\n```yaml title=\".woodpecker/test.yaml\"\nsteps:\n  - name: test\n    image: debian:stable-slim\n    commands:\n      - echo testing\n      - sleep 5\n\ndepends_on:\n  - build\n```\n\n```yaml title=\".woodpecker/lint.yaml\"\nsteps:\n  - name: lint\n    image: debian:stable-slim\n    commands:\n      - echo linting\n      - sleep 5\n```\n\n## Status lines\n\nEach workflow will report its own status back to your forge.\n\n## Flow control\n\nThe workflows run in parallel on separate agents and share nothing.\n\nDependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully.\n\nThe name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`.\n\n```diff\n steps:\n   - name: deploy\n     image: debian:stable-slim\n     commands:\n       - echo deploying\n\n+depends_on:\n+  - lint\n+  - build\n+  - test\n```\n\nWorkflows that need to run even on failures should set the `status` filter.\n\n```diff\n steps:\n   - name: notify\n     image: debian:stable-slim\n     commands:\n       - echo notifying\n\n depends_on:\n   - deploy\n\n+when:\n+  - status: [ success, failure ]\n```\n\nThis works just like the [`status` filter for steps](./20-workflow-syntax.md#status).\n\n:::info\nSome workflows don't need the source code, like creating a notification on failure.\nRead more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone)\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/30-matrix-workflows.md",
    "content": "# Matrix workflows\n\nWoodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations.\n\n:::warning\nWoodpecker currently supports a maximum of **27 matrix axes** per workflow.\nIf your matrix exceeds this number, any additional axes will be silently ignored.\n:::\n\nExample matrix definition:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  REDIS_VERSION:\n    - 2.6\n    - 2.8\n    - 3.0\n```\n\nExample matrix definition containing only specific combinations:\n\n```yaml\nmatrix:\n  include:\n    - GO_VERSION: 1.4\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.5\n      REDIS_VERSION: 2.8\n    - GO_VERSION: 1.6\n      REDIS_VERSION: 3.0\n```\n\n## Interpolation\n\nMatrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters:\n\n```yaml\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:8\n    - mysql:5\n    - mariadb:10.1\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go get\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: ${DATABASE}\n```\n\nExample YAML file after injecting the matrix parameters:\n\n```diff\n steps:\n   - name: build\n-    image: golang:${GO_VERSION}\n+    image: golang:1.4\n     commands:\n       - go get\n       - go build\n       - go test\n+    environment:\n+      - GO_VERSION=1.4\n+      - DATABASE=mysql:8\n\n services:\n   - name: database\n-    image: ${DATABASE}\n+    image: mysql:8\n```\n\n## Examples\n\n### Example matrix pipeline based on Docker image tag\n\n```yaml\nmatrix:\n  TAG:\n    - 1.7\n    - 1.8\n    - latest\n\nsteps:\n  - name: build\n    image: golang:${TAG}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline based on container image\n\n```yaml\nmatrix:\n  IMAGE:\n    - golang:1.7\n    - golang:1.8\n    - golang:latest\n\nsteps:\n  - name: build\n    image: ${IMAGE}\n    commands:\n      - go build\n      - go test\n```\n\n### Example matrix pipeline using multiple platforms\n\n```yaml\nmatrix:\n  platform:\n    - linux/amd64\n    - linux/arm64\n\nlabels:\n  platform: ${platform}\n\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n\n  - name: test-arm-only\n    image: alpine\n    commands:\n      - echo \"I am running on ${platform}\"\n      - echo \"Arm is cool!\"\n    when:\n      platform: linux/arm*\n```\n\n:::note\nIf you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector).\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/40-secrets.md",
    "content": "# Secrets\n\nWoodpecker provides the ability to store named variables in a central secret store.\nThese secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`.\n\nThere are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins):\n\n1. **Repository secrets**: Available for all pipelines of a repository.\n1. **Organization secrets**: Available for all pipelines of an organization.\n1. **Global secrets**: Can only be set by instance administrators.\n   Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution.\n\nIn addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources.\n\n:::warning\nWoodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs.\n:::\n\n## Usage\n\nYou can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax.\n\nThe following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`:\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n     commands:\n+      - echo \"The secret is $TOKEN_ENV\"\n+    environment:\n+      TOKEN_ENV:\n+        from_secret: secret_token\n```\n\nThe same syntax can be used to pass secrets to (plugin) settings.\nA secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details).\n`PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution.\n\n```diff\n steps:\n   - name: 'step name'\n     image: registry/repo/image:tag\n+    settings:\n+      TOKEN:\n+        from_secret: secret_token\n```\n\n### Escape secrets\n\nPlease note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts.\nIf secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing.\n\n```diff\n steps:\n   - name: docker\n     image: docker\n     commands:\n-      - echo ${TOKEN_ENV}\n+      - echo $${TOKEN_ENV}\n     environment:\n       TOKEN_ENV:\n         from_secret: secret_token\n```\n\n### Events filter\n\nBy default, secrets are not exposed to pull requests.\nHowever, you can change this behavior by creating the secret and enabling the `pull_request` event type.\nThis can be configured either via the UI or via the CLI.\n\n:::warning\nBe careful when exposing secrets for pull requests.\nIf your repository is public and accepts pull requests from everyone, your secrets may be at risk.\nMalicious actors could take advantage of this to expose your secrets or transfer them to an external location.\n:::\n\n### Plugins filter\n\nTo prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins.\nIf enabled, they are not available to any other plugins.\nPlugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets.\n\n:::tip\nIf you specify a tag, the filter will take it into account.\nHowever, if the same image appears several times in the list, the least privileged entry will take precedence.\nFor example, an image without a tag will allow all tags, even if it contains another entry with a tag attached.\n:::\n\n![plugins filter](./secrets-plugins-filter.png)\n\n## CLI\n\nIn addition to the UI, secrets can also be managed using the CLI.\n\nCreate the secret with the default settings.\nThe secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events).\n\n```bash\nwoodpecker-cli repo secret add \\\n  --repository octocat/hello-world \\\n  --name aws_access_key_id \\\n  --value <value>\n```\n\nCreate the secret and limit it to a single image:\n\n```diff\n woodpecker-cli secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secrets and limit it to a set of images:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n+  --image woodpeckerci/plugin-s3 \\\n+  --image woodpeckerci/plugin-docker-buildx \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nCreate the secret and enable it for multiple hook events:\n\n```diff\n woodpecker-cli repo secret add \\\n   --repository octocat/hello-world \\\n   --image woodpeckerci/plugin-s3 \\\n+  --event pull_request \\\n+  --event push \\\n+  --event tag \\\n   --name aws_access_key_id \\\n   --value <value>\n```\n\nSecrets can be loaded from a file using the syntax `@`.\nThis method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example):\n\n```diff\n woodpecker-cli repo secret add \\\n   -repository octocat/hello-world \\\n   -name ssh_key \\\n+  -value @/root/ssh/id_rsa\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/41-registries.md",
    "content": "# Registries\n\nWoodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries.\n\n## Images from private registries\n\nYou must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file.\n\nThese credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin.\n\nExample configuration using a private image:\n\n```diff\n steps:\n   - name: build\n+    image: gcr.io/custom/golang\n     commands:\n       - go build\n       - go test\n```\n\nWoodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers.\n\nExample registry hostnames:\n\n- Image `gcr.io/foo/bar` has hostname `gcr.io`\n- Image `foo/bar` has hostname `docker.io`\n- Image `qux.com:8000/foo/bar` has hostname `qux.com:8000`\n\nExample registry hostname matching logic:\n\n- Hostname `gcr.io` matches image `gcr.io/foo/bar`\n- Hostname `docker.io` matches `golang`\n- Hostname `docker.io` matches `library/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang`\n- Hostname `docker.io` matches `bradrydzewski/golang:latest`\n\n## Global registry support\n\nTo make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config).\n\n## GCR registry support\n\nFor specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file).\n\n## Local Images\n\n:::warning\nFor this, privileged rights are needed only available to admins. In addition, this only works when using a single agent.\n:::\n\nIt's possible to build a local image by mounting the docker socket as a volume.\n\nWith a `Dockerfile` at the root of the project:\n\n```yaml\nsteps:\n  - name: build-image\n    image: docker\n    commands:\n      - docker build --rm -t local/project-image .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  - name: build-project\n    image: local/project-image\n    commands:\n      - ./build.sh\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/45-cron.md",
    "content": "# Cron\n\nTo configure cron jobs you need at least push access to the repository.\n\n## Add a new cron job\n\n1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job:\n\n   ```diff\n    steps:\n      - name: sync_locales\n        image: weblate_sync\n        settings:\n          url: example.com\n          token:\n            from_secret: weblate_token\n   +    when:\n   +      event: cron\n   +      cron: \"name of the cron job\" # if you only want to execute this step by a specific cron job\n   ```\n\n2. Create a new cron job in the repository settings:\n\n   ![cron settings](./cron-settings.png)\n\n   The supported schedule syntax can be found at <https://pkg.go.dev/github.com/gdgvda/cron#hdr-CRON_Expression_Format>. If you need general understanding of the cron syntax <https://it-tools.tech/crontab-generator> is a good place to start and experiment.\n\n   Examples: `@every 5m`, `@daily`, `30 * * * *` ...\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/50-environment.md",
    "content": "# Environment variables\n\nWoodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables:\n\n```diff\n steps:\n   - name: build\n     image: golang\n+    environment:\n+      CGO: 0\n+      GOOS: linux\n+      GOARCH: amd64\n     commands:\n       - go build\n       - go test\n```\n\nPlease note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section.\n\n```diff\n steps:\n   - name: build\n     image: golang\n-    environment:\n-      - PATH=$PATH:/go\n     commands:\n+      - export PATH=$PATH:/go\n       - go build\n       - go test\n```\n\n:::warning\n`${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped:\n:::\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n-      - export PATH=${PATH}:/go\n+      - export PATH=$${PATH}:/go\n       - go build\n       - go test\n```\n\n## Built-in environment variables\n\nThis is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime.\n\n| NAME                               | Description                                                                                                        | Example                                                                                                    |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |\n| `CI`                               | CI environment name                                                                                                | `woodpecker`                                                                                               |\n|                                    | **Repository**                                                                                                     |                                                                                                            |\n| `CI_REPO`                          | repository full name `<owner>/<name>`                                                                              | `john-doe/my-repo`                                                                                         |\n| `CI_REPO_OWNER`                    | repository owner                                                                                                   | `john-doe`                                                                                                 |\n| `CI_REPO_NAME`                     | repository name                                                                                                    | `my-repo`                                                                                                  |\n| `CI_REPO_REMOTE_ID`                | repository remote ID, is the UID it has in the forge                                                               | `82`                                                                                                       |\n| `CI_REPO_URL`                      | repository web URL                                                                                                 | `https://git.example.com/john-doe/my-repo`                                                                 |\n| `CI_REPO_CLONE_URL`                | repository clone URL                                                                                               | `https://git.example.com/john-doe/my-repo.git`                                                             |\n| `CI_REPO_CLONE_SSH_URL`            | repository SSH clone URL                                                                                           | `git@git.example.com:john-doe/my-repo.git`                                                                 |\n| `CI_REPO_DEFAULT_BRANCH`           | repository default branch                                                                                          | `main`                                                                                                     |\n| `CI_REPO_PRIVATE`                  | repository is private                                                                                              | `true`                                                                                                     |\n| `CI_REPO_TRUSTED_NETWORK`          | repository has trusted network access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_VOLUMES`          | repository has trusted volumes access                                                                              | `false`                                                                                                    |\n| `CI_REPO_TRUSTED_SECURITY`         | repository has trusted security access                                                                             | `false`                                                                                                    |\n|                                    | **Current Commit**                                                                                                 |                                                                                                            |\n| `CI_COMMIT_SHA`                    | commit SHA                                                                                                         | `eba09b46064473a1d345da7abf28b477468e8dbd`                                                                 |\n| `CI_COMMIT_REF`                    | commit ref                                                                                                         | `refs/heads/main`                                                                                          |\n| `CI_COMMIT_REFSPEC`                | commit ref spec                                                                                                    | `issue-branch:main`                                                                                        |\n| `CI_COMMIT_BRANCH`                 | commit branch (equals target branch for pull requests)                                                             | `main`                                                                                                     |\n| `CI_COMMIT_SOURCE_BRANCH`          | commit source branch (set only for pull request events)                                                            | `issue-branch`                                                                                             |\n| `CI_COMMIT_TARGET_BRANCH`          | commit target branch (set only for pull request events)                                                            | `main`                                                                                                     |\n| `CI_COMMIT_TAG`                    | commit tag name (empty if event is not `tag`)                                                                      | `v1.10.3`                                                                                                  |\n| `CI_COMMIT_PULL_REQUEST`           | commit pull request number (set only for pull request events)                                                      | `1`                                                                                                        |\n| `CI_COMMIT_PULL_REQUEST_LABELS`    | labels assigned to pull request (set only for pull request events)                                                 | `server`                                                                                                   |\n| `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events)                  | `summer-sprint`                                                                                            |\n| `CI_COMMIT_MESSAGE`                | commit message                                                                                                     | `Initial commit`                                                                                           |\n| `CI_COMMIT_AUTHOR`                 | commit author username                                                                                             | `john-doe`                                                                                                 |\n| `CI_COMMIT_AUTHOR_EMAIL`           | commit author email address                                                                                        | `john-doe@example.com`                                                                                     |\n| `CI_COMMIT_PRERELEASE`             | release is a pre-release (empty if event is not `release`)                                                         | `false`                                                                                                    |\n|                                    | **Current pipeline**                                                                                               |                                                                                                            |\n| `CI_PIPELINE_NUMBER`               | pipeline number                                                                                                    | `8`                                                                                                        |\n| `CI_PIPELINE_PARENT`               | number of parent pipeline                                                                                          | `0`                                                                                                        |\n| `CI_PIPELINE_EVENT`                | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                            | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PIPELINE_EVENT_REASON`         | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change              | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PIPELINE_URL`                  | link to the web UI for the pipeline                                                                                | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n| `CI_PIPELINE_FORGE_URL`            | link to the forge's web UI for the commit(s) or tag that triggered the pipeline                                    | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd`                 |\n| `CI_PIPELINE_DEPLOY_TARGET`        | pipeline deploy target for `deployment` events                                                                     | `production`                                                                                               |\n| `CI_PIPELINE_DEPLOY_TASK`          | pipeline deploy task for `deployment` events                                                                       | `migration`                                                                                                |\n| `CI_PIPELINE_CREATED`              | pipeline created UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_STARTED`              | pipeline started UNIX timestamp                                                                                    | `1722617519`                                                                                               |\n| `CI_PIPELINE_FILES`                | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[\".woodpecker.yml\",\"README.md\"]`                                                                    |\n| `CI_PIPELINE_AUTHOR`               | pipeline author username                                                                                           | `octocat`                                                                                                  |\n| `CI_PIPELINE_AVATAR`               | pipeline author avatar                                                                                             | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | **Current workflow**                                                                                               |                                                                                                            |\n| `CI_WORKFLOW_NAME`                 | workflow name                                                                                                      | `release`                                                                                                  |\n|                                    | **Current step**                                                                                                   |                                                                                                            |\n| `CI_STEP_NAME`                     | step name                                                                                                          | `build package`                                                                                            |\n| `CI_STEP_NUMBER`                   | step number                                                                                                        | `0`                                                                                                        |\n| `CI_STEP_STARTED`                  | step started UNIX timestamp                                                                                        | `1722617519`                                                                                               |\n| `CI_STEP_URL`                      | URL to step in UI                                                                                                  | `https://ci.example.com/repos/7/pipeline/8`                                                                |\n|                                    | **Previous commit**                                                                                                |                                                                                                            |\n| `CI_PREV_COMMIT_SHA`               | previous commit SHA                                                                                                | `15784117e4e103f36cba75a9e29da48046eb82c4`                                                                 |\n| `CI_PREV_COMMIT_REF`               | previous commit ref                                                                                                | `refs/heads/main`                                                                                          |\n| `CI_PREV_COMMIT_REFSPEC`           | previous commit ref spec                                                                                           | `issue-branch:main`                                                                                        |\n| `CI_PREV_COMMIT_BRANCH`            | previous commit branch                                                                                             | `main`                                                                                                     |\n| `CI_PREV_COMMIT_SOURCE_BRANCH`     | previous commit source branch (set only for pull request events)                                                   | `issue-branch`                                                                                             |\n| `CI_PREV_COMMIT_TARGET_BRANCH`     | previous commit target branch (set only for pull request events)                                                   | `main`                                                                                                     |\n| `CI_PREV_COMMIT_URL`               | previous commit link in forge                                                                                      | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_COMMIT_MESSAGE`           | previous commit message                                                                                            | `test`                                                                                                     |\n| `CI_PREV_COMMIT_AUTHOR`            | previous commit author username                                                                                    | `john-doe`                                                                                                 |\n| `CI_PREV_COMMIT_AUTHOR_EMAIL`      | previous commit author email address                                                                               | `john-doe@example.com`                                                                                     |\n|                                    | **Previous pipeline**                                                                                              |                                                                                                            |\n| `CI_PREV_PIPELINE_NUMBER`          | previous pipeline number                                                                                           | `7`                                                                                                        |\n| `CI_PREV_PIPELINE_PARENT`          | previous pipeline number of parent pipeline                                                                        | `0`                                                                                                        |\n| `CI_PREV_PIPELINE_EVENT`           | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event))                                   | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` |\n| `CI_PREV_PIPELINE_EVENT_REASON`    | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change         | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ...                                   |\n| `CI_PREV_PIPELINE_URL`             | previous pipeline link in CI                                                                                       | `https://ci.example.com/repos/7/pipeline/7`                                                                |\n| `CI_PREV_PIPELINE_FORGE_URL`       | previous pipeline link to event in forge                                                                           | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4`                 |\n| `CI_PREV_PIPELINE_DEPLOY_TARGET`   | previous pipeline deploy target for `deployment` events                                                            | `production`                                                                                               |\n| `CI_PREV_PIPELINE_DEPLOY_TASK`     | previous pipeline deploy task for `deployment` events                                                              | `migration`                                                                                                |\n| `CI_PREV_PIPELINE_STATUS`          | previous pipeline status                                                                                           | `success`, `failure`                                                                                       |\n| `CI_PREV_PIPELINE_CREATED`         | previous pipeline created UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_STARTED`         | previous pipeline started UNIX timestamp                                                                           | `1722610173`                                                                                               |\n| `CI_PREV_PIPELINE_FINISHED`        | previous pipeline finished UNIX timestamp                                                                          | `1722610383`                                                                                               |\n| `CI_PREV_PIPELINE_AUTHOR`          | previous pipeline author username                                                                                  | `octocat`                                                                                                  |\n| `CI_PREV_PIPELINE_AVATAR`          | previous pipeline author avatar                                                                                    | `https://git.example.com/avatars/5dcbcadbce6f87f8abef`                                                     |\n|                                    | &emsp;                                                                                                             |                                                                                                            |\n| `CI_WORKSPACE`                     | Path of the workspace where source code gets cloned to                                                             | `/woodpecker/src/git.example.com/john-doe/my-repo`                                                         |\n|                                    | **System**                                                                                                         |                                                                                                            |\n| `CI_SYSTEM_NAME`                   | name of the CI system                                                                                              | `woodpecker`                                                                                               |\n| `CI_SYSTEM_URL`                    | link to CI system                                                                                                  | `https://ci.example.com`                                                                                   |\n| `CI_SYSTEM_HOST`                   | hostname of CI server                                                                                              | `ci.example.com`                                                                                           |\n| `CI_SYSTEM_VERSION`                | version of the server                                                                                              | `2.7.0`                                                                                                    |\n|                                    | **Forge**                                                                                                          |                                                                                                            |\n| `CI_FORGE_TYPE`                    | name of forge                                                                                                      | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab`                                   |\n| `CI_FORGE_URL`                     | root URL of configured forge                                                                                       | `https://git.example.com`                                                                                  |\n|                                    | **Internal** - Please don't use!                                                                                   |                                                                                                            |\n| `CI_SCRIPT`                        | Internal script path. Used to call pipeline step commands.                                                         |                                                                                                            |\n| `CI_NETRC_USERNAME`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_PASSWORD`                | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n| `CI_NETRC_MACHINE`                 | Credentials for private repos to be able to clone data. (Only available for specific images)                       |                                                                                                            |\n\n## Global environment variables\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nThese can be used, for example, to manage the image tag used by multiple projects.\n\n```ini\nWOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18\n```\n\n```diff\n steps:\n   - name: build\n-    image: golang:1.18\n+    image: golang:${GOLANG_VERSION}\n     commands:\n       - [...]\n```\n\n## String Substitution\n\nWoodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration.\n\nExample commit substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA}\n```\n\nExample tag substitution:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG}\n```\n\n## String Operations\n\nWoodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values.\n\n| OPERATION          | DESCRIPTION                                      |\n| ------------------ | ------------------------------------------------ |\n| `${param}`         | parameter substitution                           |\n| `${param,}`        | parameter substitution with lowercase first char |\n| `${param,,}`       | parameter substitution with lowercase            |\n| `${param^}`        | parameter substitution with uppercase first char |\n| `${param^^}`       | parameter substitution with uppercase            |\n| `${param:pos}`     | parameter substitution with substring            |\n| `${param:pos:len}` | parameter substitution with substring and length |\n| `${param=default}` | parameter substitution with default              |\n| `${param##prefix}` | parameter substitution with prefix removal       |\n| `${param%%suffix}` | parameter substitution with suffix removal       |\n| `${param/old/new}` | parameter substitution with find and replace     |\n\nExample variable substitution with substring:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_SHA:0:8}\n```\n\nExample variable substitution strips `v` prefix from `v.1.0.0`:\n\n```diff\n steps:\n   - name: s3\n     image: woodpeckerci/plugin-s3\n     settings:\n+      target: /target/${CI_COMMIT_TAG##v}\n```\n\n## `pull_request_metadata` specific event reason values\n\nFor the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`.\n\n**GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list.\n\n:::note\nEvent reason values are forge-specific and may change between versions.\n:::\n\n| Event                | GitHub             | Gitea              | Forgejo            | GitLab             | Bitbucket | Bitbucket Datacenter | Description                                                                    |\n| -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ |\n| `assigned`           | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was assigned to a user                                            |\n| `converted_to_draft` | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Pull request was converted to a draft                                          |\n| `demilestoned`       | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was removed from a milestone                                      |\n| `description_edited` | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Description edited                                                             |\n| `edited`             | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:                | :x:       | :x:                  | The title or body of a pull request was edited, or the base branch was changed |\n| `label_added`        | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Pull had no labels and now got label(s) added                                  |\n| `label_cleared`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | All labels removed                                                             |\n| `label_updated`      | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | New label(s) added / label(s) changed                                          |\n| `locked`             | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was locked                                      |\n| `milestoned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | Pull request was added to a milestone                                          |\n| `ready_for_review`   | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Draft pull request was marked as ready for review                              |\n| `review_requested`   | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | New review was requested                                                       |\n| `title_edited`       | :x:                | :x:                | :x:                | :white_check_mark: | :x:       | :x:                  | Title edited                                                                   |\n| `unassigned`         | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x:       | :x:                  | User was unassigned from a pull request                                        |\n| `unlabeled`          | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Label was removed from a pull request                                          |\n| `unlocked`           | :white_check_mark: | :x:                | :x:                | :x:                | :x:       | :x:                  | Conversation on a pull request was unlocked                                    |\n\n**Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/51-plugins/20-creating-plugins.md",
    "content": "# Creating plugins\n\nCreating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT.\n\n## Settings\n\nTo allow users to configure the behavior of your plugin, you should use `settings:`.\n\nThese are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix.\nUsing a setting like `url` results in an env var named `PLUGIN_URL`.\n\nCharacters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`.\nCamelCase is not respected, `anInt` get `PLUGIN_ANINT`. <!-- cspell:ignore ANINT -->\n\n### Basic settings\n\nUsing any basic YAML type (scalar) will be converted into a string:\n\n| Setting              | Environment value            |\n| -------------------- | ---------------------------- |\n| `some-bool: false`   | `PLUGIN_SOME_BOOL=\"false\"`   |\n| `some_String: hello` | `PLUGIN_SOME_STRING=\"hello\"` |\n| `anInt: 3`           | `PLUGIN_ANINT=\"3\"`           |\n\n### Complex settings\n\nIt's also possible to use complex settings like this:\n\n```yaml\nsteps:\n  - name: plugin\n    image: foo/plugin\n    settings:\n      complex:\n        abc: 2\n        list:\n          - 2\n          - 3\n```\n\nValues like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{\"abc\": \"2\", \"list\": [ \"2\", \"3\" ]}`.\n\n### Secrets\n\nSecrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage).\n\n## Plugin library\n\nFor Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See <https://codeberg.org/woodpecker-plugins/go-plugin>.\n\n## Metadata\n\nIn your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins).\n\nSupported metadata:\n\n- `name`: The plugin's full name\n- `icon`: URL to your plugin's icon\n- `description`: A short description of what it's doing\n- `author`: Your name\n- `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin)\n- `containerImage`: name of the container image\n- `containerImageUrl`: link to the container image\n- `url`: homepage or repository of your plugin\n\nIf you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required.\n\n## Example plugin\n\nThis provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline.\n\n### What end users will see\n\nThe below example demonstrates how we might configure a webhook plugin in the YAML file:\n\n```yaml\nsteps:\n  - name: webhook\n    image: foo/webhook\n    settings:\n      url: https://example.com\n      method: post\n      body: |\n        hello world\n```\n\n### Write the logic\n\nCreate a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`.\n\n```bash\n#!/bin/sh\n\ncurl \\\n  -X ${PLUGIN_METHOD} \\\n  -d ${PLUGIN_BODY} \\\n  ${PLUGIN_URL}\n```\n\n### Package it\n\nCreate a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint.\n\n```dockerfile\n# please pin the version, e.g. alpine:3.19\nFROM alpine\nADD script.sh /bin/\nRUN chmod +x /bin/script.sh\nRUN apk -Uuv add curl ca-certificates\nENTRYPOINT /bin/script.sh\n```\n\nBuild and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community.\n\n```shell\ndocker build -t foo/webhook .\ndocker push foo/webhook\n```\n\nExecute your plugin locally from the command line to verify it is working:\n\n```shell\ndocker run --rm \\\n  -e PLUGIN_METHOD=post \\\n  -e PLUGIN_URL=https://example.com \\\n  -e PLUGIN_BODY=\"hello world\" \\\n  foo/webhook\n```\n\n## Best practices\n\n- Build your plugin for different architectures to allow many users to use them.\n  At least, you should support `amd64` and `arm64`.\n- Provide binaries for users using the `local` backend.\n  These should also be built for different OS/architectures.\n- Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible.\n- Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names.\n- Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)).\n- Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/51-plugins/51-overview.md",
    "content": "# Plugins\n\nPlugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline.\nPlugins can be used to deploy code, publish artifacts, send notification, and more.\n\nThey are automatically pulled from the default container registry the agent's have configured.\n\n```dockerfile title=\"Dockerfile\"\nFROM cloud/kubectl\nCOPY deploy /usr/local/deploy\nENTRYPOINT [\"/usr/local/deploy\"]\n```\n\n```bash title=\"deploy\"\nkubectl apply -f $PLUGIN_TEMPLATE\n```\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: deploy-to-k8s\n    image: cloud/my-k8s-plugin\n    settings:\n      template: config/k8s/service.yaml\n```\n\nExample pipeline using the Prettier and S3 plugins:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  - name: prettier\n    image: woodpeckerci/plugin-prettier\n\n  - name: publish\n    image: woodpeckerci/plugin-s3\n    settings:\n      bucket: my-bucket-name\n      source: some-file-name\n      target: /target/some-file\n```\n\n## Plugin Isolation\n\nPlugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree.\nWhile normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author.\n\nThat's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically\nadjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands`\nor `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin\nanymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition.\n\n## Finding Plugins\n\nFor official plugins, you can use the Woodpecker plugin index:\n\n- [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins)\n\n:::tip\nThere are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking.\n\n- [Drone Plugins](http://plugins.drone.io)\n- [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/)\n- [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community)\n\n:::\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/51-plugins/_category_.yaml",
    "content": "label: 'Plugins'\n# position: 2\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/60-services.md",
    "content": "# Services\n\nWoodpecker provides a services section in the YAML file used for defining service containers.\nThe below configuration composes database and cache containers.\n\nServices are accessed using custom hostnames.\nIn the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`.\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n\n  - name: cache\n    image: redis\n```\n\nYou can define a port and a protocol explicitly:\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    ports:\n      - 3306\n\n  - name: wireguard\n    image: wg\n    ports:\n      - 51820/udp\n```\n\n## Stopping\n\nServices that are no longer needed receive a **SIGTERM** signal. If they do not respond, they are forcibly terminated with **SIGKILL**.\nIf there are services that do not shut down properly and this doesn't matter, you can simply ignore the error:\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    failure: ignore # we don't care how mysql exits\n     ports:\n       - 3306\n```\n\n## Configuration\n\nService containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more.\n\n```diff\n services:\n   - name: database\n     image: mysql\n+    environment:\n+      MYSQL_DATABASE: test\n+      MYSQL_ALLOW_EMPTY_PASSWORD: yes\n\n   - name: cache\n     image: redis\n```\n\n## Detachment\n\nService and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required.\n\n```diff\n steps:\n   - name: build\n     image: golang\n     commands:\n       - go build\n       - go test\n\n   - name: database\n     image: redis\n+    detach: true\n\n   - name: test\n     image: golang\n     commands:\n       - go test\n```\n\nContainers from detached steps will terminate when the pipeline ends.\n\n## Initialization\n\nService containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff.\n\n```diff\n steps:\n   - name: test\n     image: golang\n     commands:\n+      - sleep 15\n       - go get\n       - go test\n\n services:\n   - name: database\n     image: mysql\n```\n\n## Complete Pipeline Example\n\n```yaml\nservices:\n  - name: database\n    image: mysql\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ROOT_PASSWORD: example\nsteps:\n  - name: get-version\n    image: ubuntu\n    commands:\n      - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null\n      - sleep 30s # need to wait for mysql-server init\n      - echo 'SHOW VARIABLES LIKE \"version\"' | mysql -u root -h database test -p example\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/70-volumes.md",
    "content": "# Volumes\n\nWoodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers.\n\n:::note\nVolumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode.\n:::\n\n```diff\n steps:\n   - name: build\n     image: docker\n     commands:\n       - docker build --rm -t octocat/hello-world .\n       - docker run --rm octocat/hello-world --test\n       - docker push octocat/hello-world\n       - docker rmi octocat/hello-world\n     volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nIf you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`.\n\nPlease note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error.\n\n```diff\n-volumes: [ ./certs:/etc/ssl/certs ]\n+volumes: [ /etc/ssl/certs:/etc/ssl/certs ]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-extensions/40-configuration-extension.md",
    "content": "# Configuration extension\n\nThe configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n<!-- cSpell:words templating,Starlark,Jsonnet -->\n\n- Preprocess the original configuration file with something like Go templating\n- Convert custom attributes to Woodpecker attributes\n- Add defaults to the configuration like default steps\n- Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ...\n- Centralize configuration for multiple repositories in one place\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your configuration extension.\n\nThe global configuration will be called before the repository specific configuration extension if both are configured and the repository has not enabled the exclusive setting.\n\n```ini title=\"Server\"\nWOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used.\n\nYou can enable the exclusive setting (both globally and on a per-repo level). Then Woodpecker will only call your extension, but nothing else. This allows you to completely skip the forge. Requests sent to the extension will not have the configuration files added.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_CONFIG_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n  configuration?: {\n    // list of configurations. Not send if there was none.\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"configuration\": [\n    {\n      \"name\": \".woodpecker.yaml\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from Repo (.woodpecker.yaml)\\\"\\n\"\n    }\n  ],\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  configs: {\n    name: string; // filename of the configuration file\n    data: string; // content of the configuration file\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-extensions/50-registry-extension.md",
    "content": "# Registry extension\n\nWoodpecker uses the registry extension to get registry credentials. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n- Centralize registry credential management\n- Use an external storage for credentials\n- Dynamically manage which credentials Woodpecker should use\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security).\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your registry extension.\n\nIf both the global and the repo-level extension return credentials for a registry, it will use the credentials from the repo extension.\n\n```ini title=\"Server\"\nWOODPECKER_REGISTRY_EXTENSION_ENDPOINT=https://example.com/ciconfig\n```\n\n## How it works\n\nWhen a pipeline is triggered, Woodpecker will fetch the credentials from your service. As fallback, it uses the credentials configured directly in Woodpecker.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_REGISTRY_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n// Please check the latest structure in the models mentioned above.\n// This example is likely outdated.\n\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n```\n\n### Response\n\nThe extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format.\nIf the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  registries: {\n    address: string; // the docker registry address\n    username: string; // registry username\n    password: string; // registry password\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"registries\": [\n    {\n      \"address\": \"docker.io\",\n      \"username\": \"woodpecker-bot\",\n      \"password\": \"your-pass-word-123\"\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-extensions/55-secret-extension.md",
    "content": "# Secret extension\n\nWoodpecker uses the secret extension to get secrets from an external service. You can configure an HTTP endpoint in the repository settings in the extensions tab.\n\nUsing such an extension can be useful if you want to:\n\n- Centralize secret management (e.g. HashiCorp Vault, AWS Secrets Manager)\n- Dynamically generate secrets per pipeline\n\n## Security\n\n:::warning\nAs Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the security section.\n:::\n\n## Global configuration\n\nIn addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if\nyou share your Woodpecker server with others as they will also use your secret extension.\n\nIf both the global and the repo-level extension return a secret with the same name, it will use the secret from the repo extension.\n\n```ini title=\"Server\"\nWOODPECKER_SECRET_EXTENSION_ENDPOINT=https://example.com/secrets\nWOODPECKER_SECRET_EXTENSION_NETRC=false\n```\n\n## How it works\n\nWhen a pipeline is triggered, Woodpecker will fetch secrets from your service. The extension secrets are merged with the secrets configured directly in Woodpecker, with extension secrets taking priority by name. If the extension is unavailable, Woodpecker falls back to the locally configured secrets.\n\n### Request\n\nThe extension receives an HTTP POST request with the following JSON payload:\n\n:::info\nThe `netrc` field is only included in the request when the global `WOODPECKER_SECRET_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo \"Send netrc credentials\" is checked.\n:::\n\n```ts\nclass Request {\n  repo: Repo;\n  pipeline: Pipeline;\n  netrc?: Netrc; // only included when netrc sending is enabled (see above)\n}\n```\n\nCheckout the following models for more information:\n\n- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)\n- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)\n- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)\n\n:::tip\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.\n:::\n\nExample request:\n\n```json\n// Please check the latest structure in the models mentioned above.\n// This example is likely outdated.\n\n{\n  \"repo\": {\n    \"id\": 100,\n    \"uid\": \"\",\n    \"user_id\": 0,\n    \"namespace\": \"\",\n    \"name\": \"woodpecker-test-pipeline\",\n    \"slug\": \"\",\n    \"scm\": \"git\",\n    \"git_http_url\": \"\",\n    \"git_ssh_url\": \"\",\n    \"link\": \"\",\n    \"default_branch\": \"\",\n    \"private\": true,\n    \"visibility\": \"private\",\n    \"active\": true,\n    \"config\": \"\",\n    \"trusted\": false,\n    \"protected\": false,\n    \"ignore_forks\": false,\n    \"ignore_pulls\": false,\n    \"cancel_pulls\": false,\n    \"timeout\": 60,\n    \"counter\": 0,\n    \"synced\": 0,\n    \"created\": 0,\n    \"updated\": 0,\n    \"version\": 0\n  },\n  \"pipeline\": {\n    \"author\": \"myUser\",\n    \"author_avatar\": \"https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03\",\n    \"author_email\": \"my@email.com\",\n    \"branch\": \"main\",\n    \"changed_files\": [\"some-filename.txt\"],\n    \"commit\": \"2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"created_at\": 0,\n    \"deploy_to\": \"\",\n    \"enqueued_at\": 0,\n    \"error\": \"\",\n    \"event\": \"push\",\n    \"finished_at\": 0,\n    \"id\": 0,\n    \"link_url\": \"https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50\",\n    \"message\": \"test old config\\n\",\n    \"number\": 0,\n    \"parent\": 0,\n    \"ref\": \"refs/heads/main\",\n    \"refspec\": \"\",\n    \"clone_url\": \"\",\n    \"reviewed_at\": 0,\n    \"reviewed_by\": \"\",\n    \"sender\": \"myUser\",\n    \"signed\": false,\n    \"started_at\": 0,\n    \"status\": \"\",\n    \"timestamp\": 1645962783,\n    \"title\": \"\",\n    \"updated_at\": 0,\n    \"verified\": false\n  },\n  \"netrc\": {\n    \"machine\": \"myforge.com\",\n    \"login\": \"myUser\",\n    \"password\": \"forge-access-token\"\n  }\n}\n// Note: the \"netrc\" field is omitted when netrc sending is not enabled.\n```\n\n### Response\n\nThe extension should respond with a JSON object containing a `secrets` array.\nIf the extension wants to keep the existing secrets without adding any, it can respond with HTTP status `204 No Content`.\n\n```ts\nclass Response {\n  secrets: {\n    name: string; // the secret name, matched by from_secret in pipeline config\n    value: string; // the secret value\n    images?: string[]; // optional: restrict to specific plugins\n    events?: string[]; // optional: restrict to specific pipeline events\n  }[];\n}\n```\n\nExample response:\n\n```json\n{\n  \"secrets\": [\n    {\n      \"name\": \"docker_password\",\n      \"value\": \"your-secret-password-123\"\n    },\n    {\n      \"name\": \"deploy_token\",\n      \"value\": \"super-secret-token\",\n      \"events\": [\"push\", \"tag\"]\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-extensions/_category_.yaml",
    "content": "label: 'Extensions'\n# position: 3\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'index'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-extensions/index.md",
    "content": "# Extensions\n\nWoodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints.\n\nThere is currently one type of extension available:\n\n- [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly.\n- [Registry extension](./50-registry-extension.md) to get registry credentials from the extension.\n- [Secret extension](./55-secret-extension.md) to get secrets from an external service.\n\n## Security\n\n:::warning\nYou need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful\ndata like malicious pipeline configurations that could be executed.\n:::\n\nTo prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair.\nTo verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign).\nYou can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page.\n\n## Example extensions\n\nA simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions)\n\n## Configuration\n\nTo prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of:\n\n- Built-in networks:\n  - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.\n  - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.\n  - `external`: A valid non-private unicast IP, you can access all hosts on public internet.\n  - `*`: All hosts are allowed.\n- CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6\n- (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/72-linter.md",
    "content": "# Linter\n\nWoodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines.\n\n![errors and warnings in UI](./linter-warnings-errors.png)\n\n## Running the linter from CLI\n\nYou can run the linter also manually from the CLI:\n\n```shell\nwoodpecker-cli lint <workflow files>\n```\n\n## Bad habit warnings\n\nWoodpecker warns you if your configuration contains some bad habits.\n\n### Event filter for all steps\n\nAll your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well.\n\nExamples of an **incorrect** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n  - event: tag\n```\n\nThis will trigger the warning because the first item (`branch: main`) does not filter with an event.\n\n```yaml\nsteps:\n  - name: test\n    when:\n      branch: main\n\n  - name: deploy\n    when:\n      event: tag\n```\n\nExamples of a **correct** config for this rule:\n\n```yaml\nwhen:\n  - branch: main\n    event: push\n  - event: tag\n```\n\n```yaml\nsteps:\n  - name: test\n    when:\n      event: [tag, push]\n\n  - name: deploy\n    when:\n      - event: tag\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/75-project-settings.md",
    "content": "# Project settings\n\nAs the owner of a project in Woodpecker you can change project related settings via the web interface.\n\n![project settings](./project-settings.png)\n\n## Pipeline path\n\nThe path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`.\n\n## Repository hooks\n\nYour Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting.\n\n## Allow pull requests\n\nEnables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests.\n\n## Allow deployments\n\nEnables a pipeline to be started with the `deploy` event from a successful pipeline.\n\n:::danger\nOnly activate this option if you trust all users who have push access to your repository.\nOtherwise, these users will be able to steal secrets that are only available for `deploy` events.\n:::\n\n## Require approval for\n\nTo prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`.\n\n## Trusted\n\nIf you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes.\n\n:::note\n\nOnly server admins can set this option. If you are not a server admin this option won't be shown in your project settings.\n\n:::\n\n## Custom trusted clone plugins\n\nDuring the clone process, Git credentials (e.g., for private repositories) may be required.\nThese credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html).\n\nThese credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting.\n\nWith these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo.\nTo prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level.\nWithout an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step.\n\n:::info\nThis setting does not affect subsequent steps, nor does it allow direct pushes to the repository.\nTo enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push).\n:::\n\n## Project visibility\n\nYou can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners.\n\n- `Public` Every user can see your project without being logged in.\n- `Internal` Only authenticated users of the Woodpecker instance can see this project.\n- `Private` Only you and other owners of the repository can see this project.\n\n## Timeout\n\nAfter this timeout a pipeline has to finish or will be treated as timed out.\n\n## Cancel previous pipelines\n\nBy enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/80-badges.md",
    "content": "# Status Badges\n\nWoodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code.\n\n## Badge endpoint\n\n```uri\n<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n```\n\nThe status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter.\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?branch=<branch>\n```\n\nBy default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state.\nIf you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event:\n\n```diff\n-<scheme>://<hostname>/api/badges/<repo-id>/status.svg\n+<scheme>://<hostname>/api/badges/<repo-id>/status.svg?events=manual,cron\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/90-advanced-usage.md",
    "content": "# Advanced usage\n\n## Advanced YAML syntax\n\nYAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config:\n\n### Anchors & aliases\n\nYou can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config.\n\nTo convert this:\n\n```yaml\nsteps:\n  - name: test\n    image: golang:1.18\n    commands: go test ./...\n  - name: build\n    image: golang:1.18\n    commands: build\n```\n\nJust add a new section called **variables** like this:\n\n```diff\n+variables:\n+  - &golang_image 'golang:1.18'\n\n steps:\n   - name: test\n-    image: golang:1.18\n+    image: *golang_image\n     commands: go test ./...\n   - name: build\n-    image: golang:1.18\n+    image: *golang_image\n     commands: build\n```\n\n### Map merges and overwrites\n\n```yaml\nvariables:\n  - &base-plugin-settings\n    target: dist\n    recursive: false\n    try: true\n  - &special-setting\n    special: true\n  - &some-plugin codeberg.org/6543/docker-images/print_env\n\nsteps:\n  - name: develop\n    image: *some-plugin\n    settings:\n      <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map\n    when:\n      branch: develop\n\n  - name: main\n    image: *some-plugin\n    settings:\n      <<: *base-plugin-settings # merge one map and ...\n      try: false # ... overwrite original value\n      ongoing: false # ... adding a new value\n    when:\n      branch: main\n```\n\n### Sequence merges\n\n```yaml\nvariables:\n  pre_cmds: &pre_cmds\n    - echo start\n    - whoami\n  post_cmds: &post_cmds\n    - echo stop\n  hello_cmd: &hello_cmd\n    - echo hello\n\nsteps:\n  - name: step1\n    image: debian\n    commands:\n      - <<: *pre_cmds # prepend a sequence\n      - echo exec step now do dedicated things\n      - <<: *post_cmds # append a sequence\n  - name: step2\n    image: debian\n    commands:\n      - <<: [*pre_cmds, *hello_cmd] # prepend two sequences\n      - echo echo from second step\n      - <<: *post_cmds\n```\n\n### References\n\n- [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases)\n- [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml)\n\n## Persisting environment data between steps\n\nOne can create a file containing environment variables, and then source it in each step that needs them.\n\n```yaml\nsteps:\n  - name: init\n    image: bash\n    commands:\n      - echo \"FOO=hello\" >> envvars\n      - echo \"BAR=world\" >> envvars\n\n  - name: debug\n    image: bash\n    commands:\n      - source ./envvars\n      - echo $FOO\n```\n\n## Declaring global variables\n\nAs described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables:\n\n```ini\nWOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2\n```\n\nNote that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps.\n\n## Docker in docker (dind) setup\n\n:::warning\nThis set up will only work on trusted repositories and for security reasons should only be used in private environments.\nSee [project settings](./75-project-settings.md#trusted) to enable \"trusted\" mode.\n:::\n\nThe snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service.\n\n:::note\nIf your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead.\n:::\n\nFirst we need to define a service running a docker with the `dind` tag.\nThis service must run in `privileged` mode:\n\n```yaml\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    ports:\n      - 2376\n```\n\nNext, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28).\n\nThis can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below).\n\n```diff\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n+    environment:\n+      DOCKER_TLS_CERTDIR: /dind-certs\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n     ports:\n       - 2376\n```\n\nIn the docker client step:\n\n1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon.\n   These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them).\n2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`)\n\nTest the connection with the docker client:\n\n```diff\nsteps:\n  - name: test\n    image: docker:cli # in production use something like 'docker:<major version>-cli'\n+    environment:\n+      DOCKER_HOST: \"tcp://docker:2376\"\n+      DOCKER_CERT_PATH: \"/dind-certs/client\"\n+      DOCKER_TLS_VERIFY: \"1\"\n+    volumes:\n+      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n```\n\nThis step should output the server and client version information if everything has been set up correctly.\n\nFull example:\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    commands:\n      - docker version\n\nservices:\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /dind-certs\n    volumes:\n      - /opt/woodpeckerci/dind-certs:/dind-certs\n    ports:\n      - 2376\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/20-usage/_category_.yaml",
    "content": "label: 'Usage'\n# position: 2\ncollapsible: true\ncollapsed: false\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/00-general.md",
    "content": "# General\n\nWoodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`).\n\nThe **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files.\n\nThe **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance.\n\nThe **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time).\n\n:::tip\nYou can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent.\n:::\n\n## Database\n\nWoodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page.\n\n## Forge\n\nWhat would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page.\n\n## Container images\n\n:::info\nNo `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch.\n:::\n\n- `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image)\n  - `vX.Y`\n  - `vX`\n- `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0).\n  - `vX.Y-alpine`\n  - `vX-alpine`\n- `next`: Built from the `main` branch\n- `pull_<PR_ID>`: Images built from Pull Request branches.\n\nImages are pushed to DockerHub and Quay.\n\n- woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server))\n- woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent))\n- woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli))\n- woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler))\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/05-installation/10-docker-compose.md",
    "content": "# Docker Compose\n\nThis example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings.\n\nIt creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information.\n\nThe server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it.\n\n```yaml title=\"docker-compose.yaml\"\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:v3\n    ports:\n      - 8000:8000\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_HOST=${WOODPECKER_HOST}\n      - WOODPECKER_GITHUB=true\n      - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\n      - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\n  woodpecker-agent:\n    image: woodpeckerci/woodpecker-agent:v3\n    command: agent\n    restart: always\n    depends_on:\n      - woodpecker-server\n    volumes:\n      - woodpecker-agent-config:/etc/woodpecker\n      - /var/run/docker.sock:/var/run/docker.sock\n    environment:\n      - WOODPECKER_SERVER=woodpecker-server:9000\n      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n\nvolumes:\n  woodpecker-server-data:\n  woodpecker-agent-config:\n```\n\nWoodpecker must know its own address. You must therefore specify the public address in the format `<scheme>://<hostname>`. Please omit any trailing slashes:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_HOST=${WOODPECKER_HOST}\n```\n\nIt is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR}\n+      - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR}\n```\n\nIf the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_GRPC_SECURE=true # defaults to false\n+      - WOODPECKER_GRPC_VERIFY=true # default\n```\n\nAs agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n+    volumes:\n+      - /var/run/docker.sock:/var/run/docker.sock\n```\n\nAgents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-agent:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER=woodpecker-server:9000\n```\n\nThe server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Handling sensitive data\n\nThere are several options for handling sensitive data in `docker compose` or `docker swarm` configurations:\n\nFor Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure.\n\nAlternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret\n+    secrets:\n+      - woodpecker-agent-secret\n+\n+ secrets:\n+   woodpecker-agent-secret:\n+     external: true\n```\n\nTo store values in a docker secret you can use the following command:\n\n```bash\necho \"my_agent_secret_key\" | docker secret create woodpecker-agent-secret -\n```\n\n## SELinux Considerations\n\nIf you're running Woodpecker on a system with SELinux enabled (RHEL, CentOS, Fedora, etc.), you may need to add the `:z` or `:Z` option to volume mounts. For the Docker socket volume:\n\n```yaml\nvolumes:\n  - /var/run/docker.sock:/var/run/docker.sock:z\n```\n\nFor more details and other SELinux-related solutions, see the [Troubleshooting](../../20-usage/100-troubleshooting.md#selinux-issues) page.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/05-installation/20-helm-chart.md",
    "content": "# Helm Chart\n\nWoodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments:\n\n```bash\nhelm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version <VERSION>\n```\n\n## Metrics\n\nTo enable metrics gathering, set the following in values.yml:\n\n```yaml\nmetrics:\n  enabled: true\n  port: 9001\n```\n\nThis activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics.\n\nTo enable both Prometheus pod monitoring discovery, set:\n\n<!-- cspell:disable -->\n\n```yaml\nprometheus:\n  podmonitor:\n    enabled: true\n    interval: 60s\n    labels: {}\n```\n\n<!-- cspell:enable -->\n\nIf you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled:\n\n```yaml\n# Search all available namespaces\npodMonitorNamespaceSelector:\n  matchLabels: {}\n# Enable all available pod monitors\npodMonitorSelector:\n  matchLabels: {}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/05-installation/30-packages.md",
    "content": "# Distribution packages\n\n## Official packages\n\n- DEB\n- RPM\n\nThe pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution.\n\n```Shell\nRELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '\"tag_name\":\\s\"v\\K[^\"]+')\n\n# Debian/Ubuntu (x86_64)\ncurl -fLOOO \"https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\"\nsudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb\n\n# CentOS/RHEL (x86_64)\nsudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm\n```\n\nThe package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-server.service\"\n[Unit]\nDescription=WoodpeckerCI server\nDocumentation=https://woodpecker-ci.org/docs/administration/server-config\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env\nConditionPathExists=/etc/woodpecker/woodpecker-server.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-server.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-server\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-server.env\"\nWOODPECKER_OPEN=true\nWOODPECKER_HOST=${WOODPECKER_HOST}\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}\nWOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\nAfter installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server.\n\n```ini title=\"/usr/local/lib/systemd/system/woodpecker-agent.service\"\n[Unit]\nDescription=WoodpeckerCI agent\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/agent\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env\nConditionPathExists=/etc/woodpecker/woodpecker-agent.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-agent.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-agent\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```shell title=\"/etc/woodpecker/woodpecker-agent.env\"\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}\n```\n\n## Community packages\n\n:::info\nWoodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions.\n:::\n\n- [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=)\n- [Arch Linux](https://archlinux.org/packages/?q=woodpecker)\n- [openSUSE](https://software.opensuse.org/package/woodpecker)\n- [YunoHost](https://apps.yunohost.org/app/woodpecker)\n- [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html)\n- [Easypanel](https://easypanel.io/docs/templates/woodpeckerci)\n- [Homebrew](https://formulae.brew.sh/formula/woodpecker-cli) (CLI only)\n\n### NixOS\n\n:::info\nThis module is not maintained by the Woodpecker developers.\nIf you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained.\n:::\n\nIn theory, the NixOS installation is very similar to the binary installation and supports multiple backends.\nIn practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken.\n\n<!-- cspell:words Optimisation -->\n\n```nix\n{ config\n, ...\n}:\nlet\n  domain = \"woodpecker.example.org\";\nin\n{\n  # This automatically sets up certificates via let's encrypt\n  security.acme.defaults.email = \"acme@example.com\";\n  security.acme.acceptTerms = true;\n\n  # Setting up a nginx proxy that handles tls for us\n  services.nginx = {\n    enable = true;\n    openFirewall = true;\n    recommendedTlsSettings = true;\n    recommendedOptimisation = true;\n    recommendedProxySettings = true;\n    virtualHosts.\"${domain}\" = {\n      enableACME = true;\n      forceSSL = true;\n      locations.\"/\".proxyPass = \"http://localhost:3007\";\n    };\n  };\n\n  services.woodpecker-server = {\n    enable = true;\n    environment = {\n      WOODPECKER_HOST = \"https://${domain}\";\n      WOODPECKER_SERVER_ADDR = \":3007\";\n      WOODPECKER_OPEN = \"true\";\n    };\n    # You can pass a file with env vars to the system it could look like:\n    # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX\n    environmentFile = \"/path/to/my/secrets/file\";\n  };\n\n  # This sets up a woodpecker agent\n  services.woodpecker-agents.agents.\"docker\" = {\n    enable = true;\n    # We need this to talk to the podman socket\n    extraGroups = [ \"podman\" ];\n    environment = {\n      WOODPECKER_SERVER = \"localhost:9000\";\n      WOODPECKER_MAX_WORKFLOWS = \"4\";\n      DOCKER_HOST = \"unix:///run/podman/podman.sock\";\n      WOODPECKER_BACKEND = \"docker\";\n    };\n    # Same as with woodpecker-server\n    environmentFile = [ \"/var/lib/secrets/woodpecker.env\" ];\n  };\n\n  # Here we setup podman and enable dns\n  virtualisation.podman = {\n    enable = true;\n    defaultNetwork.settings = {\n      dns_enabled = true;\n    };\n  };\n  # This is needed for podman to be able to talk over dns\n  networking.firewall.interfaces.\"podman0\" = {\n    allowedUDPPorts = [ 53 ];\n    allowedTCPPorts = [ 53 ];\n  };\n}\n```\n\nAll configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/05-installation/_category_.yaml",
    "content": "label: 'Installation'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/10-server.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Server\n\n## Forge and User configuration\n\nWoodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge.\n\nYou can also restrict the registration:\n\n- closed registration and manually managing users with the CLI `woodpecker-cli user`\n- open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN`\n\n  ```ini\n  WOODPECKER_OPEN=false\n  WOODPECKER_ADMIN=john.smith,jane_doe\n  ```\n\n- open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS`\n\n  ```ini\n  WOODPECKER_OPEN=true\n  WOODPECKER_ORGS=dolores,dog-patch\n  ```\n\nAdministrators should also be explicitly set in your configuration.\n\n```ini\nWOODPECKER_ADMIN=john.smith,jane_doe\n```\n\n## Repository configuration\n\nWoodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here.\n\n```ini\nWOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user\n```\n\n## Databases\n\nThe default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind:\n\n- Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`.\n\n- Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably.\n\n- Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes.\n\n- Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice.\n\n### SQLite\n\nBy default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database.\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n+    volumes:\n+      - woodpecker-server-data:/var/lib/woodpecker/\n```\n\n### MySQL/MariaDB\n\nThe below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples.\nThe minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=mysql\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n```\n\n### PostgreSQL\n\nThe below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples.\nPlease use Postgres versions equal or higher than **11**.\n\n```ini\nWOODPECKER_DATABASE_DRIVER=postgres\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable\n```\n\n## TLS\n\nWoodpecker supports SSL configuration by mounting certificates into your container.\n\n```ini\nWOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\nWOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n```\n\nTLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library.\n\n### Container configuration\n\nIn addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     ports:\n+      - 80:80\n+      - 443:443\n       - 9000:9000\n```\n\nAdditionally, the certificate and key must be mounted and referenced:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n+      - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt\n+      - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key\n     volumes:\n+      - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt\n+      - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key\n```\n\n## Reverse Proxy\n\n### Apache\n\nThis guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration:\n\n<!-- cspell:ignore apacheconf -->\n\n```apacheconf\nProxyPreserveHost On\n\nRequestHeader set X-Forwarded-Proto \"https\"\n\nProxyPass / http://127.0.0.1:8000/\nProxyPassReverse / http://127.0.0.1:8000/\n```\n\nYou must have these Apache modules installed:\n\n- `proxy`\n- `proxy_http`\n\nYou must configure Apache to set `X-Forwarded-Proto` when using https.\n\n```diff\n ProxyPreserveHost On\n\n+RequestHeader set X-Forwarded-Proto \"https\"\n\n ProxyPass / http://127.0.0.1:8000/\n ProxyPassReverse / http://127.0.0.1:8000/\n```\n\n### Nginx\n\nThis guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide).\n\nExample configuration:\n\n```nginx\nserver {\n    listen 80;\n    server_name woodpecker.example.com;\n\n    location / {\n        proxy_set_header X-Forwarded-For $remote_addr;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Host $http_host;\n\n        proxy_pass http://127.0.0.1:8000;\n        proxy_redirect off;\n        proxy_http_version 1.1;\n        proxy_buffering off;\n\n        chunked_transfer_encoding off;\n    }\n}\n```\n\nYou must configure the proxy to set `X-Forwarded` proxy headers:\n\n```diff\n server {\n     listen 80;\n     server_name woodpecker.example.com;\n\n     location / {\n+        proxy_set_header X-Forwarded-For $remote_addr;\n+        proxy_set_header X-Forwarded-Proto $scheme;\n\n         proxy_pass http://127.0.0.1:8000;\n         proxy_redirect off;\n         proxy_http_version 1.1;\n         proxy_buffering off;\n\n         chunked_transfer_encoding off;\n     }\n }\n```\n\n### Caddy\n\nThis guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration:\n\n```caddy\n# expose WebUI and API\nwoodpecker.example.com {\n  reverse_proxy woodpecker-server:8000\n}\n\n# expose gRPC\nwoodpecker-agent.example.com {\n  reverse_proxy h2c://woodpecker-server:9000\n}\n```\n\n### Tunnelmole\n\n[Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool.\n\nStart by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation).\n\nAfter the installation, run the following command to start tunnelmole:\n\n```bash\ntmole 8000\n```\n\nIt will start a tunnel and will give a response like this:\n\n```bash\n➜  ~ tmole 8000\nhttp://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\nhttps://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000\n```\n\nSet `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server.\n\n### Ngrok\n\n[Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command:\n\n```bash\nngrok http 8000\n```\n\nSet `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server.\n\n### Traefik\n\nTo install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https.\n\n<!-- cspell:words redirectscheme certresolver  -->\n\n```yaml\nservices:\n  server:\n    image: woodpeckerci/woodpecker-server:latest\n    environment:\n      - WOODPECKER_OPEN=true\n      - WOODPECKER_ADMIN=your_admin_user\n      # other settings ...\n\n    networks:\n      - dmz # externally defined network, so that traefik can connect to the server\n    volumes:\n      - woodpecker-server-data:/var/lib/woodpecker/\n\n    deploy:\n      labels:\n        - traefik.enable=true\n\n        # web server\n        - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000\n\n        - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker-secure.tls=true\n        - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-secure.service=woodpecker-service\n\n        - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`)\n        - traefik.http.routers.woodpecker.entrypoints=web\n        - traefik.http.routers.woodpecker.service=woodpecker-service\n\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker\n\n        #  gRPC service\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000\n        - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c\n\n        - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc-secure.tls=true\n        - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt\n        - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure\n        - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc\n\n        - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`)\n        - traefik.http.routers.woodpecker-grpc.entrypoints=web\n        - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc\n\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https\n        - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true\n        - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker\n\nvolumes:\n  woodpecker-server-data:\n    driver: local\n\nnetworks:\n  dmz:\n    external: true\n```\n\n## Metrics\n\n### Endpoint\n\nWoodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above.\n\n```yaml\nglobal:\n  scrape_interval: 60s\n\nscrape_configs:\n  - job_name: 'woodpecker'\n    bearer_token: dummyToken...\n\n    static_configs:\n      - targets: ['woodpecker.domain.com']\n```\n\n### Authorization\n\nAn administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token: dummyToken...\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\nAs an alternative, the token can also be read from a file:\n\n```diff\n global:\n   scrape_interval: 60s\n\n scrape_configs:\n   - job_name: 'woodpecker'\n+    bearer_token_file: /etc/secrets/woodpecker-monitoring-token\n\n     static_configs:\n        - targets: ['woodpecker.domain.com']\n```\n\n### Reference\n\nList of Prometheus metrics specific to Woodpecker:\n\n```yaml\n# HELP woodpecker_pipeline_count Pipeline count.\n# TYPE woodpecker_pipeline_count counter\nwoodpecker_pipeline_count{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\nwoodpecker_pipeline_count{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 3\n# HELP woodpecker_pipeline_time Build time.\n# TYPE woodpecker_pipeline_time gauge\nwoodpecker_pipeline_time{branch=\"main\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 116\nwoodpecker_pipeline_time{branch=\"dev\",pipeline=\"total\",repo=\"woodpecker-ci/woodpecker\",status=\"success\"} 155\n# HELP woodpecker_pipeline_total_count Total number of builds.\n# TYPE woodpecker_pipeline_total_count gauge\nwoodpecker_pipeline_total_count 1025\n# HELP woodpecker_pending_steps Total number of pending pipeline steps.\n# TYPE woodpecker_pending_steps gauge\nwoodpecker_pending_steps 0\n# HELP woodpecker_repo_count Total number of repos.\n# TYPE woodpecker_repo_count gauge\nwoodpecker_repo_count 9\n# HELP woodpecker_running_steps Total number of running pipeline steps.\n# TYPE woodpecker_running_steps gauge\nwoodpecker_running_steps 0\n# HELP woodpecker_user_count Total number of users.\n# TYPE woodpecker_user_count gauge\nwoodpecker_user_count 1\n# HELP woodpecker_waiting_steps Total number of pipeline waiting on deps.\n# TYPE woodpecker_waiting_steps gauge\nwoodpecker_waiting_steps 0\n# HELP woodpecker_worker_count Total number of workers.\n# TYPE woodpecker_worker_count gauge\nwoodpecker_worker_count 4\n```\n\n#### Example response structure\n\n```json\n{\n  \"configs\": [\n    {\n      \"name\": \"central-override\",\n      \"data\": \"steps:\\n  - name: backend\\n    image: alpine\\n    commands:\\n      - echo \\\"Hello there from ConfigAPI\\\"\\n\"\n    }\n  ]\n}\n```\n\n## UI customization\n\nWoodpecker supports custom JS and CSS files. These files must be present in the server's filesystem.\nThey can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment.\nThe configuration variables are independent of each other, which means it can be just one file present, or both.\n\n```ini\nWOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css\nWOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js\n```\n\nThe examples below show how to place a banner message in the top navigation bar of Woodpecker.\n\n```css title=\"woodpecker.css\"\n.banner-message {\n  position: absolute;\n  width: 280px;\n  height: 40px;\n  margin-left: 240px;\n  margin-top: 5px;\n  padding-top: 5px;\n  font-weight: bold;\n  background: red no-repeat;\n  text-align: center;\n}\n```\n\n```javascript title=\"woodpecker.js\"\n// place/copy a minified version of your preferred lightweight JavaScript library here ...\n!(function () {\n  'use strict';\n  function e() {} /*...*/\n})();\n\n$().ready(function () {\n  $('.app nav img').first().htmlAfter(\"<div class='banner-message'>This is a demo banner message :)</div>\");\n});\n```\n\n## Environment variables\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### LOG_FILE\n\n- Name: `WOODPECKER_LOG_FILE`\n- Default: `stderr`\n\nOutput destination for logs.\n'stdout' and 'stderr' can be used as special keywords.\n\n---\n\n### DATABASE_LOG\n\n- Name: `WOODPECKER_DATABASE_LOG`\n- Default: `false`\n\nEnable logging in database engine (currently xorm).\n\n---\n\n### DATABASE_LOG_SQL\n\n- Name: `WOODPECKER_DATABASE_LOG_SQL`\n- Default: `false`\n\nEnable logging of sql commands.\n\n---\n\n### DATABASE_MAX_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS`\n- Default: `100`\n\nMax database connections xorm is allowed create.\n\n---\n\n### DATABASE_IDLE_CONNECTIONS\n\n- Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS`\n- Default: `2`\n\nAmount of database connections xorm will hold open.\n\n---\n\n### DATABASE_CONNECTION_TIMEOUT\n\n- Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT`\n- Default: `3 Seconds`\n\nTime an active database connection is allowed to stay open.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOST\n\n- Name: `WOODPECKER_HOST`\n- Default: none\n\nServer fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix.\n\nExamples:\n\n- `WOODPECKER_HOST=http://woodpecker.example.org`\n- `WOODPECKER_HOST=http://example.org/woodpecker`\n- `WOODPECKER_HOST=http://example.org:1234/woodpecker`\n\n---\n\n### SERVER_ADDR\n\n- Name: `WOODPECKER_SERVER_ADDR`\n- Default: `:8000`\n\nConfigures the HTTP listener port.\n\n---\n\n### SERVER_ADDR_TLS\n\n- Name: `WOODPECKER_SERVER_ADDR_TLS`\n- Default: `:443`\n\nConfigures the HTTPS listener port when SSL is enabled.\n\n---\n\n### SERVER_CERT\n\n- Name: `WOODPECKER_SERVER_CERT`\n- Default: none\n\nPath to an SSL certificate used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_CERT=/path/to/cert.pem`\n\n---\n\n### SERVER_KEY\n\n- Name: `WOODPECKER_SERVER_KEY`\n- Default: none\n\nPath to an SSL certificate key used by the server to accept HTTPS requests.\n\nExample: `WOODPECKER_SERVER_KEY=/path/to/key.pem`\n\n---\n\n### CUSTOM_CSS_FILE\n\n- Name: `WOODPECKER_CUSTOM_CSS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .CSS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css`\n\n---\n\n### CUSTOM_JS_FILE\n\n- Name: `WOODPECKER_CUSTOM_JS_FILE`\n- Default: none\n\nFile path for the server to serve a custom .JS file, used for customizing the UI.\nCan be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling).\nThe file must be UTF-8 encoded, to ensure all special characters are preserved.\n\nExample: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js`\n\n---\n\n### GRPC_ADDR\n\n- Name: `WOODPECKER_GRPC_ADDR`\n- Default: `:9000`\n\nConfigures the gRPC listener port.\n\n---\n\n### GRPC_SECRET\n\n- Name: `WOODPECKER_GRPC_SECRET`\n- Default: `secret`\n\nConfigures the gRPC JWT secret.\n\n---\n\n### GRPC_SECRET_FILE\n\n- Name: `WOODPECKER_GRPC_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GRPC_SECRET` from the specified filepath.\n\n---\n\n### METRICS_SERVER_ADDR\n\n- Name: `WOODPECKER_METRICS_SERVER_ADDR`\n- Default: none\n\nConfigures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely.\n\nExample: `:9001`\n\n---\n\n### ADMIN\n\n- Name: `WOODPECKER_ADMIN`\n- Default: none\n\nComma-separated list of admin accounts.\n\nExample: `WOODPECKER_ADMIN=user1,user2`\n\n---\n\n### ORGS\n\n- Name: `WOODPECKER_ORGS`\n- Default: none\n\nComma-separated list of approved organizations.\n\nExample: `org1,org2`\n\n---\n\n### REPO_OWNERS\n\n- Name: `WOODPECKER_REPO_OWNERS`\n- Default: none\n\nRepositories by those owners will be allowed to be used in woodpecker.\n\nExample: `user1,user2`\n\n---\n\n### OPEN\n\n- Name: `WOODPECKER_OPEN`\n- Default: `false`\n\nEnable to allow user registration.\n\n---\n\n### AUTHENTICATE_PUBLIC_REPOS\n\n- Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`\n- Default: `false`\n\nAlways use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies.\n\n---\n\n### DEFAULT_ALLOW_PULL_REQUESTS\n\n- Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS`\n- Default: `true`\n\nThe default setting for allowing pull requests on a repo.\n\n---\n\n### DEFAULT_APPROVAL_MODE\n\n- Name: `WOODPECKER_DEFAULT_APPROVAL_MODE`\n- Default: `forks`\n\nThe default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`.\n\n---\n\n### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS\n\n- Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS`\n- Default: `pull_request, push`\n\nList of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.\n\n---\n\n### DEFAULT_CLONE_PLUGIN\n\n- Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN`\n- Default: `docker.io/woodpeckerci/plugin-git`\n\nThe default docker image to be used when cloning the repo.\n\nIt is also added to the trusted clone plugin list.\n\n### DEFAULT_WORKFLOW_LABELS\n\n- Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS`\n- Default: none\n\nYou can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set.\n\nExample: `platform=linux/amd64,backend=docker`\n\n### DEFAULT_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`\n- Default: 60\n\nThe default time for a repo in minutes before a pipeline gets killed\n\n### MAX_PIPELINE_TIMEOUT\n\n- Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT`\n- Default: 120\n\nThe maximum time in minutes you can set in the repo settings before a pipeline gets killed\n\n---\n\n### SESSION_EXPIRES\n\n- Name: `WOODPECKER_SESSION_EXPIRES`\n- Default: `72h`\n\nConfigures the session expiration time.\nContext: when someone does log into Woodpecker, a temporary session token is created.\nAs long as the session is valid (until it expires or log-out),\na user can log into Woodpecker, without re-authentication.\n\n### PLUGINS_PRIVILEGED\n\n- Name: `WOODPECKER_PLUGINS_PRIVILEGED`\n- Default: none\n\nDocker images to run in privileged mode. Only change if you are sure what you do!\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n### PLUGINS_TRUSTED_CLONE\n\n- Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE`\n- Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git`\n\nPlugins which are trusted to handle the Git credential info in clone steps.\nIf a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos.\n\nYou should specify the tag of your images too, as this enforces exact matches.\n\n<!-- ---\n\n### `VOLUME`\n\n- Name: `WOODPECKER_VOLUME`\n- Default: none\n\nComma-separated list of Docker volumes that are mounted into every pipeline step.\n\nExample: `WOODPECKER_VOLUME=/path/on/host:/path/in/container:rw`| -->\n\n---\n\n### DOCKER_CONFIG\n\n- Name: `WOODPECKER_DOCKER_CONFIG`\n- Default: none\n\nConfigures a specific private registry config for all pipelines.\n\nExample: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json`\n\n---\n\n### ENVIRONMENT\n\n- Name: `WOODPECKER_ENVIRONMENT`\n- Default: none\n\nIf you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables.\n\nExample: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2`\n\n<!-- ---\n\n### NETWORK\n\n- Name: `WOODPECKER_NETWORK`\n- Default: none\n\nComma-separated list of Docker networks that are attached to every pipeline step.\n\nExample: `WOODPECKER_NETWORK=network1,network2` -->\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath\n\n---\n\n### DISABLE_USER_AGENT_REGISTRATION\n\n- Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION`\n- Default: false\n\nBy default, users can create new agents for their repos they have admin access to.\nIf an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements.\n\n:::note\nYou should set this option if you have, for example,\nglobal secrets and don't trust your users to create a rogue agent and pipeline for secret extraction.\n:::\n\n---\n\n### KEEPALIVE_MIN_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_MIN_TIME`\n- Default: none\n\nServer-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.\n\nExample: `WOODPECKER_KEEPALIVE_MIN_TIME=10s`\n\n---\n\n### DATABASE_DRIVER\n\n- Name: `WOODPECKER_DATABASE_DRIVER`\n- Default: `sqlite3`\n\nThe database driver name. Possible values are `sqlite3`, `mysql` or `postgres`.\n\n---\n\n### DATABASE_DATASOURCE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE`\n- Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container\n\nThe database connection string. The default value is the path of the embedded SQLite database file.\n\nExample:\n\n```bash\n# MySQL\n# https://github.com/go-sql-driver/mysql#dsn-data-source-name\nWOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true\n\n# PostgreSQL\n# https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING\nWOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable\n```\n\n---\n\n### DATABASE_DATASOURCE_FILE\n\n- Name: `WOODPECKER_DATABASE_DATASOURCE_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath\n\n---\n\n### PROMETHEUS_AUTH_TOKEN\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN`\n- Default: none\n\nToken to secure the Prometheus metrics endpoint.\nMust be set to enable the endpoint.\n\n---\n\n### PROMETHEUS_AUTH_TOKEN_FILE\n\n- Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath\n\n---\n\n### STATUS_CONTEXT\n\n- Name: `WOODPECKER_STATUS_CONTEXT`\n- Default: `ci/woodpecker`\n\nContext prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository.\n\n---\n\n### STATUS_CONTEXT_FORMAT\n\n- Name: `WOODPECKER_STATUS_CONTEXT_FORMAT`\n- Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}`\n\nTemplate for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language.\nSupported variables:\n\n- `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`)\n- `event`: the event which started the pipeline\n- `workflow`: the workflow's name\n- `owner`: the repo's owner\n- `repo`: the repo's name\n\n---\n\n### CONFIG_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_CONFIG_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a configuration extension endpoint, see [Configuration Extension](../../20-usage/72-extensions/40-configuration-extension.md)\n\n---\n\n### CONFIG_EXTENSION_EXCLUSIVE\n\n- Name: `CONFIG_EXTENSION_EXCLUSIVE`\n- Default: false\n\nWhether the forge request should be skipped for the global configuration endpoint.\n\n:::warning\nIf you enable this, all repos will exclusively use the global config service endpoint. There is no possibility to directly define pipelines in the forge, except the extension handles this case itself as well.\n:::\n\n---\n\n### CONFIG_EXTENSION_NETRC\n\n- Name: `WOODPECKER_CONFIG_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the config extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### SECRET_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_SECRET_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a secret extension endpoint, see [Secret Extension](../../20-usage/72-extensions/55-secret-extension.md)\n\n---\n\n### SECRET_EXTENSION_NETRC\n\n- Name: `WOODPECKER_SECRET_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the secret extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### REGISTRY_EXTENSION_ENDPOINT\n\n- Name: `WOODPECKER_REGISTRY_EXTENSION_ENDPOINT`\n- Default: none\n\nSpecify a registry extension endpoint, see [Registry Extension](../../20-usage/72-extensions/50-registry-extension.md)\n\n---\n\n### REGISTRY_EXTENSION_NETRC\n\n- Name: `WOODPECKER_REGISTRY_EXTENSION_NETRC`\n- Default: false\n\nSend `netrc` to the registry extension endpoint.\n\n:::warning\nThe `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.\n:::\n\n---\n\n### EXTENSIONS_ALLOWED_HOSTS\n\n- Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS`\n- Default: `external`\n\nComma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list.\n\n---\n\n### FORGE_TIMEOUT\n\n- Name: `WOODPECKER_FORGE_TIMEOUT`\n- Default: 5s\n\nSpecify timeout when fetching the Woodpecker configuration from forge. See <https://pkg.go.dev/time#ParseDuration> for syntax reference.\n\n---\n\n### FORGE_RETRY\n\n- Name: `WOODPECKER_FORGE_RETRY`\n- Default: 3\n\nSpecify how many retries of fetching the Woodpecker configuration from a forge are done before we fail.\n\n---\n\n### ENABLE_SWAGGER\n\n- Name: `WOODPECKER_ENABLE_SWAGGER`\n- Default: true\n\nEnable the Swagger UI for API documentation.\n\n---\n\n### DISABLE_VERSION_CHECK\n\n- Name: `WOODPECKER_DISABLE_VERSION_CHECK`\n- Default: false\n\nDisable version check in admin web UI.\n\n---\n\n### LOG_STORE\n\n- Name: `WOODPECKER_LOG_STORE`\n- Default: `database`\n\nWhere to store logs. Possible values:\n\n- `database`: stores the logs in the database\n- `file`: stores logs in JSON files on the files system\n- `addon`: uses an [addon](./100-addons.md#log) to store logs\n\n---\n\n### LOG_STORE_FILE_PATH\n\n- Name: `WOODPECKER_LOG_STORE_FILE_PATH`\n- Default: none\n\nIf [`WOODPECKER_LOG_STORE`](#log_store) is:\n\n- `file`: Directory to store logs in\n- `addon`: The path to the addon executable\n\n---\n\n### EXPERT_WEBHOOK_HOST\n\n- Name: `WOODPECKER_EXPERT_WEBHOOK_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### EXPERT_FORGE_OAUTH_HOST\n\n- Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST`\n- Default: none\n\n:::warning\nThis option is not required in most cases and should only be used if you know what you're doing.\n:::\n\nFully qualified public forge URL, used if forge url is not a public URL. Format: `<scheme>://<host>[/<prefix path>]`.\n\n---\n\n### FORCE_IGNORE_SERVICE_FAILURE\n\n- Name: `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE`\n- Default: true\n\n:::warning\nSince v3.14.0, Woodpecker can report the status of services and detached steps.\nBecause these can now fail, until v4.0.0 is released, service failures are ignored by default to preserve backward compatibility.\nWe encourage you to disable this option and update your pipeline configuration.\n:::\n\n---\n\n### GITHUB\\_\\*\n\nSee [GitHub configuration](./12-forges/20-github.md#configuration)\n\n---\n\n### GITEA\\_\\*\n\nSee [Gitea configuration](./12-forges/30-gitea.md#configuration)\n\n---\n\n### BITBUCKET\\_\\*\n\nSee [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration)\n\n---\n\n### GITLAB\\_\\*\n\nSee [GitLab configuration](./12-forges/40-gitlab.md#configuration)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/100-addons.md",
    "content": "# Addons\n\nAddons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service.\n\n:::warning\nAddon forges are still experimental. Their implementation can change and break at any time.\n:::\n\n:::danger\nYou must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information.\n:::\n\n## Usage\n\nTo use an addon forge, download the correct addon version.\n\n### Forge\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file\n```\n\nIn case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`.\n\n#### List of addon forges\n\n- [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws).\n\n### Log\n\nUse this in your `.env`:\n\n```ini\nWOODPECKER_LOG_STORE=addon\nWOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file\n```\n\n## Developing addon forges\n\nSee [Addons](../../92-development/100-addons.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/10-docker.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Docker\n\nThis is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent.\n\n## Private registries\n\nWoodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config).\n\nTo add your credential helper to the Woodpecker server container you could use the following code to build a custom image:\n\n```dockerfile\nFROM woodpeckerci/woodpecker-server:latest-alpine\n\nRUN apk add -U --no-cache docker-credential-ecr-login\n```\n\n## Step specific configuration\n\n### Run user\n\nBy default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group:\n\n```yaml\nsteps:\n  - name: example\n    image: alpine\n    commands:\n      - whoami\n    backend_options:\n      docker:\n        user: 65534:65534\n```\n\nThe syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag.\n\n## Tips and tricks\n\n### Image cleanup\n\nThe agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.\n\n:::danger\nThe following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation.\n:::\n\n- Remove all unused images\n\n  <!-- cspell:ignore trunc -->\n\n  ```bash\n  docker image rm $(docker images --filter \"dangling=true\" -q --no-trunc)\n  ```\n\n- Remove Woodpecker volumes\n\n  ```bash\n  docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true  -q)\n  ```\n\n### Podman\n\nThere is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog).\n\n## Environment variables\n\n### BACKEND_DOCKER_NETWORK\n\n- Name: `WOODPECKER_BACKEND_DOCKER_NETWORK`\n- Default: none\n\nSet to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other!\n\n---\n\n### BACKEND_DOCKER_ENABLE_IPV6\n\n- Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6`\n- Default: `false`\n\nEnable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6.\n\n---\n\n### BACKEND_DOCKER_VOLUMES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES`\n- Default: none\n\nList of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA\ncertificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM_SWAP\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_MEM\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM`\n- Default: `0`\n\nThe maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_SHM_SIZE\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE`\n- Default: `0`\n\nThe maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_QUOTA\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA`\n- Default: `0`\n\nThe number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SHARES\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES`\n- Default: `0`\n\nThe relative weight vs. other containers.\n\n---\n\n### BACKEND_DOCKER_LIMIT_CPU_SET\n\n- Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET`\n- Default: none\n\nComma-separated list to limit the specific CPUs or cores a pipeline container can use.\n\nExample: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2`\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/20-kubernetes.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Kubernetes\n\nThe Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps.\n\n## Metadata labels\n\nWoodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies.\n\nThe following metadata labels are supported:\n\n- `woodpecker-ci.org/forge-id`\n- `woodpecker-ci.org/repo-forge-id`\n- `woodpecker-ci.org/repo-id`\n- `woodpecker-ci.org/repo-name`\n- `woodpecker-ci.org/repo-full-name`\n- `woodpecker-ci.org/branch`\n- `woodpecker-ci.org/org-id`\n- `woodpecker-ci.org/task-uuid`\n- `woodpecker-ci.org/step`\n\n## Private registries\n\nIn addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML.\n\nPlace these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.\n\n## Step specific configuration\n\n### Resources\n\nThe Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory.\nWe recommend to add a `resources` definition to all steps to ensure efficient scheduling.\n\nHere is an example definition with an arbitrary `resources` definition below the `backend_options` section:\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        resources:\n          requests:\n            memory: 200Mi\n            cpu: 100m\n          limits:\n            memory: 400Mi\n            cpu: 1000m\n```\n\nYou can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis.\n\n### Runtime class\n\n`runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes.\n\n### Service account\n\n`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.\n\n```yaml\nsteps:\n  - name: 'My kubernetes step'\n    image: alpine\n    commands:\n      - echo \"Hello world\"\n    backend_options:\n      kubernetes:\n        # Use the service account `default` in the current namespace.\n        # This usually the same as wherever woodpecker is deployed.\n        serviceAccountName: default\n```\n\nTo give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)\n\n### Node selector\n\n`nodeSelector` specifies the labels which are used to select the node on which the step will be executed.\n\nLabels defined here will be appended to a list which already contains `\"kubernetes.io/arch\"`.\nBy default `\"kubernetes.io/arch\"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`.\nWithout a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures.\n\nTo overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`.\nA practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture.\nIn this case, one must define an arbitrary key in the matrix section of the respective matrix element:\n\n```yaml\nmatrix:\n  include:\n    - NAME: runner1\n      ARCH: arm64\n```\n\nAnd then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var:\n\n```yaml\n[...]\n    backend_options:\n      kubernetes:\n        nodeSelector:\n          kubernetes.io/arch: \"${ARCH}\"\n```\n\nYou can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent\nor [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis.\n\n### Tolerations\n\nWhen you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints.\nSee the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations.\n\nExample pipeline configuration:\n\n```yaml\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go get\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        serviceAccountName: 'my-service-account'\n        resources:\n          requests:\n            memory: 128Mi\n            cpu: 1000m\n          limits:\n            memory: 256Mi\n        nodeSelector:\n          beta.kubernetes.io/instance-type: Standard_D2_v3\n        tolerations:\n          - key: 'key1'\n            operator: 'Equal'\n            value: 'value1'\n            effect: 'NoSchedule'\n            tolerationSeconds: 3600\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: topology.kubernetes.io/zone\n                      operator: In\n                      values:\n                        - eu-central-1a\n                        - eu-central-1b\n```\n\n### Affinity\n\nKubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods.\n\nYou can configure affinity at two levels:\n\n1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it\n2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden\n\n#### Agent-wide affinity\n\nTo apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  nodeAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n      nodeSelectorTerms:\n        - matchExpressions:\n            - key: node-role.kubernetes.io/worker\n              operator: In\n              values:\n                - \"true\"\n```\n\nBy default, per-step affinity settings are **not allowed** for security reasons. To enable them:\n\n```bash\nWOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true\n```\n\n:::warning\nEnabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications.\n:::\n\nWhen per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged).\n\n#### Example: agent affinity for co-location\n\nThis example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes.\n\nIt uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs:\n\n```yaml\nWOODPECKER_BACKEND_K8S_POD_AFFINITY: |\n  podAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      matchLabelKeys:\n        - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n  podAntiAffinity:\n    requiredDuringSchedulingIgnoredDuringExecution:\n    - labelSelector: {}\n      mismatchLabelKeys:\n      - woodpecker-ci.org/task-uuid\n      topologyKey: \"kubernetes.io/hostname\"\n```\n\n:::note\nThe `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`.\n:::\n\n#### Example: Node affinity for GPU workloads\n\nEnsure a step runs only on GPU-enabled nodes:\n\n```yaml\nsteps:\n  - name: train-model\n    image: tensorflow/tensorflow:latest-gpu\n    backend_options:\n      kubernetes:\n        affinity:\n          nodeAffinity:\n            requiredDuringSchedulingIgnoredDuringExecution:\n              nodeSelectorTerms:\n                - matchExpressions:\n                    - key: accelerator\n                      operator: In\n                      values:\n                        - nvidia-tesla-v100\n```\n\n### Volumes\n\nTo mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.\n\nPersistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference.\n\n_If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._\n\nNOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver:\n\n```yaml\naccessModes:\n  - ReadWriteMany\n```\n\nAssuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step:\n\n```yaml\nsteps:\n  - name: \"Restore Cache\"\n    image: meltwater/drone-cache\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    settings:\n      mount:\n        - \"woodpecker-cache\"\n    [...]\n```\n\nOr as follows when using a normal image:\n\n```yaml\nsteps:\n  - name: \"Edit cache\"\n    image: alpine:latest\n    volumes:\n      - woodpecker-cache:/woodpecker/src/cache\n    commands:\n      - echo \"Hello World\" > /woodpecker/src/cache/output.txt\n    [...]\n```\n\n### Security context\n\nUse the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:\n\n```yaml\nsteps:\n  - name: test\n    image: alpine\n    commands:\n      - echo Hello world\n    backend_options:\n      kubernetes:\n        securityContext:\n          runAsUser: 999\n          runAsGroup: 999\n          privileged: true\n    [...]\n```\n\nNote that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object.\nBy default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the\nconfiguration shown above will result in something like the following Pod spec:\n\n<!-- cspell:disable -->\n\n```yaml\nkind: Pod\nspec:\n  securityContext:\n    runAsUser: 999\n    runAsGroup: 999\n  containers:\n    - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0\n      image: alpine\n      securityContext:\n        privileged: true\n  [...]\n```\n\n<!-- cspell:enable -->\n\nYou can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      seccompProfile:\n        type: Localhost\n        localhostProfile: profiles/audit.json\n```\n\nor restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      apparmorProfile:\n        type: Localhost\n        localhostProfile: k8s-apparmor-example-deny-write\n```\n\nor configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always')\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      fsGroupChangePolicy: OnRootMismatch\n```\n\n:::note\nThe feature requires Kubernetes v1.30 or above.\n:::\n\nYou can set `allowPrivilegeEscalation` to `false` to prevent a container from gaining more privileges than its parent process.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      allowPrivilegeEscalation: false\n```\n\nYou can also drop [Linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) from a container. Adding capabilities is not allowed.\n\n```yaml\nbackend_options:\n  kubernetes:\n    securityContext:\n      capabilities:\n        drop:\n          - ALL\n```\n\n### Annotations and labels\n\nYou can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration:\n\n```yaml\nbackend_options:\n  kubernetes:\n    annotations:\n      workflow-group: alpha\n      io.kubernetes.cri-o.Devices: /dev/fuse\n    labels:\n      environment: ci\n      app.kubernetes.io/name: builder\n```\n\nIn order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent:\n[WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step).\n\n## Tips and tricks\n\n### CRI-O\n\nCRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration:\n\n```yaml\nworkspace:\n  base: '/woodpecker'\n  path: '/'\n```\n\nSee [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details.\n\n### `KUBERNETES_SERVICE_HOST` environment variable\n\nLike the below env vars used for configuration, this can be set in the environment for configuration of the agent.\nIt configures the address of the Kubernetes API server to connect to.\n\nIf running the agent within Kubernetes, this will already be set and you don't have to add it manually.\n\n### Headless services\n\nFor each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created,\nand all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname.\n\nUsing the headless services, the step pod is connected to directly, so any port on the other step pods can be reached.\n\nThis is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service.\n\n```yaml\nsteps:\n  - name: test\n    image: docker:cli # use 'docker:<major-version>-cli' or similar in production\n    environment:\n      DOCKER_HOST: 'tcp://docker:2376'\n      DOCKER_CERT_PATH: '/woodpecker/dind-certs/client'\n      DOCKER_TLS_VERIFY: '1'\n    commands:\n      - docker run hello-world\n\n  - name: docker\n    image: docker:dind # use 'docker:<major-version>-dind' or similar in production\n    detached: true\n    privileged: true\n    environment:\n      DOCKER_TLS_CERTDIR: /woodpecker/dind-certs\n```\n\nIf ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP.\n\n## Environment variables\n\nThese env vars can be set in the `env:` sections of the agent.\n\n---\n\n### BACKEND_K8S_NAMESPACE\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE`\n- Default: `woodpecker`\n\nThe namespace to create worker Pods in.\n\n---\n\n### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION\n\n- Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION`\n- Default: `false`\n\nEnables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation.\n\nWith this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker.\n\n### BACKEND_K8S_VOLUME_SIZE\n\n- Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE`\n- Default: `10G`\n\nThe volume size of the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS`\n- Default: none\n\nThe storage class to use for the pipeline volume.\n\n---\n\n### BACKEND_K8S_STORAGE_RWX\n\n- Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX`\n- Default: `true`\n\nDetermines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead.\n\n---\n\n### BACKEND_K8S_POD_LABELS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS`\n- Default: none\n\nAdditional labels to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-label\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if additional Pod labels can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS`\n- Default: none\n\nAdditional annotations to apply to worker Pods. Must be a YAML object, e.g. `{\"example.com/test-annotation\":\"test-value\"}`.\n\n---\n\n### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP`\n- Default: `false`\n\nDetermines if Pod annotations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS`\n- Default: none\n\nAdditional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{\"effect\":\"NoSchedule\",\"key\":\"jobs\",\"operator\":\"Exists\"}]`.\n\n---\n\n### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP`\n- Default: `true`\n\nDetermines if Pod tolerations can be defined from a step's backend options.\n\n---\n\n### BACKEND_K8S_POD_NODE_SELECTOR\n\n- Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR`\n- Default: none\n\nAdditional node selector to apply to worker pods. Must be a YAML object, e.g. `{\"topology.kubernetes.io/region\":\"eu-central-1\"}`.\n\n---\n\n### BACKEND_K8S_SECCTX_NONROOT <!-- cspell:ignore SECCTX NONROOT -->\n\n- Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT`\n- Default: `false`\n\nDetermines if containers must be required to run as non-root users.\n\n---\n\n### BACKEND_K8S_PULL_SECRET_NAMES\n\n- Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`\n- Default: none\n\nSecret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).\n\n---\n\n### BACKEND_K8S_PRIORITY_CLASS\n\n- Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS`\n- Default: none, which will use the default priority class configured in Kubernetes\n\nWhich [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/30-local.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Local\n\n:::danger\nThe local backend executes pipelines on the local system without any isolation.\n:::\n\n:::note\nCurrently we do not support [services](../../../20-usage/60-services.md) for this backend.\n[Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095).\n:::\n\nSince the commands run directly in the same context as the agent (same user, same\nfilesystem), a malicious pipeline could be used to access the agent\nconfiguration especially the `WOODPECKER_AGENT_SECRET` variable.\n\nIt is recommended to use this backend only for private setup where the code and\npipeline can be trusted. It should not be used in a public instance where\nanyone can submit code or add new repositories. The agent should not run as a privileged user (root).\n\nThe local backend will use a random directory in `$TMPDIR` to store the cloned\ncode and execute commands.\n\nIn order to use this backend, you need to download (or build) the\n[agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine.\n\n## Step specific configuration\n\n### Shell\n\nThe `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is\nused to run the commands.\n\n```yaml title=\".woodpecker.yaml\"\nsteps:\n  - name: build\n    image: bash\n    commands: [...]\n```\n\n### Plugins\n\n```yaml\nsteps:\n  - name: build\n    image: /usr/bin/tree\n```\n\nIf no commands are provided, plugins are treated in the usual manner.\nIn the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path.\n\n## Environment variables\n\n### BACKEND_LOCAL_TEMP_DIR\n\n- Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR`\n- Default: default temp directory\n\nDirectory to create folders for workflows.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/50-custom.md",
    "content": "# Custom\n\nIf none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend:\n\n```go\npackage main\n\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core\"\n  backendTypes \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc main() {\n  core.RunAgent([]backendTypes.Backend{\n    yourBackend,\n  })\n}\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/_category_.yaml",
    "content": "label: 'Backends'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/11-overview.md",
    "content": "# Forges\n\n## Supported features\n\n| Feature                                                                                                                | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) |\n| ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- |\n| Event: Push                                                                                                            | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Tag                                                                                                             | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Pull-Request                                                                                                    | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| Event: Release                                                                                                         | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| Event: Deploy¹                                                                                                         | :white_check_mark:     | :x:                  | :x:                      | :x:                    | :x:                          | :x:                                                |\n| [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :x:                          | :x:                                                |\n| [Multiple workflows](../../../20-usage/25-workflows.md)                                                                | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n| [when.path filter](../../../20-usage/20-workflow-syntax.md#path)                                                       | :white_check_mark:     | :white_check_mark:   | :white_check_mark:       | :white_check_mark:     | :white_check_mark:           | :white_check_mark:                                 |\n\n¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks.\n\nIn addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/20-github.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitHub\n\nWoodpecker comes with built-in support for GitHub and GitHub Enterprise.\nTo use Woodpecker with GitHub the following environment variables should be set for the server component:\n\n```ini\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID\nWOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET\n```\n\nYou will get these values from GitHub when you register your OAuth application.\nTo do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App.\n\n:::warning\nDo not use a \"GitHub App\" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically)\n:::\n\n## App Settings\n\n- Name: An arbitrary name for your App\n- Homepage URL: The URL of your Woodpecker instance\n- Callback URL: `https://<your-woodpecker-instance>/authorize`\n- (optional) Upload the Woodpecker Logo: <https://avatars.githubusercontent.com/u/84780935?s=200&v=4>\n\n## Client Secret Creation\n\nAfter your App has been created, you can generate a client secret.\nUse this one for the `WOODPECKER_GITHUB_SECRET` environment variable.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITHUB\n\n- Name: `WOODPECKER_GITHUB`\n- Default: `false`\n\nEnables the GitHub driver.\n\n---\n\n### GITHUB_URL\n\n- Name: `WOODPECKER_GITHUB_URL`\n- Default: `https://github.com`\n\nConfigures the GitHub server address.\n\n---\n\n### GITHUB_CLIENT\n\n- Name: `WOODPECKER_GITHUB_CLIENT`\n- Default: none\n\nConfigures the GitHub OAuth client id to authorize access.\n\n---\n\n### GITHUB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITHUB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath.\n\n---\n\n### GITHUB_SECRET\n\n- Name: `WOODPECKER_GITHUB_SECRET`\n- Default: none\n\nConfigures the GitHub OAuth client secret. This is used to authorize access.\n\n---\n\n### GITHUB_SECRET_FILE\n\n- Name: `WOODPECKER_GITHUB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath.\n\n---\n\n### GITHUB_MERGE_REF\n\n- Name: `WOODPECKER_GITHUB_MERGE_REF`\n- Default: `true`\n\n---\n\n### GITHUB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITHUB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### GITHUB_PUBLIC_ONLY\n\n- Name: `WOODPECKER_GITHUB_PUBLIC_ONLY`\n- Default: `false`\n\nConfigures the GitHub OAuth client to only obtain a token that can manage public repositories.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/30-gitea.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Gitea\n\nWoodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITEA=true\nWOODPECKER_GITEA_URL=YOUR_GITEA_URL\nWOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT\nWOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET\n```\n\n## Gitea on the same host with containers\n\nIf you have Gitea also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `gitea`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea\n```\n\n## Registration\n\n### User OAuth Application\n\nRegister your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\n### System-wide OAuth Application\n\nIf you are the administrator of both Gitea and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Gitea site administrator level and are visible to all users.\n\nTo create a system-wide OAuth application in Gitea:\n\n1. Navigate to the site administration settings at `https://gitea.<host>/admin/settings/applications`\n2. Create a new OAuth2 application under the \"OAuth2 Applications\" section\n3. Configure the application with the same settings as above (callback URL, etc.)\n4. Use the generated client id and secret for Woodpecker configuration\n\nSystem-wide applications are particularly useful for:\n\n- Shared CI/CD environments where multiple users need Woodpecker access\n- Organizations that want centralized control over OAuth applications\n- Preventing user-level application quotas from affecting CI/CD operations\n\n### Local Connections\n\nIf you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook).\n\n![gitea oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITEA\n\n- Name: `WOODPECKER_GITEA`\n- Default: `false`\n\nEnables the Gitea driver.\n\n---\n\n### GITEA_URL\n\n- Name: `WOODPECKER_GITEA_URL`\n- Default: `https://try.gitea.io`\n\nConfigures the Gitea server address.\n\n---\n\n### GITEA_CLIENT\n\n- Name: `WOODPECKER_GITEA_CLIENT`\n- Default: none\n\nConfigures the Gitea OAuth client id. This is used to authorize access.\n\n---\n\n### GITEA_CLIENT_FILE\n\n- Name: `WOODPECKER_GITEA_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath\n\n---\n\n### GITEA_SECRET\n\n- Name: `WOODPECKER_GITEA_SECRET`\n- Default: none\n\nConfigures the Gitea OAuth client secret. This is used to authorize access.\n\n---\n\n### GITEA_SECRET_FILE\n\n- Name: `WOODPECKER_GITEA_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITEA_SECRET` from the specified filepath\n\n---\n\n### GITEA_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITEA_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/35-forgejo.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Forgejo\n\nWoodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_FORGEJO=true\nWOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL\nWOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT\nWOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET\n```\n\n## Forgejo on the same host with containers\n\nIf you have Forgejo also running on the same host within a container, make sure the agent does have access to it.\nThe agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in.\nOtherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1).\n\nTo configure the Docker network if the network's name is `forgejo`, configure it like this:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   [...]\n   woodpecker-agent:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo\n```\n\n## Registration\n\n### User OAuth Application\n\nRegister your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo.<host>/user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https://<host>/authorize` as the path.\n\n### System-wide OAuth Application\n\nIf you are the administrator of both Forgejo and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Forgejo site administrator level and are visible to all users.\n\nTo create a system-wide OAuth application in Forgejo:\n\n1. Navigate to the site administration settings at `https://forgejo.<host>/admin/settings/applications`\n2. Create a new OAuth2 application under the \"OAuth2 Applications\" section\n3. Configure the application with the same settings as above (callback URL, etc.)\n4. Use the generated client id and secret for Woodpecker configuration\n\nSystem-wide applications are particularly useful for:\n\n- Shared CI/CD environments where multiple users need Woodpecker access\n- Organizations that want centralized control over OAuth applications\n- Preventing user-level application quotas from affecting CI/CD operations\n\n### Local Connections\n\nIf you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`).\n\n```ini\n[webhook]\nALLOWED_HOST_LIST=external,loopback\n```\n\nFor reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook).\n\n![forgejo oauth setup](gitea_oauth.gif)\n\n:::warning\nMake sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api).\n:::\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### FORGEJO\n\n- Name: `WOODPECKER_FORGEJO`\n- Default: `false`\n\nEnables the Forgejo driver.\n\n---\n\n### FORGEJO_URL\n\n- Name: `WOODPECKER_FORGEJO_URL`\n- Default: `https://next.forgejo.org`\n\nConfigures the Forgejo server address.\n\n---\n\n### FORGEJO_CLIENT\n\n- Name: `WOODPECKER_FORGEJO_CLIENT`\n- Default: none\n\nConfigures the Forgejo OAuth client id. This is used to authorize access.\n\n---\n\n### FORGEJO_CLIENT_FILE\n\n- Name: `WOODPECKER_FORGEJO_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath\n\n---\n\n### FORGEJO_SECRET\n\n- Name: `WOODPECKER_FORGEJO_SECRET`\n- Default: none\n\nConfigures the Forgejo OAuth client secret. This is used to authorize access.\n\n---\n\n### FORGEJO_SECRET_FILE\n\n- Name: `WOODPECKER_FORGEJO_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath\n\n---\n\n### FORGEJO_SKIP_VERIFY\n\n- Name: `WOODPECKER_FORGEJO_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/40-gitlab.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# GitLab\n\nWoodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_GITLAB=true\nWOODPECKER_GITLAB_URL=http://gitlab.mycompany.com\nWOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82\nWOODPECKER_GITLAB_SECRET=30f5064039e6b359e075\n```\n\n## Registration\n\nYou must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application.\n\nPlease use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application.\n\nIf you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`.\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### GITLAB\n\n- Name: `WOODPECKER_GITLAB`\n- Default: `false`\n\nEnables the GitLab driver.\n\n---\n\n### GITLAB_URL\n\n- Name: `WOODPECKER_GITLAB_URL`\n- Default: `https://gitlab.com`\n\nConfigures the GitLab server address.\n\n---\n\n### GITLAB_CLIENT\n\n- Name: `WOODPECKER_GITLAB_CLIENT`\n- Default: none\n\nConfigures the GitLab OAuth client id. This is used to authorize access.\n\n---\n\n### GITLAB_CLIENT_FILE\n\n- Name: `WOODPECKER_GITLAB_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath\n\n---\n\n### GITLAB_SECRET\n\n- Name: `WOODPECKER_GITLAB_SECRET`\n- Default: none\n\nConfigures the GitLab OAuth client secret. This is used to authorize access.\n\n---\n\n### GITLAB_SECRET_FILE\n\n- Name: `WOODPECKER_GITLAB_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath\n\n---\n\n### GITLAB_SKIP_VERIFY\n\n- Name: `WOODPECKER_GITLAB_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/50-bitbucket.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket\n\nWoodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables:\n\n```ini\nWOODPECKER_BITBUCKET=true\nWOODPECKER_BITBUCKET_CLIENT=... # called \"Key\" in Bitbucket\nWOODPECKER_BITBUCKET_SECRET=...\n```\n\n## Registration\n\nYou must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`).\n\nPlease set a name and set the `Callback URL` like this:\n\n```uri\nhttps://<your-woodpecker-address>/authorize\n```\n\n![bitbucket oauth setup](bitbucket_oauth.png)\n\nPlease also be sure to check the following permissions:\n\n- Account: Email, Read\n- Workspace membership: Read\n- Projects: Read\n- Repositories: Read\n- Pull requests: Read\n- Webhooks: Read and Write\n\n![bitbucket permissions](bitbucket_permissions.png)\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET\n\n- Name: `WOODPECKER_BITBUCKET`\n- Default: `false`\n\nEnables the Bitbucket driver.\n\n---\n\n### BITBUCKET_CLIENT\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT`\n- Default: none\n\nConfigures the Bitbucket OAuth client key. This is used to authorize access.\n\n---\n\n### BITBUCKET_CLIENT_FILE\n\n- Name: `WOODPECKER_BITBUCKET_CLIENT_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath\n\n---\n\n### BITBUCKET_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_SECRET`\n- Default: none\n\nConfigures the Bitbucket OAuth client secret. This is used to authorize access.\n\n---\n\n### BITBUCKET_SECRET_FILE\n\n- Name: `WOODPECKER_BITBUCKET_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath\n\n## Known Issues\n\nBitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details.\n\n## Missing Features\n\nPath filters for pull requests are not supported. We are interested in patches to include this functionality.\nIf you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md",
    "content": "---\ntoc_max_heading_level: 2\n---\n\n# Bitbucket Datacenter / Server\n\n:::warning\nWoodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash.\n:::\n\nTo enable Bitbucket Server you should configure the Woodpecker container using the following environment variables:\n\n```diff title=\"docker-compose.yaml\"\n services:\n   woodpecker-server:\n     [...]\n     environment:\n       - [...]\n+      - WOODPECKER_BITBUCKET_DC=true\n+      - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo\n+      - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx\n+      - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy\n+      - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com\n+      - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true\n\n   woodpecker-agent:\n     [...]\n```\n\n## Service Account\n\nWoodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories.\n\n## Registration\n\nWoodpecker must be registered with Bitbucket Datacenter / Server.\nIn the administration section of Bitbucket choose \"Application Links\" and then \"Create link\".\nWoodpecker should be listed as \"External Application\" and the direction should be set to \"Incoming\".\nNote the client id and client secret of the registration to be used in the configuration of Woodpecker.\n\nSee also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html).\n\n## Configuration\n\nThis is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations.\n\n---\n\n### BITBUCKET_DC\n\n- Name: `WOODPECKER_BITBUCKET_DC`\n- Default: `false`\n\nEnables the Bitbucket Server driver.\n\n---\n\n### BITBUCKET_DC_URL\n\n- Name: `WOODPECKER_BITBUCKET_DC_URL`\n- Default: none\n\nConfigures the Bitbucket Server address.\n\n---\n\n### BITBUCKET_DC_CLIENT_ID\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client id.\n\n---\n\n### BITBUCKET_DC_CLIENT_SECRET\n\n- Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET`\n- Default: none\n\nConfigures your Bitbucket Server OAUth 2.0 client secret.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME`\n- Default: none\n\nThis username is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_USERNAME_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD`\n- Default: none\n\nThe password is used to authenticate and clone all private repositories.\n\n---\n\n### BITBUCKET_DC_GIT_PASSWORD_FILE\n\n- Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath\n\n---\n\n### BITBUCKET_DC_SKIP_VERIFY\n\n- Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY`\n- Default: `false`\n\nConfigure if SSL verification should be skipped.\n\n---\n\n### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN\n\n- Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN`\n- Default: `false`\n\nWhen enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/_category_.yaml",
    "content": "label: 'Forges'\ncollapsible: true\ncollapsed: true\nlink:\n  type: 'doc'\n  id: 'overview'\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/30-agent.md",
    "content": "---\ntoc_max_heading_level: 3\n---\n\n# Agent\n\nAgents are configured by the command line or environment variables. At the minimum you need the following information:\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\n```\n\nThe following are automatically set and can be overridden:\n\n- `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname\n- `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1\n\n## Workflows per agent\n\nBy default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent.\n\n```ini\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=\"your-shared-secret-goes-here\"\nWOODPECKER_MAX_WORKFLOWS=4\n```\n\n## Agent registration\n\nWhen the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before.\n\nThere are two types of tokens to connect an agent to the server:\n\n### Using system token\n\nA _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents.\n\nIn that case registration process would be as following:\n\n1. The first time the agent communicates with the server, it is using the system token\n1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent\n1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`)\n1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server\n\n### Using agent token\n\nAn _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`.\n\nTo get an _agent token_ you have to register the agent manually in the server using the UI:\n\n1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent`\n   ![Agent creation](./new-agent-registration.png)\n   ![Agent created](./new-agent-created.png)\n1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET`\n1. The agent will connect to the server using the provided token and will update its status in the UI:\n   ![Agent connected](./new-agent-connected.png)\n\n## Environment variables\n\n### SERVER\n\n- Name: `WOODPECKER_SERVER`\n- Default: `localhost:9000`\n\nConfigures gRPC address of the server.\n\n---\n\n### USERNAME\n\n- Name: `WOODPECKER_USERNAME`\n- Default: `x-oauth-basic`\n\nThe gRPC username.\n\n---\n\n### AGENT_SECRET\n\n- Name: `WOODPECKER_AGENT_SECRET`\n- Default: none\n\nA shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`.\n\n---\n\n### AGENT_SECRET_FILE\n\n- Name: `WOODPECKER_AGENT_SECRET_FILE`\n- Default: none\n\nRead the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf`\n\n---\n\n### LOG_LEVEL\n\n- Name: `WOODPECKER_LOG_LEVEL`\n- Default: `info`\n\nConfigures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty.\n\n---\n\n### DEBUG_PRETTY\n\n- Name: `WOODPECKER_DEBUG_PRETTY`\n- Default: `false`\n\nEnable pretty-printed debug output.\n\n---\n\n### DEBUG_NOCOLOR\n\n- Name: `WOODPECKER_DEBUG_NOCOLOR`\n- Default: `true`\n\nDisable colored debug output.\n\n---\n\n### HOSTNAME\n\n- Name: `WOODPECKER_HOSTNAME`\n- Default: none\n\nConfigures the agent hostname.\n\n---\n\n### AGENT_CONFIG_FILE\n\n- Name: `WOODPECKER_AGENT_CONFIG_FILE`\n- Default: `/etc/woodpecker/agent.conf`\n\nConfigures the path of the agent config file.\n\n---\n\n### MAX_WORKFLOWS\n\n- Name: `WOODPECKER_MAX_WORKFLOWS`\n- Default: `1`\n\nConfigures the number of parallel workflows.\n\n---\n\n### AGENT_SINGLE_WORKFLOW\n\n- Name: `WOODPECKER_AGENT_SINGLE_WORKFLOW`\n- Default: `false`\n\nConfigures the agent to exit (shutdown) after executing one workflow. When configured,\n`WOODPECKER_MAX_WORKFLOWS` is forced to 1.\n\nThis one-shot mode is useful in ephemeral environments that are provisioned on demand\nby external automation — for example, when an autoscaler spins up a dedicated machine. In these setups, the agent starts, executes exactly one workflow, and exits, allowing the environment to be cleanly torn down afterward.\n\n---\n\n### AGENT_LABELS\n\n- Name: `WOODPECKER_AGENT_LABELS`\n- Default: none\n\nConfigures custom labels for the agent, to let workflows filter by it.\nUse a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard.\nIf you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched.\nBy default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed.\nTo learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels).\n\n---\n\n### HEALTHCHECK\n\n- Name: `WOODPECKER_HEALTHCHECK`\n- Default: `true`\n\nEnable healthcheck endpoint.\n\n---\n\n### HEALTHCHECK_ADDR\n\n- Name: `WOODPECKER_HEALTHCHECK_ADDR`\n- Default: `:3000`\n\nConfigures healthcheck endpoint address.\n\n---\n\n### KEEPALIVE_TIME\n\n- Name: `WOODPECKER_KEEPALIVE_TIME`\n- Default: none\n\nAfter a duration of this time of no activity, the agent pings the server to check if the transport is still alive.\n\n---\n\n### KEEPALIVE_TIMEOUT\n\n- Name: `WOODPECKER_KEEPALIVE_TIMEOUT`\n- Default: `20s`\n\nAfter pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity.\n\n---\n\n### GRPC_SECURE\n\n- Name: `WOODPECKER_GRPC_SECURE`\n- Default: `false`\n\nConfigures if the connection to `WOODPECKER_SERVER` should be made using a secure transport.\n\n---\n\n### GRPC_VERIFY\n\n- Name: `WOODPECKER_GRPC_VERIFY`\n- Default: `true`\n\nConfigures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`.\n\n---\n\n## RETRY_TIMEOUT\n\n- Name: `WOODPECKER_RETRY_TIMEOUT`\n- Default: `2m`\n\nSet how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up.\n\n:::warning\nIf set to 0 we retry forever.\n:::\n\n---\n\n### BACKEND\n\n- Name: `WOODPECKER_BACKEND`\n- Default: `auto-detect`\n\nConfigures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`.\n\n### BACKEND_DOCKER\\_\\*\n\nSee [Docker backend configuration](./11-backends/10-docker.md#environment-variables)\n\n---\n\n### BACKEND_K8S\\_\\*\n\nSee [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables)\n\n---\n\n### BACKEND_LOCAL\\_\\*\n\nSee [Local backend configuration](./11-backends/30-local.md#environment-variables)\n\n### Advanced Settings\n\n:::warning\nOnly change these If you know what you do.\n:::\n\n#### CONNECT_RETRY_COUNT\n\n- Name: `WOODPECKER_CONNECT_RETRY_COUNT`\n- Default: `5`\n\nConfigures number of times agent retries to connect to the server.\n\n#### CONNECT_RETRY_DELAY\n\n- Name: `WOODPECKER_CONNECT_RETRY_DELAY`\n- Default: `2s`\n\nConfigures delay between agent connection retries to the server.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/40-autoscaler.md",
    "content": "# Autoscaler\n\nIf your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler).\n\nPlease note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap).\n\n## Setup\n\n### docker compose\n\nIf you are using docker compose you can add the following to your `docker-compose.yaml` file:\n\n```yaml\nservices:\n  woodpecker-server:\n    image: woodpeckerci/woodpecker-server:next\n    [...]\n\n  woodpecker-autoscaler:\n    image: woodpeckerci/autoscaler:next\n    restart: always\n    depends_on:\n      - woodpecker-server\n    environment:\n      - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url\n      - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user\n      - WOODPECKER_MIN_AGENTS=0\n      - WOODPECKER_MAX_AGENTS=3\n      - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time\n      - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include \"https://\" in the value.\n      - WOODPECKER_GRPC_SECURE=true\n      - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents\n      - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below\n      - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/10-configuration/_category_.yaml",
    "content": "label: 'Configuration'\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/30-administration/_category_.yaml",
    "content": "label: 'Administration'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/40-cli.md",
    "content": "# CLI\n\n# NAME\n\nwoodpecker-cli - command line utility\n\n# SYNOPSIS\n\nwoodpecker-cli\n\n```\n[--config|-c]=[value]\n[--disable-update-check]\n[--log-file]=[value]\n[--log-level]=[value]\n[--nocolor]\n[--pretty]\n[--server|-s]=[value]\n[--skip-verify]\n[--socks-proxy-off]\n[--socks-proxy]=[value]\n[--token|-t]=[value]\n```\n\n# DESCRIPTION\n\nWoodpecker command line utility\n\n**Usage**:\n\n```\nwoodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...]\n```\n\n# GLOBAL OPTIONS\n\n**--config, -c**=\"\": path to config file\n\n**--disable-update-check**: disable update check (default: false)\n\n**--log-file**=\"\": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr)\n\n**--log-level**=\"\": set logging level (default: info)\n\n**--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false)\n\n**--pretty**: enable pretty-printed debug output (default: true)\n\n**--server, -s**=\"\": server address\n\n**--skip-verify**: skip ssl verification (default: false)\n\n**--socks-proxy**=\"\": socks proxy address\n\n**--socks-proxy-off**: socks proxy ignored (default: false)\n\n**--token, -t**=\"\": server auth token\n\n\n# COMMANDS\n\n## admin\n\nmanage server settings\n\n### log-level\n\nretrieve log level from server, or set it with [level]\n\n### org\n\nmanage organizations\n\n#### ls\n\nlist organizations\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nOrganization ID: {{ .ID }}\\n)\n\n### registry\n\nmanage global registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage global secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--value**=\"\": secret value\n\n### user\n\nmanage users\n\n#### add\n\nadd a user\n\n#### ls\n\nlist all users\n\n**--format**=\"\": format output (default: {{ .Login }})\n\n#### rm\n\nremove a user\n\n#### show\n\nshow user information\n\n**--format**=\"\": format output (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## context, ctx\n\nmanage contexts\n\n### list, ls\n\nlist all contexts\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: do not print headers in output (default: false)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### use\n\nset the current context\n\n### delete, rm\n\ndelete a context\n\n### rename\n\nrename a context\n\n## exec\n\nexecute a local pipeline\n\n**--backend-docker-api-version**=\"\": the version of the API to reach, leave empty for latest.\n\n**--backend-docker-cert**=\"\": path to load the TLS certificates for connecting to docker server\n\n**--backend-docker-host**=\"\": path to docker socket or url to the docker server\n\n**--backend-docker-ipv6**: backend docker enable IPV6 (default: false)\n\n**--backend-docker-limit-cpu-quota**=\"\": impose a cpu quota (default: 0)\n\n**--backend-docker-limit-cpu-set**=\"\": set the cpus allowed to execute containers\n\n**--backend-docker-limit-cpu-shares**=\"\": change the cpu shares (default: 0)\n\n**--backend-docker-limit-mem**=\"\": maximum memory allowed in bytes (default: 0)\n\n**--backend-docker-limit-mem-swap**=\"\": maximum memory used for swap in bytes (default: 0)\n\n**--backend-docker-limit-shm-size**=\"\": docker /dev/shm allowed in bytes (default: 0)\n\n**--backend-docker-network**=\"\": backend docker network\n\n**--backend-docker-stop-timeout**=\"\": seconds Woodpecker waits for a container to stop gracefully before forcefully killing it (default: 20)\n\n**--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true)\n\n**--backend-docker-volumes**=\"\": backend docker volumes (comma separated)\n\n**--backend-engine**=\"\": backend engine to run pipelines on (default: auto-detect)\n\n**--backend-http-proxy**=\"\": if set, pass the environment variable down as \"HTTP_PROXY\" to steps\n\n**--backend-https-proxy**=\"\": if set, pass the environment variable down as \"HTTPS_PROXY\" to steps\n\n**--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false)\n\n**--backend-k8s-namespace**=\"\": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker)\n\n**--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false)\n\n**--backend-k8s-pod-affinity**=\"\": backend k8s Agent-wide worker pod affinity, in YAML format\n\n**--backend-k8s-pod-affinity-allow-from-step**: whether to allow using affinity from step's backend options (default: false)\n\n**--backend-k8s-pod-annotations**=\"\": backend k8s additional Agent-wide worker pod annotations\n\n**--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false)\n\n**--backend-k8s-pod-image-pull-secret-names**=\"\": backend k8s pull secret names for private registries\n\n**--backend-k8s-pod-labels**=\"\": backend k8s additional Agent-wide worker pod labels\n\n**--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false)\n\n**--backend-k8s-pod-node-selector**=\"\": backend k8s Agent-wide worker pod node selector\n\n**--backend-k8s-pod-tolerations**=\"\": backend k8s Agent-wide worker pod tolerations\n\n**--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true)\n\n**--backend-k8s-priority-class**=\"\": which kubernetes priority class to assign to created job pods\n\n**--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false)\n\n**--backend-k8s-stop-timeout**=\"\": seconds Woodpecker waits for pods to stop gracefully before forcefully killing them (default: 20)\n\n**--backend-k8s-storage-class**=\"\": backend k8s storage class\n\n**--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true)\n\n**--backend-k8s-volume-size**=\"\": backend k8s volume size (default 10G) (default: 10G)\n\n**--backend-local-isolated-home**: set HOME, USERPROFILE and other variables to an isolated directory, if false we ignore netrc (default: true)\n\n**--backend-local-temp-dir**=\"\": set a different temp dir to clone workflows into (default: system temporary directory)\n\n**--backend-no-proxy**=\"\": if set, pass the environment variable down as \"NO_PROXY\" to steps\n\n**--commit-author-avatar**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_AVATAR\".\n\n**--commit-author-email**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR_EMAIL\".\n\n**--commit-author-name**=\"\": Set the metadata environment variable \"CI_COMMIT_AUTHOR\".\n\n**--commit-branch**=\"\": Set the metadata environment variable \"CI_COMMIT_BRANCH\". (default: main)\n\n**--commit-message**=\"\": Set the metadata environment variable \"CI_COMMIT_MESSAGE\".\n\n**--commit-pull-labels**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_LABELS\".\n\n**--commit-pull-milestone**=\"\": Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_MILESTONE\".\n\n**--commit-ref**=\"\": Set the metadata environment variable \"CI_COMMIT_REF\".\n\n**--commit-refspec**=\"\": Set the metadata environment variable \"CI_COMMIT_REFSPEC\".\n\n**--commit-release-is-pre**: Set the metadata environment variable \"CI_COMMIT_PRERELEASE\". (default: false)\n\n**--commit-sha**=\"\": Set the metadata environment variable \"CI_COMMIT_SHA\".\n\n**--env**=\"\": Set the metadata environment variable \"CI_ENV\".\n\n**--forge-type**=\"\": Set the metadata environment variable \"CI_FORGE_TYPE\".\n\n**--forge-url**=\"\": Set the metadata environment variable \"CI_FORGE_URL\".\n\n**--local**: run from local directory (default: true)\n\n**--metadata-file**=\"\": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags\n\n**--netrc-machine**=\"\": \n\n**--netrc-password**=\"\": \n\n**--netrc-username**=\"\": \n\n**--network**=\"\": external networks\n\n**--pipeline-changed-files**=\"\": Set the metadata environment variable \"CI_PIPELINE_FILES\", either json formatted list of strings, or comma separated string list.\n\n**--pipeline-created**=\"\": Set the metadata environment variable \"CI_PIPELINE_CREATED\". (default: 0)\n\n**--pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TASK\".\n\n**--pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TARGET\".\n\n**--pipeline-event**=\"\": Set the metadata environment variable \"CI_PIPELINE_EVENT\". (default: manual)\n\n**--pipeline-number**=\"\": Set the metadata environment variable \"CI_PIPELINE_NUMBER\". (default: 0)\n\n**--pipeline-parent**=\"\": Set the metadata environment variable \"CI_PIPELINE_PARENT\". (default: 0)\n\n**--pipeline-started**=\"\": Set the metadata environment variable \"CI_PIPELINE_STARTED\". (default: 0)\n\n**--pipeline-url**=\"\": Set the metadata environment variable \"CI_PIPELINE_FORGE_URL\".\n\n**--plugins-privileged**=\"\": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none\n\n**--prev-commit-author-avatar**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_AVATAR\".\n\n**--prev-commit-author-email**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_EMAIL\".\n\n**--prev-commit-author-name**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR\".\n\n**--prev-commit-branch**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_BRANCH\".\n\n**--prev-commit-message**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_MESSAGE\".\n\n**--prev-commit-ref**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REF\".\n\n**--prev-commit-refspec**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_REFSPEC\".\n\n**--prev-commit-sha**=\"\": Set the metadata environment variable \"CI_PREV_COMMIT_SHA\".\n\n**--prev-pipeline-created**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_CREATED\". (default: 0)\n\n**--prev-pipeline-deploy-task**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TASK\".\n\n**--prev-pipeline-deploy-to**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TARGET\".\n\n**--prev-pipeline-event**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_EVENT\".\n\n**--prev-pipeline-finished**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FINISHED\". (default: 0)\n\n**--prev-pipeline-number**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_NUMBER\". (default: 0)\n\n**--prev-pipeline-started**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STARTED\". (default: 0)\n\n**--prev-pipeline-status**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_STATUS\".\n\n**--prev-pipeline-url**=\"\": Set the metadata environment variable \"CI_PREV_PIPELINE_FORGE_URL\".\n\n**--repo**=\"\": Set the full name to derive metadata environment variables \"CI_REPO\", \"CI_REPO_NAME\" and \"CI_REPO_OWNER\".\n\n**--repo-clone-ssh-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_SSH_URL\".\n\n**--repo-clone-url**=\"\": Set the metadata environment variable \"CI_REPO_CLONE_URL\".\n\n**--repo-default-branch**=\"\": Set the metadata environment variable \"CI_REPO_DEFAULT_BRANCH\". (default: main)\n\n**--repo-path**=\"\": path to local repository\n\n**--repo-private**=\"\": Set the metadata environment variable \"CI_REPO_PRIVATE\".\n\n**--repo-remote-id**=\"\": Set the metadata environment variable \"CI_REPO_REMOTE_ID\".\n\n**--repo-trusted-network**: Set the metadata environment variable \"CI_REPO_TRUSTED_NETWORK\". (default: false)\n\n**--repo-trusted-security**: Set the metadata environment variable \"CI_REPO_TRUSTED_SECURITY\". (default: false)\n\n**--repo-trusted-volumes**: Set the metadata environment variable \"CI_REPO_TRUSTED_VOLUMES\". (default: false)\n\n**--repo-url**=\"\": Set the metadata environment variable \"CI_REPO_URL\".\n\n**--secrets**=\"\": map of secrets, ex. 'secret=\"val\",secret2=\"value2\"'\n\n**--secrets-file**=\"\": path to yaml file with secrets map\n\n**--system-host**=\"\": Set the metadata environment variable \"CI_SYSTEM_HOST\".\n\n**--system-name**=\"\": Set the metadata environment variable \"CI_SYSTEM_NAME\". (default: woodpecker)\n\n**--system-platform**=\"\": Set the metadata environment variable \"CI_SYSTEM_PLATFORM\".\n\n**--system-url**=\"\": Set the metadata environment variable \"CI_SYSTEM_URL\". (default: https://github.com/woodpecker-ci/woodpecker)\n\n**--timeout**=\"\": pipeline timeout (default: 1h0m0s)\n\n**--volumes**=\"\": pipeline volumes\n\n**--workflow-name**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NAME\".\n\n**--workflow-number**=\"\": Set the metadata environment variable \"CI_WORKFLOW_NUMBER\". (default: 0)\n\n**--workspace-base**=\"\":  (default: /woodpecker)\n\n**--workspace-path**=\"\":  (default: src)\n\n## info\n\nshow information about the current user\n\n**--format**=\"\": format output (deprecated) (default: User: {{ .Login }}\\nEmail: {{ .Email }})\n\n## lint\n\nlint a pipeline configuration file\n\n**--plugins-privileged**=\"\": allow plugins to run in privileged mode, if set empty, there is no\n\n**--plugins-trusted-clone**=\"\": plugins that are trusted to handle Git credentials in cloning steps (default: \"docker.io/woodpeckerci/plugin-git:2.9.0\", \"docker.io/woodpeckerci/plugin-git\", \"quay.io/woodpeckerci/plugin-git\")\n\n**--strict**: treat warnings as errors (default: false)\n\n## org\n\nmanage organizations\n\n### registry\n\nmanage organization registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--password**=\"\": registry password\n\n**--username**=\"\": registry username\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": secret limited to these events\n\n**--image**=\"\": secret limited to these images\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these event\n\n**--image**=\"\": limit secret to these image\n\n**--name**=\"\": secret name\n\n**--organization, --org**=\"\": organization id or full name (e.g. 123 or octocat)\n\n**--value**=\"\": secret value\n\n## pipeline\n\nmanage pipelines\n\n### approve\n\napprove a pipeline\n\n### create\n\ncreate new pipeline\n\n**--branch**=\"\": branch to create pipeline from\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--var**=\"\": key=value\n\n### decline\n\ndecline a pipeline\n\n### deploy\n\ntrigger a pipeline with the 'deployment' event\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter (default: push)\n\n**--format**=\"\": format output (default: Number: {{ .Number }}\\nStatus: {{ .Status }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nMessage: {{ .Message }}\\nAuthor: {{ .Author }}\\nTarget: {{ .Deploy }}\\n)\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n**--status**=\"\": status filter (default: success)\n\n### last\n\nshow latest pipeline information\n\n**--branch**=\"\": branch name (default: main)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### ls\n\nshow pipeline history\n\n**--after**=\"\": only return pipelines after this date (RFC3339)\n\n**--before**=\"\": only return pipelines before this date (RFC3339)\n\n**--branch**=\"\": branch filter\n\n**--event**=\"\": event filter\n\n**--limit**=\"\": limit the list size (default: 25)\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n**--status**=\"\": status filter\n\n### log\n\nmanage logs\n\n#### purge\n\npurge a log\n\n#### show\n\nshow pipeline logs\n\n### ps\n\nshow pipeline steps\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\\x1b[0m\\nStep: {{ .step.Name }}\\nStarted: {{ .step.Started }}\\nStopped: {{ .step.Stopped }}\\nType: {{ .step.Type }}\\nState: {{ .step.State }}\\n)\n\n### purge\n\npurge pipelines\n\n**--branch**=\"\": remove pipelines of this branch only\n\n**--dry-run**: disable non-read api calls (default: false)\n\n**--keep-min**=\"\": minimum number of pipelines to keep (default: 10)\n\n**--older-than**=\"\": remove pipelines older than the specified time limit (default: 0s)\n\n### queue\n\nshow pipeline queue\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }} #{{ .Number }} \\x1b[0m\\nStatus: {{ .Status }}\\nEvent: {{ .Event }}\\nCommit: {{ .Commit }}\\nBranch: {{ .Branch }}\\nRef: {{ .Ref }}\\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\\nMessage: {{ .Message }}\\n)\n\n### show\n\nshow pipeline information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### start\n\nstart a pipeline\n\n**--param, -p**=\"\": custom parameters to inject into the step environment. Format: KEY=value\n\n### stop\n\nstop a pipeline\n\n## repo\n\nmanage repositories\n\n### add\n\nadd a repository\n\n### chown\n\nassume ownership of a repository\n\n### cron\n\nmanage cron jobs\n\n#### add\n\nadd a cron job\n\n**--branch**=\"\": cron branch\n\n**--enabled**: whether cron is enabled (default: true)\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n#### rm\n\nremove a cron job\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist cron jobs\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow cron job information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a cron job\n\n**--branch**=\"\": cron branch\n\n**--enabled**: whether cron is enabled (default: true)\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nID: {{ .ID }}\\nBranch: {{ .Branch }}\\nSchedule: {{ .Schedule }}\\nNextExec: {{ .NextExec }}\\n)\n\n**--id**=\"\": cron id\n\n**--name**=\"\": cron name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--schedule**=\"\": cron schedule\n\n### ls\n\nlist all repos\n\n**--all**: query all repos, including inactive ones (default: false)\n\n**--format**=\"\": format output (deprecated)\n\n**--org**=\"\": filter by organization\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### registry\n\nmanage registries\n\n#### add\n\nadd a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n#### rm\n\nremove a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist registries\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow registry information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Address }} \\x1b[0m\\nUsername: {{ .Username }}\\nEmail: {{ .Email }}\\n)\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a registry\n\n**--hostname**=\"\": registry hostname (default: docker.io)\n\n**--password**=\"\": registry password\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--username**=\"\": registry username\n\n### rm\n\nremove a repository\n\n### repair\n\nrepair repository webhooks\n\n### secret\n\nmanage secrets\n\n#### add\n\nadd a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n#### rm\n\nremove a secret\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### ls\n\nlist secrets\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### show\n\nshow secret information\n\n**--format**=\"\": format output (deprecated) (default: \\x1b[33m{{ .Name }} \\x1b[0m\\nEvents: {{ list .Events }}\\n{{- if .Images }}\\nImages: {{ list .Images }}\\n{{- else }}\\nImages: <any>\\n{{- end }}\\n)\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n#### update\n\nupdate a secret\n\n**--event**=\"\": limit secret to these events\n\n**--image**=\"\": limit secret to these images\n\n**--name**=\"\": secret name\n\n**--repository, --repo**=\"\": repository id or full name (e.g. 134 or octocat/hello-world)\n\n**--value**=\"\": secret value\n\n### show\n\nshow repository information\n\n**--output**=\"\": output format (default: table)\n\n**--output-no-headers**: don't print headers (default: false)\n\n### sync\n\nsynchronize the repository list\n\n**--format**=\"\": format output (default: \\x1b[33m{{ .FullName }}\\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }}))\n\n### update\n\nupdate a repository\n\n**--config**=\"\": repository configuration path. Example: .woodpecker.yml\n\n**--pipeline-counter**=\"\": repository starting pipeline number (default: 0)\n\n**--require-approval**=\"\": repository requires approval for\n\n**--timeout**=\"\": repository timeout (default: 0s)\n\n**--trusted-network**: repository is network trusted (default: false)\n\n**--trusted-security**: repository is security trusted (default: false)\n\n**--trusted-volumes**: repository is volumes trusted (default: false)\n\n**--unsafe**: allow unsafe operations (default: false)\n\n**--visibility**=\"\": repository visibility\n\n## setup\n\nsetup the woodpecker-cli for the first time\n\n**--context, --ctx**=\"\": name for the context (defaults to 'default')\n\n**--server**=\"\": URL of the woodpecker server\n\n**--token**=\"\": token to authenticate with the woodpecker server\n\n## update\n\nupdate the woodpecker-cli to the latest version\n\n**--force**: force update even if the latest version is already installed (default: false)\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/01-getting-started.md",
    "content": "# Getting started\n\nYou can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea).\n\n## Gitpod\n\nIf you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing:\n\n- An IDE in the browser or bridged to your local VS-Code or Jetbrains\n- A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge\n- A pre-configured Woodpecker server\n- A single pre-configured Woodpecker agent node\n- Our docs preview server\n\nStart Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`.\n\n[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker)\n\n## Preparation for local development\n\n### Install Go\n\nInstall Golang as described by [this guide](https://go.dev/doc/install).\n\n### Install make\n\n> GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (<https://www.gnu.org/software/make/>).\n\nInstall make on:\n\n- Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/)\n- [Windows](https://stackoverflow.com/a/32127632/8461267)\n- Mac OS: `brew install make`\n\n### Install Node.js & `pnpm`\n\nInstall [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation.\n\nFor dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.\n[This guide](https://pnpm.io/installation) describes the installation of `pnpm`.\n\n### Install `pre-commit` (optional)\n\nWoodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code.\nTo apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage).\n\n### Create a `.env` file with your development configuration\n\nSimilar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it.\n\nA common config for debugging would look like this:\n\n```ini\nWOODPECKER_OPEN=true\nWOODPECKER_ADMIN=your-username\n\nWOODPECKER_HOST=http://localhost:8000\n\n# github (sample for a forge config - see /docs/administration/forge/overview for other forges)\nWOODPECKER_GITHUB=true\nWOODPECKER_GITHUB_CLIENT=<redacted>\nWOODPECKER_GITHUB_SECRET=<redacted>\n\n# agent\nWOODPECKER_SERVER=localhost:9000\nWOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system\nWOODPECKER_MAX_WORKFLOWS=1\n\n# enable if you want to develop the UI\n# WOODPECKER_DEV_WWW_PROXY=http://localhost:8010\n\n# if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server\nWOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com\n\n# disable health-checks while debugging (normally not needed while developing)\nWOODPECKER_HEALTHCHECK=false\n\n# WOODPECKER_LOG_LEVEL=debug\n# WOODPECKER_LOG_LEVEL=trace\n```\n\n### Setup OAuth\n\nCreate an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md).\n\n## Developing with VS Code\n\nYou can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it.\n\nTo launch all needed services for local development, you can use \"Woodpecker CI\" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it.\n\nAs a starting guide for programming Go with VS Code, you can use this video guide:\n[![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80)\n\n### Debugging Woodpecker\n\nThe Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points.\n\n![Woodpecker debugging with VS Code](./vscode-debug.png)\n\n## Testing & linting code\n\nTo test or lint parts of Woodpecker, you can run one of the following commands:\n\n```bash\n# test server code\nmake test-server\n\n# test agent code\nmake test-agent\n\n# test cli code\nmake test-cli\n\n# test datastore / database related code like migrations of the server\nmake test-server-datastore\n\n# lint go code\nmake lint\n\n# lint UI code\nmake lint-frontend\n\n# test UI code\nmake test-frontend\n```\n\nIf you want to test a specific Go file, you can also use:\n\n```bash\ngo test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/<path-to-the-package-or-file-to-test>\n```\n\nOr you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands:\n\n![Run test via VS-Code](./vscode-run-test.png)\n\n## Run applications from terminal\n\nIf you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor.\n\n```bash title=\"start server\"\ngo run ./cmd/server\n```\n\n```bash title=\"start agent\"\ngo run ./cmd/agent\n```\n\n```bash title=\"execute cli command\"\ngo run ./cmd/cli [command]\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/02-core-ideas.md",
    "content": "# Core ideas\n\n- A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂).\n- If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle).\n- What is used most often should be default.\n- Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md).\n\n## Addons and extensions\n\nIf you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an\n[addon](../30-administration/10-configuration/100-addons.md), [extension](../20-usage/72-extensions/40-configuration-extension.md) or an\n[external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points:\n\n- Is your change very specific to your setup and unlikely to be used by anyone else?\n- Does your change violate the [guidelines](#guidelines)?\n\nBoth should be false when you open a pull request to get your change into the core repository.\n\n### Guidelines\n\n#### Forges\n\nA new forge must support these features:\n\n- OAuth2\n- Webhooks\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/03-ui.md",
    "content": "# UI Development\n\nTo develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api.\n\n## Setup\n\nThe UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed).\n\nTesting UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files.\n\n![UI Proxy architecture](./ui-proxy.svg)\n\nStart the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.\nAfter starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000).\n\n### Usage with remote server\n\nIf you would like to test your UI changes on a \"real-world\" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables:\n\n- `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org`\n- `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser\n\nThen, open the UI at `http://localhost:8010`.\n\n## Tools and frameworks\n\nThe following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing.\n\n- [Vue 3](https://v3.vuejs.org/)\n  - use `setup` and composition api\n  - place (re-usable) components in `web/src/components/`\n  - views should have a route in `web/src/router.ts` and are located in `web/src/views/`\n- [Tailwind CSS](https://tailwindcss.com/)\n  - use Tailwind classes where possible\n  - if needed extend the Tailwind config to use new classes\n  - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier)\n- [Vite](https://vitejs.dev/) (similar to Webpack)\n- [Typescript](https://www.typescriptlang.org/)\n  - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:)\n- [eslint](https://eslint.org/)\n- [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file\n  - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471)\n\n## Messages and Translations\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source.\nYou must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet)\n\nFor more information about translations see [Translations](./08-translations.md).\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/04-docs.md",
    "content": "# Documentation\n\nThe documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/).\n\nIf you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands:\n\n```bash\ncd docs/\n\npnpm install\n\n# build plugins used by the docs\npnpm build:woodpecker-plugins\n\n# start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually\npnpm start\n\n# or build the docs to deploy it to some static page hosting\npnpm build\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/05-architecture.md",
    "content": "# Architecture\n\n## Module Interactions\n\n![Woodpecker architecture](./woodpecker-architecture.svg)\n\n<!--\n  To update the graph, first look at a simple svg of all module imports:\n  `go run github.com/loov/goda@latest graph 'go.woodpecker-ci.org/woodpecker/v3/...' | dot -Tsvg -o graph.svg`\n\n  generate a new svg of the graph using:\n  `dot -Tsvg woodpecker-architecture.dot -o woodpecker-architecture.svg`\n-->\n\n## System architecture\n\n### main package hierarchy\n\n| package            | meaning                                                        | imports                               |\n| ------------------ | -------------------------------------------------------------- | ------------------------------------- |\n| `cmd/**`           | parse command-line args & environment to stat server/cli/agent | all other                             |\n| `agent/**`         | code only agent (remote worker) will need                      | `pipeline`, `rpc`, `shared`           |\n| `cli/**`           | code only cli tool does need                                   | `pipeline`, `shared`, `woodpecker-go` |\n| `server/**`        | code only server will need                                     | `pipeline`, `rpc`, `shared`           |\n| `pipeline/**`      | core ci/cd engine from parsing to execution                    | `shared`                              |\n| `rpc/**`           | RPC interface for agent-server communication                   | `pipeline`                            |\n| `shared/**`        | code shared for all three main tools (go help utils)           | only std and external libs            |\n| `woodpecker-go/**` | go client for server rest api                                  | std                                   |\n\n### Server\n\n| package              | meaning                                                                        | imports                                                                                                                                                                                      |\n| -------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `server/api/**`      | handle web requests from `server/router`                                       | `pipeline`, `rpc`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) |\n| `server/badges/**`   | generate svg badges for pipelines                                              | `../model`                                                                                                                                                                                   |\n| `server/ccmenu/**`   | generate xml ccmenu for pipelines                                              | `../model`                                                                                                                                                                                   |\n| `server/rpc/**`      | gRPC server agents can connect to                                              | `rpc`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store`                                                                                              |\n| `server/logging/**`  | logging lib for gPRC server to stream logs while running                       | std                                                                                                                                                                                          |\n| `server/model/**`    | structs for store (db) and api (json)                                          | std                                                                                                                                                                                          |\n| `server/pipeline/**` | orchestrate pipelines (TODO: parts of it should move into /pipeline)           | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins`                                                                                                        |\n| `server/pubsub/**`   | pubsub lib for server to push changes to the WebUI                             | std                                                                                                                                                                                          |\n| `server/queue/**`    | queue lib for server where agents pull new pipelines from via gRPC             | `server/model`                                                                                                                                                                               |\n| `server/forge/**`    | forge lib for server to connect and handle forge specific stuff                | `shared`, `server/model`                                                                                                                                                                     |\n| `server/router/**`   | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web`                                                                                                                             |\n| `server/store/**`    | handle database                                                                | `server/model`                                                                                                                                                                               |\n| `server/web/**`      | server SPA                                                                     |                                                                                                                                                                                              |\n\n- `../` = `server/`\n\n### Agent\n\n| package        | meaning                                              | imports                                                |\n| -------------- | ---------------------------------------------------- | ------------------------------------------------------ |\n| `agent/**`     | agent implementation that runs workflows             | `pipeline`, `rpc`, `shared`                            |\n| `agent/rpc/**` | gRPC client for agent-server communication           | `rpc`, `pipeline/backend/types`, std and external libs |\n| `cmd/agent/**` | CLI interface for starting and configuring the agent | `agent`, std and external libs                         |\n\nThe agent is a remote worker that connects to the server via gRPC to receive pipeline execution instructions and report back execution state and logs.\nThe agent polls the server's queue for new work, executes pipeline steps using the pipeline engine, and streams results back to the server.\n\nTODO: Review cmd/agent/core to determine if any logic should be moved into the agent package for better separation of concerns.\n\n### CLI\n\n| package                  | meaning                                                                 | imports                                                                          |\n| ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |\n| `cli/admin/**`           | admin commands for server management (users, secrets, registries, etc.) | `../common`, `../internal`, `woodpecker-go`                                      |\n| `cli/common/**`          | shared utilities and helpers used across all CLI subcommands            | `../internal/config`, `../update`, `shared`                                      |\n| `cli/context/**`         | manage multiple server contexts (connections to different servers)      | `../common`, `../internal/config`, `../output`                                   |\n| `cli/exec/**`            | execute pipelines locally without server orchestration                  | `pipeline`, `../common`, `../lint`, `shared`                                     |\n| `cli/info/**`            | display information about the current user                              | `../common`, `../internal`                                                       |\n| `cli/internal/**`        | internal utilities for HTTP client, auth, and server communication      | `../internal/config`, `woodpecker-go`, `shared`                                  |\n| `cli/internal/config/**` | configuration file management (load, store, credentials)                | std and external libs                                                            |\n| `cli/lint/**`            | validate pipeline configuration files                                   | `pipeline/frontend/yaml`, `pipeline/frontend/yaml/linter`, `../common`, `shared` |\n| `cli/org/**`             | manage organization-level resources (secrets, registries)               | `../common`, `../internal`, `woodpecker-go`                                      |\n| `cli/output/**`          | formatting utilities for CLI output (tables, etc.)                      | std and external libs                                                            |\n| `cli/pipeline/**`        | manage pipeline operations (start, stop, approve, logs, etc.)           | `../common`, `../internal`, `../output`, `woodpecker-go`, `shared`               |\n| `cli/repo/**`            | manage repository-level resources (repos, crons, secrets, registries)   | `../common`, `../internal`, `../output`, `woodpecker-go`                         |\n| `cli/setup/**`           | interactive first-time setup wizard for CLI configuration               | `../internal/config`                                                             |\n| `cli/update/**`          | self-updater for the CLI binary                                         | std and external libs                                                            |\n| `cmd/cli/**`             | CLI entry point and command structure                                   | `cli/**`                                                                         |\n\nThe CLI provides a command-line interface for interacting with Woodpecker servers.\nEach subcommand is organized into its own package under `cli/<subcommand>/`.\n\nThe `cli/exec` subcommand allows local pipeline execution for testing and development by combining pipeline parsing and execution without requiring a running server or agent.\n\n- `../` = `cli/`\n\n### Engine\n\nThe engine is the shared kernel that validates, parses frontend facing config files, enrich it by the provided forge metadata and produce config for the backends to execute on based on that. It also contains the default backend implementations.\n\n#### Runtime\n\nThe runtime is the package controlling how a workflow is executed, and can be found at `pipeline/runtime`.\n\n<img src=\"/svg/woodpecker-workflow-run-flowchart.svg\" alt=\"Pipeline/runtime flow diagram\" style=\"max-width: 600px; width: 100%;\" />\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/06-conventions.md",
    "content": "# Conventions\n\n## Database naming\n\nDatabase tables are named plural, columns don't have any prefix.\n\nExample: Model name `Agent` with table name `agents` and columns `id`, `name`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/07-guides.md",
    "content": "# Guides\n\n## ORM\n\nWoodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection.\n\n## Add a new migration\n\nWoodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`.\n\n:::info\nAdding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created.\n:::\n\n:::warning\nYou should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager.\n:::\n\nTo automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start.\n\n## Constants of official images\n\nAll official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag.\n\n## Building images locally\n\n### Server\n\n```sh\n### build web component\nmake vendor\ncd web/\npnpm install --frozen-lockfile\npnpm build\ncd ..\n\n### define the platforms to build for (e.g. linux/amd64)\n# (the | is not a typo here)\nexport PLATFORMS='linux|amd64'\nmake cross-compile-server\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push .\n```\n\n:::info\nThe `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)).\nYou can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS).\n:::\n\n### Agent\n\n```sh\n### build the agent\nmake build-agent\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push .\n```\n\n### CLI\n\n```sh\n### build the CLI\nmake build-cli\n\n### build the image\ndocker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push .\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/08-translations.md",
    "content": "# Translations\n\nTo translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.**\n\n<a href=\"https://translate.woodpecker-ci.org/engage/woodpecker-ci/\">\n  <img src=\"https://translate.woodpecker-ci.org/widgets/woodpecker-ci/-/ui/multi-blue.svg\" alt=\"Translation status\" />\n</a>\n\nWoodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/09-openapi.md",
    "content": "# Swagger, API Spec and Code Generation\n\nWoodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically\ngenerate Swagger v2 API specifications and a nice looking Web UI from the source code.\nAlso, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger)\nand then being using on the community's website documentation.\n\nIt's paramount important to keep the gin handler function's godoc documentation up-to-date,\nto always have accurate API documentation.\nWhenever you change, add or enhance an API endpoint, please update the godoc.\n\nYou don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools.\n\n## Gin-Handler API documentation guideline\n\nHere's a typical example of how annotations for Swagger documentation look like...\n\n```go title=\"server/api/user.go\"\n// @Summary  Get a user\n// @Description Returns a user with the specified login name. Requires admin rights.\n// @Router   /users/{login} [get]\n// @Produce  json\n// @Success  200 {object} User\n// @Tags   Users\n// @Param   Authorization header string true \"Insert your personal access token\" default(Bearer <personal access token>)\n// @Param   login   path string true \"the user's login name\"\n// @Param   foobar  query   string false \"optional foobar parameter\"\n// @Param   page    query int  false \"for response pagination, page offset number\" default(1)\n// @Param   perPage query int  false \"for response pagination, max items per page\" default(50)\n```\n\n```go title=\"server/model/user.go\"\ntype User struct {\n  ID int64 `json:\"id\" xorm:\"pk autoincr 'user_id'\"`\n// ...\n} // @name User\n```\n\nThese guidelines aim to have consistent wording in the OpenAPI doc:\n\n- first word after `@Summary` and `@Summary` are always uppercase\n- `@Summary` has no `.` (dot) at the end of the line\n- model structs shall use custom short names, to ease life for API consumers, using `@name`\n- `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI\n- when pagination is used, `@Param page` and `@Param perPage` must be added manually\n- `@Param Authorization` is almost always present, there are just a few un-protected endpoints\n\nThere are many examples in the `server/api` package, which you can use a blueprint.\nMore enhanced information you can find here <https://github.com/swaggo/swag/blob/master/README.md#declarative-comments-format>\n\n### Manual code generation\n\n```bash title=\"generate the server's Go code containing the OpenAPI\"\nmake generate-openapi\n```\n\n```bash title=\"update the Markdown in the ./docs folder\"\nmake generate-docs\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/09-testing.md",
    "content": "# Testing\n\n## Backend\n\n### Unit Tests\n\n[We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test)\nwith [`\"github.com/stretchr/testify/assert\"`](https://pkg.go.dev/github.com/stretchr/testify/assert) to simplify testing.\n\n### Integration Tests\n\n### Dummy backend\n\nThere is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave.\nTo enable it you need to build the agent or cli with the `test` build tag.\n\nAn example pipeline config would be:\n\n```yaml\nwhen:\n  event: manual\n\nsteps:\n  - name: echo\n    image: dummy\n    commands: echo \"hello woodpecker\"\n    environment:\n      SLEEP: '1s'\n\nservices:\n  echo:\n    image: dummy\n    commands: echo \"i am a service\"\n```\n\nThis could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`:\n\n<!-- cspell:disable -->\n\n```none\n9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo\n9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: service\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo\n9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n[echo:L0:0s] StepName: echo\n[echo:L1:0s] StepType: commands\n[echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y\n[echo:L3:0s] StepCommands:\n[echo:L4:0s] ------------------\n[echo:L5:0s] echo ja\n[echo:L6:0s] ------------------\n[echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745\n9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo\n9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745\n```\n\n<!-- cspell:enable -->\n\nThere are also environment variables to alter step behavior:\n\n- `SLEEP: 10` will let the step wait 10 seconds\n- `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands`\n- `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled)\n- `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs\n- `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0\n- `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains\n\nYou can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`.\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/10-packaging.md",
    "content": "# Packaging\n\nIf you repackage it, we encourage to build from source, which requires internet connection.\n\nFor offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI\non the [release page](https://github.com/woodpecker-ci/woodpecker/releases).\n\n## Distribute web UI in own directory\n\nIf you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary.\n\nAdd `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path.\n\nExample: <!-- cspell:ignore webui -->\n\n```sh\ngo build -tags 'external_web' -ldflags '-s -w -extldflags \"-static\" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server\n```\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/100-addons.md",
    "content": "# Addons\n\nThe Woodpecker server supports addons for forges and the log store.\n\n:::warning\nAddons are still experimental. Their implementation can change and break at any time.\n:::\n\n## Bug reports\n\nIf you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name.\n\n## Creating addons\n\nAddons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin).\n\n### Writing your code\n\nThis example will use the Go language.\n\nDirectly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there.\n\nIn the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument.\nThis will take care of connecting the addon forge to the server.\n\n:::note\nIt is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process.\n:::\n\n### Example structure\n\nThis is an example for a forge addon.\n\n```go\npackage main\n\nimport (\n  \"context\"\n  \"net/http\"\n\n  \"go.woodpecker-ci.org/woodpecker/v3/server/forge/addon\"\n  forgeTypes \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n  \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc main() {\n  addon.Serve(config{})\n}\n\ntype config struct {\n}\n\n// `config` must implement `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`. You must directly use Woodpecker's packages - see imports above.\n```\n\n### Addon types\n\n| Type      | Addon package                                                 | Service interface                                                 |\n| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- |\n| Forge     | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon`       | `\"go.woodpecker-ci.org/woodpecker/v3/server/forge\".Forge`         |\n| Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `\"go.woodpecker-ci.org/woodpecker/v3/server/service/log\".Service` |\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/40-deprecations.md",
    "content": "# Deprecation Policy\n\n## Pipeline Configuration Changes\n\nPipeline configuration (YAML syntax) changes follow a strict deprecation process to ensure users have sufficient time to migrate.\n\n### Process Timeline\n\n1. **Minor Version N.x - Add Deprecation Warning**\n   - Linter shows a warning (not an error)\n   - Old syntax remains functional\n   - Documentation is updated to reflect the new syntax\n   - Warning message includes guidance on required changes\n\n2. **Major Version (N+1).0 - Warning Becomes Error**\n   - Linter issues an error (pipeline fails)\n   - Old syntax is no longer supported\n   - Breaking change is documented in the migration guide\n   - Users **must** update their configurations\n\n3. **Minor Version (N+1).x - Code Cleanup**\n   - Deprecated code paths are removed\n   - Implementation is simplified/refactored\n   - Parser no longer recognizes the old syntax\n\n### Example\n\nOld syntax: `secrets: [token]`\nNew syntax: `environment: { TOKEN: { from_secret: token } }`\n\n- **v2.5.0:** Deprecation warning added in linter; both syntaxes work\n- **v2.6-2.9:** Warning persists; both syntaxes remain functional\n- **v3.0.0:** Linter error; old syntax fails (breaking change)\n- **v3.1.0:** Deprecated code paths removed; parser simplified\n\n### Implementation Checklist\n\nWhen deprecating pipeline configuration syntax, ensure the following:\n\n- [ ] Add linter warning in `/pipeline/frontend/yaml/linter/`\n- [ ] Update JSON schema in `/pipeline/frontend/yaml/linter/schema`\n- [ ] Add test cases for deprecated syntax\n- [ ] Update documentation to reflect the new syntax\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/_category_.yaml",
    "content": "label: 'Development'\n# position: 3\ncollapsible: true\ncollapsed: true\n"
  },
  {
    "path": "docs/versioned_docs/version-3.14/92-development/woodpecker-architecture.dot",
    "content": "digraph WoodpeckerArchitecture {\n  graph [\n    rankdir=TB,\n    splines=ortho,\n    nodesep=0.5,\n    ranksep=0.8,\n    fontname=\"Helvetica\"\n  ]\n\n  node [\n    shape=box,\n    style=\"rounded,filled\",\n    fillcolor=\"#2b2b2b\",\n    fontcolor=\"white\",\n    fontname=\"Helvetica\"\n  ]\n\n  edge [\n    color=\"#bdbdbd\",\n    arrowsize=0.7\n  ]\n\n  /* ===================== UI ===================== */\n  subgraph cluster_ui {\n    label=\"UI\"\n    fillcolor=\"#c7efe9\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    ui_web [label=\"web/\"]\n  }\n\n  /* ===================== SDK ===================== */\n  subgraph cluster_sdk {\n    label=\"SDK (woodpecker-go)\"\n    fillcolor=\"#e8f5e9\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    sdk [label=\"woodpecker-go\"]\n  }\n\n  /* ===================== CLI ===================== */\n  subgraph cluster_cli {\n    label=\"woodpecker-cli\"\n    fillcolor=\"#bfe9e0\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    cli_cmd  [label=\"cmd/cli/\"]\n    cli_core [label=\"cli/\"]\n  }\n\n  /* ===================== Agent ===================== */\n  subgraph cluster_agent {\n    label=\"woodpecker-agent\"\n    fillcolor=\"#ffe0c7\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    agent_cmd  [label=\"cmd/agent/\"]\n    agent_core [label=\"agent/\"]\n  }\n\n  /* ===================== Pipelines ===================== */\n  subgraph cluster_pipelines {\n    label=\"Pipelines\"\n    fillcolor=\"#ffe8d6\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    pipe_core      [label=\"pipeline/\"]\n    pipe_frontend  [label=\"pipeline/frontend/\\n(yaml)\"]\n    pipe_backend   [label=\"pipeline/backend/\\n(exec engines)\"]\n  }\n\n  /* ===================== Server ===================== */\n  subgraph cluster_server {\n    label=\"woodpecker-server\"\n    fillcolor=\"#dbe9ff\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    srv_cmd     [label=\"cmd/server/\"]\n    srv_router  [label=\"server/router/\"]\n    srv_api     [label=\"server/api/\"]\n    srv_grpc    [label=\"server/rpc/\"]\n    srv_queue   [label=\"server/queue/\"]\n    srv_pubsub  [label=\"server/pubsub/\"]\n    srv_store   [label=\"server/store/\"]\n    srv_model   [label=\"server/model/\"]\n    srv_forge   [label=\"server/forge/\"]\n  }\n\n  /* ===================== Shared Libs ===================== */\n  subgraph cluster_shared {\n    label=\"Shared Libs\"\n    fillcolor=\"#eeeeee\"\n    fontcolor=\"black\"\n    style=\"rounded,filled\"\n\n    shared_util   [label=\"shared/util/\"]\n    shared_token  [label=\"shared/token/\"]\n    shared_http   [label=\"shared/httputil/\"]\n    shared_log    [label=\"shared/logger/\"]\n  }\n\n  /* ===================== External ===================== */\n  subgraph cluster_external {\n    label=\"External Systems\"\n    style=\"rounded,dashed\"\n    fontcolor=\"white\"\n\n    ext_scm [label=\"SCM Providers\", shape=cloud]\n    ext_db  [label=\"Database\", shape=cylinder]\n  }\n\n  /* ===================== Runtime Interactions ===================== */\n\n  /* UI */\n  ui_web -> srv_router [xlabel=\"HTTP\"]\n  ui_web -> srv_api    [xlabel=\"REST API\"]\n\n  /* CLI */\n  cli_cmd  -> cli_core\n  cli_core -> sdk\n  sdk      -> srv_api [xlabel=\"REST API\"]\n\n  /* Agent */\n  agent_cmd  -> agent_core\n  agent_core -> srv_grpc  [xlabel=\"gRPC connect\"]\n  agent_core -> srv_queue [xlabel=\"poll work\"]\n  agent_core -> pipe_backend [xlabel=\"execute steps\"]\n\n  /* Pipelines */\n  pipe_frontend -> pipe_core\n  pipe_core     -> pipe_backend\n\n  /* Server internal flow */\n  srv_cmd    -> srv_router\n  srv_router -> srv_api\n  srv_api    -> srv_store\n  srv_api    -> srv_pubsub\n  srv_api    -> srv_queue\n  srv_grpc   -> srv_queue\n  srv_store  -> srv_model\n\n  /* External integrations */\n  srv_forge -> ext_scm [xlabel=\"SCM API\"]\n  srv_store -> ext_db  [xlabel=\"SQL\"]\n\n  /* Shared libs usage (consumer -> library) */\n  srv_router -> shared_token\n  srv_api    -> shared_http\n  srv_grpc   -> shared_log\n  pipe_core  -> shared_util\n}\n"
  },
  {
    "path": "docs/versioned_sidebars/version-2.8-sidebars.json",
    "content": "{\n  \"tutorialSidebar\": [\n    {\n      \"type\": \"autogenerated\",\n      \"dirName\": \".\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/versioned_sidebars/version-3.12-sidebars.json",
    "content": "{\n  \"tutorialSidebar\": [\n    {\n      \"type\": \"autogenerated\",\n      \"dirName\": \".\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/versioned_sidebars/version-3.13-sidebars.json",
    "content": "{\n  \"tutorialSidebar\": [\n    {\n      \"type\": \"autogenerated\",\n      \"dirName\": \".\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/versioned_sidebars/version-3.14-sidebars.json",
    "content": "{\n  \"tutorialSidebar\": [\n    {\n      \"type\": \"autogenerated\",\n      \"dirName\": \".\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/versions.json",
    "content": "[\"3.14\", \"3.13\", \"3.12\", \"2.8\"]\n"
  },
  {
    "path": "e2e/scenarios/agent_routing_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// labelRoutingYAML is a single-workflow pipeline that requires the label\n// gpu=true. Only the gpu-agent should pick it up; the plain agent must not.\nvar labelRoutingYAML = []byte(`\nlabels:\n  gpu: \"true\"\n\nsteps:\n  - name: gpu-step\n    image: dummy\n    commands:\n      - echo running on gpu agent\n`)\n\n// TestAgentLabelRouting starts two agents — one plain, one with gpu=true —\n// and asserts that the pipeline with labels: gpu: \"true\" is always picked up\n// by the gpu agent and never by the plain agent.\nfunc TestAgentLabelRouting(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: labelRoutingYAML},\n\t})\n\n\t// Plain agent: wildcard repo label only — cannot satisfy gpu=true.\n\tplainAgent := setup.StartAgent(t, env.GRPCAddr,\n\t\tsetup.WithHostname(\"plain-agent\"),\n\t)\n\n\t// GPU agent: carries gpu=true — the only agent that can accept the task.\n\tgpuAgent := setup.StartAgent(t, env.GRPCAddr,\n\t\tsetup.WithHostname(\"gpu-agent\"),\n\t\tsetup.WithCustomLabels(map[string]string{\"gpu\": \"true\"}),\n\t)\n\n\tsetup.WaitForAgentRegistered(t, env.Store, plainAgent, gpuAgent)\n\n\t// Ensure both agents are actively polling before enqueuing the task.\n\t// Without this, the plain agent (which polls with repo=* and no gpu label)\n\t// could theoretically win if the queue tries to assign before the gpu-agent\n\t// has connected its poll goroutines. In practice label filtering prevents a\n\t// wrong assignment here, but waiting avoids any startup-ordering flakiness.\n\tsetup.WaitForWorkersReady(t, env.Queue, 2*setup.AgentMaxWorkflows)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create pipeline\")\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"pipeline should succeed\")\n\n\t// The single workflow (name=\"woodpecker\" from SanitizePath(\".woodpecker.yaml\"))\n\t// must have been executed by the gpu agent, not the plain agent.\n\tsetup.AssertWorkflowRanOnAgent(t, env.Store, finished, \"woodpecker\", gpuAgent)\n}\n\n/*\n// TODO: The agent assignment is currently flaky and so is the test, fix that.\n\n// orgPipelineYAML is a plain single-step pipeline used for org-preference tests.\nVar orgPipelineYAML = []byte(`\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n`)\n\n// TestOrgAgentPreferredOverGlobal starts a global agent and an org-scoped agent\n// for the same org as the test repo. It asserts that the org agent is always\n// preferred by the queue (score 10 vs 1) and picks up the pipeline.\nFunc TestOrgAgentPreferredOverGlobal(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: orgPipelineYAML},\n\t})\n\n\t// Global agent: matches org-id=* (score 1).\n\tglobalAgent := setup.StartAgent(t, env.GRPCAddr,\n\t\tsetup.WithHostname(\"global-agent\"),\n\t)\n\n\t// Org agent: will be patched with the repo's OrgID (score 10).\n\torgAgent := setup.StartAgent(t, env.GRPCAddr,\n\t\tsetup.WithHostname(\"org-agent\"),\n\t\tsetup.WithOrgID(env.Fixtures.Repo.OrgID),\n\t)\n\n\tsetup.WaitForAgentRegistered(t, env.Store, globalAgent, orgAgent)\n\n\t// Wait until both agents have connected their poll goroutines to the queue.\n\t// The org-agent reads its OrgID label from the DB at Poll time — if we\n\t// create the pipeline before the org-agent is polling, the global agent\n\t// can steal the task first (it's already blocking on Poll and wins the\n\t// race). agentMaxWorkflows slots per agent = 8 workers total.\n\tsetup.WaitForWorkersReady(t, env.Queue, 2*setup.AgentMaxWorkflows)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create pipeline\")\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"pipeline should succeed\")\n\n\t// The workflow must have been picked up by the org-scoped agent, not the\n\t// global one — the queue scores exact org-id matches 10× higher.\n\tsetup.AssertWorkflowRanOnAgent(t, env.Store, finished, \"woodpecker\", orgAgent)\n}.\n*/\n"
  },
  {
    "path": "e2e/scenarios/cancel_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// cancelPipelineYAML has one long-sleeping step followed by one that must\n// be skipped when the pipeline is canceled.\nvar cancelPipelineYAML = []byte(`\nsteps:\n  - name: long-running\n    image: dummy\n    commands:\n      - echo starting long job\n    environment:\n      SLEEP: \"30s\"\n\n  - name: after-cancel\n    image: dummy\n    commands:\n      - echo this should never run\n`)\n\n// TestCancelRunningPipeline triggers a long-running pipeline, waits for it\n// to enter StatusRunning, then cancels it via pipeline.Cancel and asserts:\n//   - pipeline ends up as StatusKilled\n//   - the running step exits with code 130 (dummy cancel convention = SIGINT)\n//   - the subsequent step is skipped\nfunc TestCancelRunningPipeline(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: cancelPipelineYAML},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create pipeline\")\n\trequire.NotNil(t, created)\n\n\t// Wait until the agent has picked it up and set it to running.\n\tsetup.WaitForPipelineStatus(t, env.Store, created.ID, model.StatusRunning, 10*time.Second)\n\n\t// Also wait for the specific step to reach StatusRunning in the DB.\n\t// The pipeline transitions to StatusRunning as soon as the agent starts\n\t// the workflow, but the step itself may not yet have entered its\n\t// sleepWithContext call in the dummy backend. If we cancel before the\n\t// step is actually sleeping, WaitStep returns immediately with success\n\t// before the cancel context propagates — causing \"success\" instead of\n\t// \"killed\". Waiting here ensures the dummy sleep is genuinely in progress.\n\tsetup.WaitForStepRunning(t, env.Store, created.ID, \"long-running\")\n\n\t// Resolve the forge instance (MockForge) via the manager.\n\tforge, err := env.Manager.ForgeByID(env.Fixtures.Forge.ID)\n\trequire.NoError(t, err, \"resolve forge\")\n\n\t// Fetch the latest pipeline state from the store before canceling.\n\trunning, err := env.Store.GetPipeline(created.ID)\n\trequire.NoError(t, err, \"get running pipeline\")\n\n\t// Cancel through the normal server API path — same as the HTTP handler does.\n\terr = pipeline.Cancel(t.Context(), forge, env.Store, env.Fixtures.Repo, env.Fixtures.Owner, running, nil)\n\trequire.NoError(t, err, \"cancel pipeline\")\n\n\t// Wait for the pipeline to reach a terminal state.\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusKilled, finished.Status, \"canceled pipeline should be killed\")\n\n\tt.Run(\"long-running step is killed\", func(t *testing.T) {\n\t\t// After pipeline.Cancel() the pipeline itself reaches a terminal state\n\t\t// immediately, but the running step's status is written asynchronously\n\t\t// by the agent's gRPC Done() call — which arrives *after* the cancel\n\t\t// signal is processed. We therefore wait explicitly for the step to\n\t\t// leave \"running\", giving the agent enough time to finish cleanup and\n\t\t// report back.\n\t\tstep := setup.WaitForStepStatus(t, env.Store, finished, \"long-running\", model.StatusKilled, 30*time.Second)\n\t\tassert.Equal(t, model.StatusKilled, step.State)\n\t})\n\n\tt.Run(\"after-cancel step is canceled\", func(t *testing.T) {\n\t\t// Pending steps get StatusCanceled synchronously by pipeline.Cancel()\n\t\t// before any agent is involved, so this should already be set.\n\t\tstep := setup.WaitForStep(t, env.Store, finished, \"after-cancel\")\n\t\tassert.Equal(t, model.StatusCanceled, step.State)\n\t})\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/01_simple_success.json",
    "content": "{\n  \"name\": \"simple success\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_steps\": [\n    { \"name\": \"clone\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"build\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"test\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/01_simple_success.yaml",
    "content": "steps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n\n  - name: test\n    image: dummy\n    commands:\n      - echo testing\n"
  },
  {
    "path": "e2e/scenarios/fixtures/02_step_failure.json",
    "content": "{\n  \"name\": \"step failure stops pipeline\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_steps\": [\n    { \"name\": \"build\", \"status\": \"failure\", \"exit_code\": 1 },\n    { \"name\": \"deploy\", \"status\": \"skipped\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/02_step_failure.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n    environment:\n      STEP_EXIT_CODE: '1'\n\n  - name: deploy\n    image: dummy\n    commands:\n      - echo deploying\n"
  },
  {
    "path": "e2e/scenarios/fixtures/03_failure_ignore.json",
    "content": "{\n  \"name\": \"failure ignore continues pipeline\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_steps\": [\n    { \"name\": \"lint\", \"status\": \"failure\", \"exit_code\": 1 },\n    { \"name\": \"build\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/03_failure_ignore.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: lint\n    image: dummy\n    commands:\n      - echo linting\n    failure: ignore\n    environment:\n      STEP_EXIT_CODE: '1'\n\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n"
  },
  {
    "path": "e2e/scenarios/fixtures/04_on_failure_notify.json",
    "content": "{\n  \"name\": \"on-failure step runs after failure\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_steps\": [\n    { \"name\": \"build\", \"status\": \"failure\", \"exit_code\": 2 },\n    { \"name\": \"notify\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/04_on_failure_notify.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n    environment:\n      STEP_EXIT_CODE: '2'\n\n  - name: notify\n    image: dummy\n    commands:\n      - echo notifying\n    when:\n      - status: [failure]\n"
  },
  {
    "path": "e2e/scenarios/fixtures/05_service.json",
    "content": "{\n  \"name\": \"service runs alongside steps\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_steps\": [\n    { \"name\": \"clone\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"test\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"db\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/05_service.yaml",
    "content": "steps:\n  - name: test\n    image: dummy\n    commands:\n      - echo running tests\n\nservices:\n  - name: db\n    image: dummy\n    environment:\n      SLEEP: '100ms'\n"
  },
  {
    "path": "e2e/scenarios/fixtures/06_parallel_steps.json",
    "content": "{\n  \"name\": \"parallel steps with depends_on\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_steps\": [\n    { \"name\": \"clone\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"test-unit\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"test-integration\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"deploy\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/06_parallel_steps.yaml",
    "content": "steps:\n  - name: test-unit\n    image: dummy\n    commands:\n      - echo unit tests\n    depends_on: []\n\n  - name: test-integration\n    image: dummy\n    commands:\n      - echo integration tests\n    depends_on: []\n\n  - name: deploy\n    image: dummy\n    commands:\n      - echo deploying\n    depends_on: [test-unit, test-integration]\n"
  },
  {
    "path": "e2e/scenarios/fixtures/07_oom_killed.json",
    "content": "{\n  \"name\": \"OOM killed step fails pipeline\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_steps\": [{ \"name\": \"hungry\", \"status\": \"failure\", \"exit_code\": 137 }]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/07_oom_killed.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: hungry\n    image: dummy\n    commands:\n      - echo eating memory\n    environment:\n      STEP_OOM_KILLED: 'true'\n      STEP_EXIT_CODE: '137'\n"
  },
  {
    "path": "e2e/scenarios/fixtures/08_multi_step_on_failure.json",
    "content": "{\n  \"name\": \"always-run step executes on failure\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_steps\": [\n    { \"name\": \"build\", \"status\": \"failure\", \"exit_code\": 1 },\n    { \"name\": \"always-cleanup\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"deploy\", \"status\": \"skipped\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/08_multi_step_on_failure.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n    environment:\n      STEP_EXIT_CODE: '1'\n\n  - name: always-cleanup\n    image: dummy\n    commands:\n      - echo cleaning up\n    when:\n      - status: [success, failure]\n\n  - name: deploy\n    image: dummy\n    commands:\n      - echo deploying\n"
  },
  {
    "path": "e2e/scenarios/fixtures/09_multi_workflow_parallel/build.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: compile\n    image: dummy\n    commands:\n      - echo compiling\n  - name: test\n    image: dummy\n    commands:\n      - echo testing\n"
  },
  {
    "path": "e2e/scenarios/fixtures/09_multi_workflow_parallel/lint.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: lint\n    image: dummy\n    commands:\n      - echo linting\n"
  },
  {
    "path": "e2e/scenarios/fixtures/09_multi_workflow_parallel/scenario.json",
    "content": "{\n  \"name\": \"two parallel workflows both succeed\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_workflows\": [\n    { \"name\": \"build\", \"status\": \"success\" },\n    { \"name\": \"lint\", \"status\": \"success\" }\n  ],\n  \"expected_steps\": [\n    { \"name\": \"compile\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"test\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"lint\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/10_multi_workflow_failure/failing.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: bad-step\n    image: dummy\n    environment:\n      STEP_EXIT_CODE: '1'\n    commands:\n      - echo this will fail\n"
  },
  {
    "path": "e2e/scenarios/fixtures/10_multi_workflow_failure/passing.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: ok-step\n    image: dummy\n    commands:\n      - echo this is fine\n"
  },
  {
    "path": "e2e/scenarios/fixtures/10_multi_workflow_failure/scenario.json",
    "content": "{\n  \"name\": \"one workflow fails pipeline is failure\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_workflows\": [\n    { \"name\": \"failing\", \"status\": \"failure\" },\n    { \"name\": \"passing\", \"status\": \"success\" }\n  ],\n  \"expected_steps\": [\n    { \"name\": \"ok-step\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"bad-step\", \"status\": \"failure\", \"exit_code\": 1 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/flaky.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: flaky\n    image: dummy\n    environment:\n      STEP_EXIT_CODE: '1'\n    commands:\n      - echo flaky step\n    when:\n      - failure: ignore\n"
  },
  {
    "path": "e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/main.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo building\n"
  },
  {
    "path": "e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/scenario.json",
    "content": "{\n  \"name\": \"two workflows one fails pipeline is failure\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_workflows\": [\n    { \"name\": \"flaky\", \"status\": \"failure\" },\n    { \"name\": \"main\", \"status\": \"success\" }\n  ],\n  \"expected_steps\": [\n    { \"name\": \"build\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"flaky\", \"status\": \"failure\", \"exit_code\": 1 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/12_multi_workflow_depends_on/build.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: compile\n    image: dummy\n    commands:\n      - echo compiling\n  - name: unit-test\n    image: dummy\n    commands:\n      - echo unit testing\n"
  },
  {
    "path": "e2e/scenarios/fixtures/12_multi_workflow_depends_on/deploy.yaml",
    "content": "skip_clone: true\n\ndepends_on:\n  - build\n\nsteps:\n  - name: deploy\n    image: dummy\n    commands:\n      - echo deploying\n"
  },
  {
    "path": "e2e/scenarios/fixtures/12_multi_workflow_depends_on/notify.yaml",
    "content": "skip_clone: true\n\ndepends_on:\n  - build\n\nsteps:\n  - name: notify\n    image: dummy\n    commands:\n      - echo notifying\n"
  },
  {
    "path": "e2e/scenarios/fixtures/12_multi_workflow_depends_on/scenario.json",
    "content": "{\n  \"name\": \"workflows with depends_on run in order\",\n  \"event\": \"push\",\n  \"expected_status\": \"success\",\n  \"expected_workflows\": [\n    { \"name\": \"build\", \"status\": \"success\" },\n    { \"name\": \"deploy\", \"status\": \"success\" },\n    { \"name\": \"notify\", \"status\": \"success\" }\n  ],\n  \"expected_steps\": [\n    { \"name\": \"compile\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"unit-test\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"deploy\", \"status\": \"success\", \"exit_code\": 0 },\n    { \"name\": \"notify\", \"status\": \"success\", \"exit_code\": 0 }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/build.yaml",
    "content": "skip_clone: true\n\nsteps:\n  - name: compile\n    image: dummy\n    environment:\n      STEP_EXIT_CODE: '1'\n    commands:\n      - echo compile failed\n"
  },
  {
    "path": "e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/deploy.yaml",
    "content": "skip_clone: true\n\ndepends_on:\n  - build\n\nsteps:\n  - name: deploy\n    image: dummy\n    commands:\n      - echo this should not run\n"
  },
  {
    "path": "e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/scenario.json",
    "content": "{\n  \"name\": \"downstream workflow skipped when dependency fails\",\n  \"event\": \"push\",\n  \"expected_status\": \"failure\",\n  \"expected_workflows\": [\n    { \"name\": \"build\", \"status\": \"failure\" },\n    { \"name\": \"deploy\", \"status\": \"skipped\" }\n  ],\n  \"expected_steps\": [\n    { \"name\": \"compile\", \"status\": \"failure\", \"exit_code\": 1 },\n    { \"name\": \"deploy\", \"status\": \"killed\", \"exit_code\": 0, \"_comment\": \"TODO: it should be skipped not killed\" }\n  ]\n}\n"
  },
  {
    "path": "e2e/scenarios/fixtures.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n//go:embed fixtures/*.yaml fixtures/*.json fixtures/*/*.yaml fixtures/*/*.json\nvar fixtureFS embed.FS\n\n// Scenario is the single source of truth for one integration test case.\n//\n// Single-workflow scenarios use a flat fixture pair:\n//\n//\tfixtures/NN_name.yaml   — the pipeline YAML served by the mock forge\n//\tfixtures/NN_name.json   — assertions (Scenario fields)\n//\n// Multi-workflow scenarios use a subdirectory:\n//\n//\tfixtures/NN_name/workflow-a.yaml\n//\tfixtures/NN_name/workflow-b.yaml\n//\tfixtures/NN_name/scenario.json   — assertions; Workflows field is populated from the YAMLs\ntype Scenario struct {\n\t// Name is a human-readable label shown in test output.\n\tName string `json:\"name\"`\n\n\t// Event is the webhook event that triggers the pipeline (default: push).\n\tEvent model.WebhookEvent `json:\"event\"`\n\n\t// ExpectedStatus is the final pipeline status we assert on.\n\tExpectedStatus model.StatusValue `json:\"expected_status\"`\n\n\t// ExpectedSteps lists per-step assertions (matched by step name).\n\t// Steps not listed here are not checked.\n\tExpectedSteps []ExpectedStep `json:\"expected_steps\"`\n\n\t// ExpectedWorkflows lists per-workflow assertions (matched by workflow name).\n\t// Only checked when non-empty. For single-workflow pipelines, the workflow\n\t// name is derived from the YAML filename by the step builder.\n\tExpectedWorkflows []ExpectedWorkflow `json:\"expected_workflows\"`\n\n\t// Files is the set of workflow YAML files served by the mock forge.\n\t// Single-workflow: one entry named \".woodpecker.yaml\".\n\t// Multi-workflow:  one entry per file in the fixtures subdirectory,\n\t//                  with paths like \".woodpecker/workflow-a.yaml\".\n\t// Populated by LoadScenarios — not present in the JSON.\n\tFiles []*forge_types.FileMeta `json:\"-\"`\n}\n\n// ExpectedStep describes what we expect for one named step after the pipeline finishes.\ntype ExpectedStep struct {\n\tName     string            `json:\"name\"`\n\tStatus   model.StatusValue `json:\"status\"`\n\tExitCode int               `json:\"exit_code\"`\n}\n\n// ExpectedWorkflow describes what we expect for one named workflow after the pipeline finishes.\ntype ExpectedWorkflow struct {\n\tName   string            `json:\"name\"`\n\tStatus model.StatusValue `json:\"status\"`\n}\n\n// LoadScenarios reads all fixture pairs and subdirectories from the embedded\n// fixtures/ directory and returns them sorted by filesystem order.\n//\n// Flat pairs  (NN_name.yaml + NN_name.json)   → single-workflow scenario.\n// Directories (NN_name/ with *.yaml + scenario.json) → multi-workflow scenario.\nfunc LoadScenarios(t *testing.T) []Scenario {\n\tt.Helper()\n\n\tentries, err := fixtureFS.ReadDir(\"fixtures\")\n\trequire.NoError(t, err, \"read fixtures dir\")\n\n\t// Index flat YAML files by stem.\n\tyamlByStem := make(map[string][]byte)\n\tjsonByStem := make(map[string][]byte)\n\n\tvar scenarios []Scenario\n\n\tfor _, e := range entries {\n\t\tname := e.Name()\n\n\t\tif e.IsDir() {\n\t\t\t// Multi-workflow scenario: load scenario.json + all *.yaml files.\n\t\t\ts := loadMultiWorkflowScenario(t, name)\n\t\t\tscenarios = append(scenarios, s)\n\t\t\tcontinue\n\t\t}\n\n\t\tdata, err := fixtureFS.ReadFile(filepath.Join(\"fixtures\", name))\n\t\trequire.NoError(t, err, \"read fixture %s\", name)\n\n\t\tstem := strings.TrimSuffix(strings.TrimSuffix(name, \".yaml\"), \".json\")\n\t\tswitch filepath.Ext(name) {\n\t\tcase \".yaml\":\n\t\t\tyamlByStem[stem] = data\n\t\tcase \".json\":\n\t\t\tjsonByStem[stem] = data\n\t\t}\n\t}\n\n\t// Pair flat YAML + JSON files.\n\tfor stem, jsonData := range jsonByStem {\n\t\tvar s Scenario\n\t\trequire.NoError(t, json.Unmarshal(jsonData, &s), \"parse %s.json\", stem)\n\n\t\tyamlData, ok := yamlByStem[stem]\n\t\trequire.True(t, ok, \"missing %s.yaml for %s.json\", stem, stem)\n\n\t\t// Single-workflow: serve as \".woodpecker.yaml\" so the config service\n\t\t// calls File() and gets back the YAML directly.\n\t\ts.Files = []*forge_types.FileMeta{\n\t\t\t{Name: \".woodpecker.yaml\", Data: yamlData},\n\t\t}\n\n\t\tif s.Event == \"\" {\n\t\t\ts.Event = model.EventPush\n\t\t}\n\t\tscenarios = append(scenarios, s)\n\t}\n\n\trequire.NotEmpty(t, scenarios, \"no scenarios loaded\")\n\treturn scenarios\n}\n\n// loadMultiWorkflowScenario reads a fixtures/dirName/ subdirectory.\n// It expects a scenario.json and one or more *.yaml workflow files.\nfunc loadMultiWorkflowScenario(t *testing.T, dirName string) Scenario {\n\tt.Helper()\n\n\tdir := filepath.Join(\"fixtures\", dirName)\n\tentries, err := fixtureFS.ReadDir(dir)\n\trequire.NoError(t, err, \"read multi-workflow dir %s\", dir)\n\n\tvar s Scenario\n\tvar files []*forge_types.FileMeta\n\n\tfor _, e := range entries {\n\t\tif e.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := e.Name()\n\t\tdata, err := fixtureFS.ReadFile(filepath.Join(dir, name))\n\t\trequire.NoError(t, err, \"read %s/%s\", dirName, name)\n\n\t\tswitch {\n\t\tcase name == \"scenario.json\":\n\t\t\trequire.NoError(t, json.Unmarshal(data, &s), \"parse %s/scenario.json\", dirName)\n\t\tcase strings.HasSuffix(name, \".yaml\"):\n\t\t\t// Serve under .woodpecker/<filename> so Dir() returns them.\n\t\t\tfiles = append(files, &forge_types.FileMeta{\n\t\t\t\tName: \".woodpecker/\" + name,\n\t\t\t\tData: data,\n\t\t\t})\n\t\t}\n\t}\n\n\trequire.NotEmpty(t, files, \"no YAML files in multi-workflow dir %s\", dirName)\n\trequire.NotEmpty(t, s.Name, \"scenario.json missing 'name' in %s\", dirName)\n\n\ts.Files = forge_types.SortByName(files)\n\tif s.Event == \"\" {\n\t\ts.Event = model.EventPush\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "e2e/scenarios/infra_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\n// Package scenarios contains end-to-end integration tests that run a real\n// in-process Woodpecker server (with MockForge) and a real in-process agent\n// (with the dummy backend). Tests trigger pipelines via server/pipeline.Create\n// and assert on final DB state.\npackage scenarios\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// TestMain sets global log level to warn so test output isn't buried in JSON.\n// Override by setting WOODPECKER_LOG_LEVEL=trace before running tests.\nfunc TestMain(m *testing.M) {\n\tlevel := zerolog.WarnLevel\n\tif lvl := os.Getenv(\"WOODPECKER_LOG_LEVEL\"); lvl != \"\" {\n\t\tif l, err := zerolog.ParseLevel(lvl); err == nil {\n\t\t\tlevel = l\n\t\t}\n\t}\n\tzerolog.SetGlobalLevel(level)\n\tlog.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})\n\tos.Exit(m.Run())\n}\n\n// simpleSuccessYAML is the minimal pipeline config for the smoke test.\n// \"image: dummy\" is handled by the dummy backend (requires -tags test).\nvar simpleSuccessYAML = []byte(`\nsteps:\n  - name: step-one\n    image: dummy\n    commands:\n      - echo hello\n\n  - name: step-two\n    image: dummy\n    commands:\n      - echo world\n`)\n\n// TestInfraSmoke verifies the full server+agent stack can start, accept a\n// pipeline, run it through the dummy backend, and reach StatusSuccess.\n// This is the \"does the plumbing work at all\" gate — it runs first.\nfunc TestInfraSmoke(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: simpleSuccessYAML},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tdraftPipeline := &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t}\n\tcreatedPipeline, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, draftPipeline)\n\trequire.NoError(t, err, \"create pipeline\")\n\trequire.NotNil(t, createdPipeline)\n\tt.Logf(\"pipeline %d created with status=%s\", createdPipeline.ID, createdPipeline.Status)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, createdPipeline.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"pipeline should succeed\")\n}\n"
  },
  {
    "path": "e2e/scenarios/matrix_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// matrixPipelineYAML defines a 2×2 matrix (GO_VERSION × OS), yielding 4\n// workflows. Each step echoes its matrix variables so we can confirm the\n// dummy backend receives the interpolated values via the step environment.\nvar matrixPipelineYAML = []byte(`\nmatrix:\n  GO_VERSION:\n    - \"1.24\"\n    - \"1.26\"\n  OS:\n    - linux\n    - windows\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo \"go=${GO_VERSION} os=${OS}\"\n`)\n\n// matrixIncludePipelineYAML uses the matrix.include form to specify exact\n// combinations, verifying the alternative matrix syntax is also handled.\nvar matrixIncludePipelineYAML = []byte(`\nmatrix:\n  include:\n    - GO_VERSION: \"1.24\"\n      OS: linux\n    - GO_VERSION: \"1.26\"\n      OS: linux\n    - GO_VERSION: \"1.26\"\n      OS: windows\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo \"go=${GO_VERSION} os=${OS}\"\n`)\n\n// TestMatrixPipeline verifies that a matrix YAML expands into the correct\n// number of workflows, that every workflow succeeds, and that each workflow's\n// Environ map carries the right variable combination.\nfunc TestMatrixPipeline(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: matrixPipelineYAML},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create matrix pipeline\")\n\trequire.NotNil(t, created)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"matrix pipeline should succeed\")\n\n\tworkflows, err := env.Store.WorkflowGetTree(finished)\n\trequire.NoError(t, err, \"get workflow tree\")\n\n\t// 2 GO_VERSION values × 2 OS values = 4 workflows\n\tconst wantWorkflows = 4\n\tassert.Len(t, workflows, wantWorkflows,\n\t\t\"matrix should expand to %d workflows\", wantWorkflows)\n\n\t// Build the set of expected (GO_VERSION, OS) pairs and verify each\n\t// workflow accounts for exactly one, with no duplicates.\n\ttype combo struct{ goVersion, os string }\n\texpected := map[combo]bool{\n\t\t{\"1.24\", \"linux\"}:   true,\n\t\t{\"1.24\", \"windows\"}: true,\n\t\t{\"1.26\", \"linux\"}:   true,\n\t\t{\"1.26\", \"windows\"}: true,\n\t}\n\n\tseen := make(map[combo]bool, len(workflows))\n\tfor _, wf := range workflows {\n\t\tassert.Equal(t, model.StatusSuccess, wf.State,\n\t\t\t\"workflow axis %d should succeed\", wf.AxisID)\n\t\tassert.NotZero(t, wf.AxisID,\n\t\t\t\"matrix workflows must have a non-zero AxisID\")\n\n\t\tgoVer := wf.Environ[\"GO_VERSION\"]\n\t\tos := wf.Environ[\"OS\"]\n\t\tc := combo{goVer, os}\n\n\t\tassert.True(t, expected[c],\n\t\t\t\"unexpected matrix combination GO_VERSION=%q OS=%q\", goVer, os)\n\t\tassert.False(t, seen[c],\n\t\t\t\"duplicate matrix combination GO_VERSION=%q OS=%q\", goVer, os)\n\t\tseen[c] = true\n\t}\n\n\t// Every expected combination must have been present.\n\tfor c := range expected {\n\t\tassert.True(t, seen[c],\n\t\t\t\"missing matrix combination GO_VERSION=%q OS=%q\", c.goVersion, c.os)\n\t}\n}\n\n// TestMatrixIncludePipeline verifies the matrix.include syntax produces the\n// exact explicit combinations listed (3 workflows, not a full cross product).\nfunc TestMatrixIncludePipeline(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: matrixIncludePipelineYAML},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create matrix include pipeline\")\n\trequire.NotNil(t, created)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"matrix include pipeline should succeed\")\n\n\tworkflows, err := env.Store.WorkflowGetTree(finished)\n\trequire.NoError(t, err, \"get workflow tree\")\n\n\t// matrix.include has 3 explicit entries — no cross product.\n\tconst wantWorkflows = 3\n\tassert.Len(t, workflows, wantWorkflows,\n\t\t\"matrix include should produce exactly %d workflows\", wantWorkflows)\n\n\ttype combo struct{ goVersion, os string }\n\texpected := map[combo]bool{\n\t\t{\"1.24\", \"linux\"}:   true,\n\t\t{\"1.26\", \"linux\"}:   true,\n\t\t{\"1.26\", \"windows\"}: true,\n\t}\n\n\tseen := make(map[combo]bool, len(workflows))\n\tfor _, wf := range workflows {\n\t\tassert.Equal(t, model.StatusSuccess, wf.State,\n\t\t\t\"workflow (axis %d) should succeed\", wf.AxisID)\n\n\t\tc := combo{wf.Environ[\"GO_VERSION\"], wf.Environ[\"OS\"]}\n\t\tassert.True(t, expected[c],\n\t\t\t\"unexpected combination GO_VERSION=%q OS=%q\", c.goVersion, c.os)\n\t\tassert.False(t, seen[c],\n\t\t\t\"duplicate combination GO_VERSION=%q OS=%q\", c.goVersion, c.os)\n\t\tseen[c] = true\n\t}\n\n\tfor c := range expected {\n\t\tassert.True(t, seen[c],\n\t\t\t\"missing combination GO_VERSION=%q OS=%q\", c.goVersion, c.os)\n\t}\n}\n\n// TestMatrixSingleAxis verifies a single-axis matrix (TAG: [1.7, 1.8, latest])\n// — the simplest possible matrix — to ensure no edge cases in the axis\n// calculation code.\nfunc TestMatrixSingleAxis(t *testing.T) {\n\tyaml := []byte(`\nmatrix:\n  TAG:\n    - \"1.7\"\n    - \"1.8\"\n    - latest\n\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo \"tag=${TAG}\"\n`)\n\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: yaml},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create single-axis matrix pipeline\")\n\trequire.NotNil(t, created)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status, \"single-axis matrix pipeline should succeed\")\n\n\tworkflows, err := env.Store.WorkflowGetTree(finished)\n\trequire.NoError(t, err, \"get workflow tree\")\n\n\tassert.Len(t, workflows, 3, \"single-axis matrix [1.7, 1.8, latest] should produce 3 workflows\")\n\n\twantTags := map[string]bool{\"1.7\": true, \"1.8\": true, \"latest\": true}\n\tseenTags := make(map[string]bool, 3)\n\tfor _, wf := range workflows {\n\t\tassert.Equal(t, model.StatusSuccess, wf.State,\n\t\t\t\"workflow for TAG=%q should succeed\", wf.Environ[\"TAG\"])\n\t\ttag := wf.Environ[\"TAG\"]\n\t\tassert.True(t, wantTags[tag], \"unexpected TAG value %q\", tag)\n\t\tassert.False(t, seenTags[tag], \"duplicate TAG value %q\", tag)\n\t\tseenTags[tag] = true\n\t}\n}\n\n// TestMatrixNoMatrix is a regression guard: a YAML without a matrix section\n// must produce exactly one workflow (the existing behavior must not break).\nfunc TestMatrixNoMatrix(t *testing.T) {\n\tyaml := []byte(`\nsteps:\n  - name: build\n    image: dummy\n    commands:\n      - echo \"no matrix\"\n`)\n\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: yaml},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create non-matrix pipeline\")\n\trequire.NotNil(t, created)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, model.StatusSuccess, finished.Status)\n\n\tworkflows, err := env.Store.WorkflowGetTree(finished)\n\trequire.NoError(t, err, \"get workflow tree\")\n\n\tassert.Len(t, workflows, 1, \"non-matrix pipeline should produce exactly 1 workflow\")\n\tassert.Zero(t, workflows[0].AxisID,\n\t\t\"non-matrix workflow should have AxisID=0\")\n\tassert.Empty(t, workflows[0].Environ,\n\t\t\"non-matrix workflow should have no Environ variables\")\n}\n"
  },
  {
    "path": "e2e/scenarios/restart_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// TestRestartPipeline verifies pipeline.Restart produces a distinct pipeline\n// linked to the original via Parent, with its own fresh workflow rows, and\n// that the original's workflows are untouched.\nfunc TestRestartPipeline(t *testing.T) {\n\tenv := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{\n\t\t{Name: \".woodpecker.yaml\", Data: simpleSuccessYAML},\n\t})\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\t// First run.\n\toriginal, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create original pipeline\")\n\toriginalFinished := setup.WaitForPipeline(t, env.Store, original.ID)\n\trequire.Equal(t, model.StatusSuccess, originalFinished.Status, \"original should succeed\")\n\n\toriginalWorkflows, err := env.Store.WorkflowGetTree(originalFinished)\n\trequire.NoError(t, err)\n\trequire.Len(t, originalWorkflows, 1, \"original should have exactly one workflow\")\n\n\t// Restart it.\n\trestarted, err := pipeline.Restart(t.Context(), env.Store, originalFinished, env.Fixtures.Owner, env.Fixtures.Repo, nil)\n\trequire.NoError(t, err, \"restart pipeline\")\n\trequire.NotNil(t, restarted)\n\n\t// Parent/ID invariants.\n\tassert.NotEqual(t, originalFinished.ID, restarted.ID, \"restart should have a new ID\")\n\tassert.NotEqual(t, originalFinished.Number, restarted.Number, \"restart should have a new number\")\n\tassert.Equal(t, originalFinished.Number, restarted.Parent, \"restart.Parent should point at original.Number\")\n\n\t// The restart runs through the same start path — wait for it to finish.\n\trestartedFinished := setup.WaitForPipeline(t, env.Store, restarted.ID)\n\tassert.Equal(t, model.StatusSuccess, restartedFinished.Status, \"restarted pipeline should succeed\")\n\n\t// Restart should have its OWN workflows, not reuse the originals.\n\trestartedWorkflows, err := env.Store.WorkflowGetTree(restartedFinished)\n\trequire.NoError(t, err)\n\trequire.Len(t, restartedWorkflows, 1, \"restart should produce its own workflow\")\n\tassert.NotEqual(t, originalWorkflows[0].ID, restartedWorkflows[0].ID,\n\t\t\"restart should insert a new workflow row, not reassign the original\")\n\tassert.Equal(t, restartedFinished.ID, restartedWorkflows[0].PipelineID,\n\t\t\"restarted workflow must be linked to the restarted pipeline\")\n\tassert.Equal(t, model.StatusSuccess, restartedWorkflows[0].State)\n\tassert.Greater(t, restartedWorkflows[0].AgentID, int64(0))\n\n\t// Original's workflows must remain pointing at the original pipeline.\n\toriginalAfter, err := env.Store.WorkflowGetTree(originalFinished)\n\trequire.NoError(t, err)\n\trequire.Len(t, originalAfter, 1)\n\tassert.Equal(t, originalWorkflows[0].ID, originalAfter[0].ID,\n\t\t\"restart must not mutate the original's workflow row\")\n\tassert.Equal(t, originalFinished.ID, originalAfter[0].PipelineID,\n\t\t\"original's workflow must still be linked to the original pipeline\")\n}\n"
  },
  {
    "path": "e2e/scenarios/suite_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage scenarios\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/e2e/setup\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\n// TestScenarios is the table-driven runner for all fixture-based scenarios.\n// Each subtest gets its own isolated server+agent environment so they cannot\n// interfere with each other.\n//\n// Subtests do NOT run in parallel because StartServer writes to the\n// server.Config package-level global — running concurrently would race.\nfunc TestScenarios(t *testing.T) {\n\tfor _, sc := range LoadScenarios(t) {\n\t\tt.Run(sc.Name, func(t *testing.T) {\n\t\t\trunScenario(t, sc)\n\t\t})\n\t}\n}\n\n// runScenario starts a fresh server+agent, triggers one pipeline described by\n// sc, waits for it to finish, then asserts the expected DB state.\nfunc runScenario(t *testing.T, sc Scenario) {\n\tt.Helper()\n\n\tenv := setup.StartServer(t.Context(), t, sc.Files)\n\tagent := setup.StartAgent(t, env.GRPCAddr)\n\tsetup.WaitForAgentRegistered(t, env.Store, agent)\n\n\tcreated, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{\n\t\tEvent:  sc.Event,\n\t\tBranch: \"main\",\n\t\tCommit: \"deadbeef\",\n\t\tRef:    \"refs/heads/main\",\n\t\tAuthor: env.Fixtures.Owner.Login,\n\t\tSender: env.Fixtures.Owner.Login,\n\t})\n\trequire.NoError(t, err, \"create pipeline\")\n\trequire.NotNil(t, created)\n\n\tfinished := setup.WaitForPipeline(t, env.Store, created.ID)\n\tassert.Equal(t, sc.ExpectedStatus, finished.Status, \"pipeline final status\")\n\n\tif len(sc.ExpectedSteps) == 0 {\n\t\treturn\n\t}\n\n\tsteps, err := env.Store.StepList(finished.ID)\n\trequire.NoError(t, err, \"list steps for pipeline %d\", finished.ID)\n\n\trequire.ElementsMatch(t, expStepsToName(sc.ExpectedSteps), modelStepsToName(steps), \"we got different steps reported back as we expected\")\n\n\t// Index steps by name for O(1) lookup.\n\tbyName := make(map[string]*model.Step, len(steps))\n\tfor _, s := range steps {\n\t\tbyName[s.Name] = s\n\t}\n\n\tfor _, want := range sc.ExpectedSteps {\n\t\tstep, ok := byName[want.Name]\n\t\tif !assert.Truef(t, ok, \"step %q not found in pipeline %d\", want.Name, finished.ID) {\n\t\t\tcontinue\n\t\t}\n\t\tassert.Equalf(t, want.Status, step.State, \"step %q status\", want.Name)\n\t\tassert.Equalf(t, want.ExitCode, step.ExitCode, \"step %q exit code\", want.Name)\n\t}\n\n\tif len(sc.ExpectedWorkflows) == 0 {\n\t\treturn\n\t}\n\n\tworkflows, err := env.Store.WorkflowGetTree(finished)\n\trequire.NoError(t, err, \"list workflows for pipeline %d\", finished.ID)\n\n\trequire.ElementsMatch(t, expWorkflowsToName(sc.ExpectedWorkflows), modelWorkflowsToName(workflows), \"we got different workflows reported back as we expected\")\n\n\tbyWorkflowName := make(map[string]*model.Workflow, len(workflows))\n\tfor _, w := range workflows {\n\t\tbyWorkflowName[w.Name] = w\n\t}\n\n\tfor _, want := range sc.ExpectedWorkflows {\n\t\twf, ok := byWorkflowName[want.Name]\n\t\tif !assert.Truef(t, ok, \"workflow %q not found in pipeline %d\", want.Name, finished.ID) {\n\t\t\tcontinue\n\t\t}\n\t\tassert.Equalf(t, want.Status, wf.State, \"workflow %q status\", want.Name)\n\t}\n}\n\nfunc expStepsToName(in []ExpectedStep) []string {\n\tout := make([]string, 0, len(in))\n\tfor _, s := range in {\n\t\tout = append(out, s.Name)\n\t}\n\treturn out\n}\n\nfunc modelStepsToName(in []*model.Step) []string {\n\tout := make([]string, 0, len(in))\n\tfor _, s := range in {\n\t\tout = append(out, s.Name)\n\t}\n\treturn out\n}\n\nfunc expWorkflowsToName(in []ExpectedWorkflow) []string {\n\tout := make([]string, 0, len(in))\n\tfor _, s := range in {\n\t\tout = append(out, s.Name)\n\t}\n\treturn out\n}\n\nfunc modelWorkflowsToName(in []*model.Workflow) []string {\n\tout := make([]string, 0, len(in))\n\tfor _, s := range in {\n\t\tout = append(out, s.Name)\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "e2e/setup/agent.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/stretchr/testify/require\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n\t\"google.golang.org/grpc/keepalive\"\n\t\"google.golang.org/grpc/metadata\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/agent\"\n\tagent_rpc \"go.woodpecker-ci.org/woodpecker/v3/agent/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nconst (\n\tAgentMaxWorkflows     = 4\n\tagentAuthRefreshEvery = 30 * time.Minute\n)\n\n// AgentEnv holds the running state of one in-process test agent.\n// Use AgentID to assert which agent picked up a workflow.\ntype AgentEnv struct {\n\t// AgentID is the server-assigned ID after registration.\n\t// Valid only after WaitForAgentRegistered returns.\n\tAgentID int64\n\n\t// name is used for logging and as the hostname label.\n\tname string\n\n\t// requestedOrgID is applied to the DB record by WaitForAgentRegistered\n\t// so the server's GetServerLabels returns the right org-id filter.\n\t// model.IDNotSet (-1) means global (default).\n\trequestOrgID int64\n}\n\n// AgentOption configures an agent before it registers with the server.\ntype AgentOption func(*agentConfig)\n\ntype agentConfig struct {\n\t// hostname is sent to the server as the agent's hostname metadata and label.\n\thostname string\n\n\t// customLabels are merged into the agent's filter labels.\n\t// They are matched against task Labels set in pipeline YAML (labels: key: value).\n\tcustomLabels map[string]string\n\n\t// orgID pins the agent to a specific organization (-1 = global).\n\t// Org agents score higher than global agents for tasks in the same org,\n\t// so they are always preferred by the queue when available.\n\torgID int64\n}\n\n// WithHostname sets the agent's hostname label (default: \"test-agent\").\nfunc WithHostname(name string) AgentOption {\n\treturn func(c *agentConfig) { c.hostname = name }\n}\n\n// WithCustomLabels merges extra labels into the agent's filter set.\n// Use this to test label-based task routing, e.g.:\n//\n//\tsetup.StartAgent(ctx, t, addr, setup.WithCustomLabels(map[string]string{\"gpu\": \"true\"}))\n//\n// The pipeline YAML must set a matching label:\n//\n//\tlabels:\n//\t  gpu: \"true\"\nfunc WithCustomLabels(labels map[string]string) AgentOption {\n\treturn func(c *agentConfig) {\n\t\tfor k, v := range labels {\n\t\t\tc.customLabels[k] = v\n\t\t}\n\t}\n}\n\n// WithOrgID restricts the agent to a specific organization. Org agents score\n// 10× higher than global agents (score 1) for tasks from the same org, so the\n// queue always prefers them when both are available. Pass model.IDNotSet (-1)\n// for a global agent (the default).\nfunc WithOrgID(id int64) AgentOption {\n\treturn func(c *agentConfig) { c.orgID = id }\n}\n\n// StartAgent connects an in-process agent using the dummy backend to the gRPC\n// server at grpcAddr and returns an *AgentEnv whose AgentID is populated once\n// the agent has registered. Pass AgentOption values to configure labels, hostname,\n// or org-scoping; multiple agents can be started in the same test.\nfunc StartAgent(t *testing.T, grpcAddr string, opts ...AgentOption) *AgentEnv {\n\tt.Helper()\n\n\tcfg := &agentConfig{\n\t\thostname:     \"test-agent\",\n\t\tcustomLabels: make(map[string]string),\n\t\torgID:        model.IDNotSet, // global by default\n\t}\n\tfor _, o := range opts {\n\t\to(cfg)\n\t}\n\n\tenv := &AgentEnv{name: cfg.hostname}\n\n\ttransport := grpc.WithTransportCredentials(insecure.NewCredentials())\n\tkeepaliveOpts := grpc.WithKeepaliveParams(keepalive.ClientParameters{\n\t\tTime:    defaultTimeout,\n\t\tTimeout: shortTimeout,\n\t})\n\n\tagentCtx, agentCancel := context.WithCancelCause(t.Context())\n\tt.Cleanup(func() { agentCancel(nil) })\n\n\tauthConn, err := grpc.NewClient(grpcAddr, transport, keepaliveOpts)\n\tif err != nil {\n\t\tt.Fatalf(\"StartAgent(%s): create auth gRPC connection: %v\", cfg.hostname, err)\n\t}\n\tt.Cleanup(func() { authConn.Close() })\n\n\tauthClient := agent_rpc.NewAuthGrpcClient(authConn, TestAgentToken, -1)\n\tauthInterceptor, err := agent_rpc.NewAuthInterceptor(agentCtx, authClient, agentAuthRefreshEvery)\n\tif err != nil {\n\t\tt.Fatalf(\"StartAgent(%s): authenticate with server: %v\", cfg.hostname, err)\n\t}\n\n\tconn, err := grpc.NewClient(\n\t\tgrpcAddr,\n\t\ttransport,\n\t\tkeepaliveOpts,\n\t\tgrpc.WithUnaryInterceptor(authInterceptor.Unary()),\n\t\tgrpc.WithStreamInterceptor(authInterceptor.Stream()),\n\t)\n\tif err != nil {\n\t\tt.Fatalf(\"StartAgent(%s): create main gRPC connection: %v\", cfg.hostname, err)\n\t}\n\tt.Cleanup(func() { conn.Close() })\n\n\tclient := agent_rpc.NewGrpcClient(agentCtx, conn)\n\n\tgrpcCtx := metadata.NewOutgoingContext(agentCtx, metadata.Pairs(\"hostname\", cfg.hostname))\n\n\tbackend := dummy.New()\n\tif !backend.IsAvailable(agentCtx) {\n\t\tt.Fatalf(\"StartAgent(%s): dummy backend is not available\", cfg.hostname)\n\t}\n\tengInfo, err := backend.Load(agentCtx)\n\tif err != nil {\n\t\tt.Fatalf(\"StartAgent(%s): load dummy backend: %v\", cfg.hostname, err)\n\t}\n\n\tenv.AgentID, err = client.RegisterAgent(grpcCtx, rpc.AgentInfo{\n\t\tVersion:      version.String(),\n\t\tBackend:      backend.Name(),\n\t\tPlatform:     engInfo.Platform,\n\t\tCapacity:     AgentMaxWorkflows,\n\t\tCustomLabels: cfg.customLabels,\n\t})\n\trequire.NoErrorf(t, err, \"StartAgent(%s): register with server: %v\", cfg.hostname, err)\n\n\t// If a non-global org is requested, update the agent's OrgID in the DB so\n\t// the server's GetServerLabels returns the right org-id filter (score 10).\n\tif cfg.orgID != model.IDNotSet {\n\t\t// The server stores agents; we patch via the store after registration.\n\t\t// This is done in WaitForAgentRegistered which the caller must invoke.\n\t\t// We stash the requested orgID so the wait helper can apply it.\n\t\tenv.requestOrgID = cfg.orgID\n\t}\n\n\tt.Cleanup(func() {\n\t\tif err := client.UnregisterAgent(grpcCtx); err != nil {\n\t\t\tlog.Warn().Err(err).Str(\"hostname\", cfg.hostname).Msg(\"test agent: unregister failed (expected during teardown)\")\n\t\t}\n\t})\n\n\t// Build the filter labels the agent advertises to the queue.\n\t// org-id is handled server-side via GetServerLabels; we only set\n\t// the labels the agent explicitly provides (platform, backend, repo wildcard,\n\t// and any custom labels).\n\tfilter := rpc.Filter{\n\t\tLabels: map[string]string{\n\t\t\t\"hostname\": cfg.hostname,\n\t\t\t\"platform\": engInfo.Platform,\n\t\t\t\"backend\":  backend.Name(),\n\t\t\t\"repo\":     \"*\",\n\t\t},\n\t}\n\tfor k, v := range cfg.customLabels {\n\t\tfilter.Labels[k] = v\n\t}\n\n\tcounter := &agent.State{\n\t\tPolling:  AgentMaxWorkflows,\n\t\tMetadata: make(map[string]agent.Info),\n\t}\n\n\tfor i := range AgentMaxWorkflows {\n\t\tgo func(slot int) {\n\t\t\trunner := agent.NewRunner(client, filter, cfg.hostname, counter, backend)\n\t\t\tlog.Debug().Int(\"slot\", slot).Str(\"hostname\", cfg.hostname).Msg(\"test agent: runner started\")\n\t\t\tfor {\n\t\t\t\tif agentCtx.Err() != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err := runner.Run(agentCtx); err != nil {\n\t\t\t\t\tif agentCtx.Err() != nil {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tlog.Error().Err(err).Int(\"slot\", slot).Str(\"hostname\", cfg.hostname).Msg(\"test agent: runner error, retrying\")\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-agentCtx.Done():\n\t\t\t\t\t\treturn\n\t\t\t\t\tcase <-time.After(500 * time.Millisecond):\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}(i)\n\t}\n\n\treturn env\n}\n"
  },
  {
    "path": "e2e/setup/forge.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage setup\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/mock\"\n\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// newMockForge builds a MockForge that serves the given files for any\n// config-fetch call, no-ops status reporting, and stubs all other methods safely.\n//\n// Single-workflow (len(files)==1, name \".woodpecker.yaml\"): File() returns the\n// raw YAML bytes; Dir() is not called but is stubbed for safety.\n//\n// Multi-workflow (len(files)>1, names \".woodpecker/foo.yaml\"): File() returns\n// empty (causing the config service to fall through to Dir()); Dir() returns\n// all files.\nfunc newMockForge(t *testing.T, files []*forge_types.FileMeta) *forge_mocks.MockForge {\n\tt.Helper()\n\tm := forge_mocks.NewMockForge(t)\n\n\t// Identity.\n\tm.On(\"Name\").Return(\"mock\").Maybe()\n\tm.On(\"URL\").Return(\"https://forge.example.test\").Maybe()\n\n\tif len(files) == 1 {\n\t\t// Single-workflow: config service calls File(\".woodpecker.yaml\").\n\t\tm.On(\"File\",\n\t\t\tmock.Anything, mock.Anything, mock.Anything, mock.Anything, \".woodpecker.yaml\",\n\t\t).Return(files[0].Data, nil).Maybe()\n\n\t\tm.On(\"Dir\",\n\t\t\tmock.Anything, mock.Anything, mock.Anything, mock.Anything, \".woodpecker\",\n\t\t).Return(files, nil).Maybe()\n\t} else {\n\t\t// Multi-workflow: config service calls Dir(\".woodpecker\").\n\t\t// File() must return empty so the service falls through to Dir().\n\t\tm.On(\"File\",\n\t\t\tmock.Anything, mock.Anything, mock.Anything, mock.Anything, \".woodpecker.yaml\",\n\t\t).Return([]byte(nil), nil).Maybe()\n\t\tm.On(\"Dir\",\n\t\t\tmock.Anything, mock.Anything, mock.Anything, mock.Anything, \".woodpecker\",\n\t\t).Return(files, nil).Maybe()\n\t}\n\n\t// Status reporting back to forge — no-op.\n\tm.On(\"Status\",\n\t\tmock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything,\n\t).Return(nil).Maybe()\n\n\t// Netrc for clone steps.\n\tm.On(\"Netrc\",\n\t\tmock.Anything, mock.Anything,\n\t).Return(&model.Netrc{}, nil).Maybe()\n\n\treturn m\n}\n\n// compile-time import guard.\nvar _ *http.Request\n"
  },
  {
    "path": "e2e/setup/server.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v3\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/keepalive\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/cache\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\tserver_rpc \"go.woodpecker-ci.org/woodpecker/v3/server/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nconst (\n\t// TestAgentToken is the shared secret used between the in-process server\n\t// and agent. Hard-coded for tests — not a real secret.\n\tTestAgentToken = \"test-agent-secret-for-integration-tests\"\n\n\t// TestJWTSecret is used for signing gRPC auth JWTs.\n\tTestJWTSecret = \"test-jwt-secret-for-integration-tests\"\n\n\t// TestForgeType is the forge type the mock pretends to bee.\n\tTestForgeType = model.ForgeTypeGitea\n)\n\nvar configLock = sync.Mutex{}\n\n// ServerEnv holds all the pieces of a running test server environment.\ntype ServerEnv struct {\n\tGRPCAddr string\n\tStore    store.Store\n\tQueue    queue.Queue\n\tFixtures *Fixtures\n\tForge    *forge_mocks.MockForge\n\tManager  services.Manager\n}\n\n// StartServer wires up the full in-process server stack:\n//   - in-memory sqlite store (fully migrated) with seeded fixtures\n//   - in-memory queue, pubsub, and logging\n//   - MockForge that serves the provided workflow files\n//   - gRPC server on a random TCP port\n//\n// files must contain at least one entry. Single-workflow scenarios pass one\n// file named \".woodpecker.yaml\"; multi-workflow scenarios pass multiple files\n// named \".woodpecker/foo.yaml\" etc. The repo's Config path is set accordingly.\n//\n// All resources are cleaned up via t.Cleanup.\nfunc StartServer(ctx context.Context, t *testing.T, files []*forge_types.FileMeta) *ServerEnv {\n\tt.Helper()\n\tconfigLock.Lock()\n\tdefer configLock.Unlock()\n\n\tmemStore := newStore(ctx, t)\n\tfixtures := seedFixtures(t, memStore)\n\tmockForge := newMockForge(t, files)\n\n\tmgr, err := newTestManager(memStore, mockForge)\n\trequire.NoError(t, err, \"create services manager\")\n\n\tmemQueue, err := queue.New(ctx, queue.Config{Backend: queue.TypeMemory})\n\trequire.NoError(t, err, \"create queue\")\n\n\t// Save and restore server.Config around the test. server.Config is a\n\t// package-level global read by server/pipeline and server/rpc. Tests run\n\t// sequentially within a package, but we still need to clean up so the next\n\t// subtest starts from a known-zero state rather than the previous test's values.\n\torig := server.Config\n\tt.Cleanup(func() {\n\t\tconfigLock.Lock()\n\t\tdefer configLock.Unlock()\n\t\tserver.Config = orig\n\t})\n\n\tserver.Config.Services.Logs = logging.New()\n\tserver.Config.Services.Scheduler = scheduler.NewScheduler(memQueue, memory.New())\n\tserver.Config.Services.Membership = cache.NewMembershipService(memStore)\n\tserver.Config.Services.Manager = mgr\n\tserver.Config.Services.LogStore = memStore\n\n\tserver.Config.Server.AgentToken = TestAgentToken\n\tserver.Config.Server.Host = \"http://localhost\"\n\tserver.Config.Server.JWTSecret = TestJWTSecret\n\n\tserver.Config.Pipeline.DefaultClonePlugin = \"docker.io/woodpeckerci/plugin-git:latest\"\n\tserver.Config.Pipeline.TrustedClonePlugins = []string{\"docker.io/woodpeckerci/plugin-git:latest\"}\n\tserver.Config.Pipeline.DefaultApprovalMode = model.RequireApprovalNone\n\tserver.Config.Pipeline.DefaultTimeout = 60\n\tserver.Config.Pipeline.MaxTimeout = 60\n\n\tserver.Config.Permissions.Open = true\n\tserver.Config.Permissions.Admins = permissions.NewAdmins([]string{})\n\tserver.Config.Permissions.Orgs = permissions.NewOrgs([]string{})\n\tserver.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist([]string{})\n\n\tgrpcAddr := startGRPCServer(ctx, t, memStore)\n\n\treturn &ServerEnv{\n\t\tGRPCAddr: grpcAddr,\n\t\tStore:    memStore,\n\t\tQueue:    memQueue,\n\t\tFixtures: fixtures,\n\t\tForge:    mockForge,\n\t\tManager:  mgr,\n\t}\n}\n\n// newTestManager builds a services.Manager whose SetupForge always returns\n// the provided MockForge, bypassing real forge instantiation.\nfunc newTestManager(s store.Store, mockForge *forge_mocks.MockForge) (services.Manager, error) {\n\tcmd := &cli.Command{\n\t\tFlags: []cli.Flag{\n\t\t\t// Config fetch tuning.\n\t\t\t&cli.DurationFlag{Name: \"forge-timeout\", Value: defaultTimeout},\n\t\t\t&cli.UintFlag{Name: \"forge-retry\", Value: defaultRetry},\n\t\t\t&cli.StringSliceFlag{Name: \"environment\"},\n\t\t\t// Forge flags — gitea=true satisfies setupForgeService's type switch.\n\t\t\t&cli.BoolFlag{Name: string(TestForgeType), Value: true},\n\t\t\t&cli.StringFlag{Name: \"forge-url\", Value: \"https://forge.example.test\"},\n\t\t},\n\t}\n\n\tsetupForge := services.SetupForge(func(*model.Forge) (forge.Forge, error) {\n\t\treturn mockForge, nil\n\t})\n\n\treturn services.NewManager(cmd, s, setupForge)\n}\n\n// startGRPCServer binds to a random TCP port, registers Woodpecker's gRPC\n// services, and starts serving. Shutdown happens via t.Cleanup.\nfunc startGRPCServer(ctx context.Context, t *testing.T, s store.Store) string {\n\tt.Helper()\n\n\tlis, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\trequire.NoError(t, err, \"listen on random port for gRPC\")\n\taddr := lis.Addr().String()\n\n\tjwtManager := server_rpc.NewJWTManager(TestJWTSecret)\n\tauthorizer := server_rpc.NewAuthorizer(jwtManager)\n\n\tgrpcServer := grpc.NewServer(\n\t\tgrpc.StreamInterceptor(authorizer.StreamInterceptor),\n\t\tgrpc.UnaryInterceptor(authorizer.UnaryInterceptor),\n\t\tgrpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{\n\t\t\tMinTime: shortTimeout,\n\t\t}),\n\t)\n\n\tproto.RegisterWoodpeckerServer(grpcServer, server_rpc.NewTestWoodpeckerServer(\n\t\tserver.Config.Services.Scheduler,\n\t\tserver.Config.Services.Logs,\n\t\ts,\n\t\tprometheus.NewRegistry(),\n\t))\n\tproto.RegisterWoodpeckerAuthServer(grpcServer, server_rpc.NewWoodpeckerAuthServer(\n\t\tjwtManager,\n\t\tTestAgentToken,\n\t\ts,\n\t))\n\n\tstopped := make(chan struct{})\n\tgrpcCtx, grpcCancel := context.WithCancelCause(ctx)\n\tgo func() {\n\t\t<-grpcCtx.Done()\n\t\tgrpcServer.GracefulStop()\n\t\tclose(stopped)\n\t}()\n\tgo func() {\n\t\tif err := grpcServer.Serve(lis); err != nil {\n\t\t\tgrpcCancel(err)\n\t\t}\n\t}()\n\n\tt.Cleanup(func() {\n\t\tgrpcCancel(nil)\n\t\t<-stopped\n\t})\n\treturn addr\n}\n"
  },
  {
    "path": "e2e/setup/store.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/datastore\"\n)\n\n// Fixtures holds the pre-seeded database records shared across all tests.\ntype Fixtures struct {\n\tForge *model.Forge\n\tOwner *model.User\n\tRepo  *model.Repo\n}\n\n// newStore creates a fully-migrated in-memory sqlite store.\nfunc newStore(ctx context.Context, t *testing.T) store.Store {\n\tt.Helper()\n\n\ts, err := datastore.NewEngine(&store.Opts{\n\t\tDriver: \"sqlite3\",\n\t\tConfig: \":memory:\",\n\t\t// MaxOpenConns=1 and MaxIdleConns=1 are required for in-memory sqlite:\n\t\t// without them the pool drops idle connections, destroying the in-memory\n\t\t// schema between calls and breaking migrations.\n\t\tXORM: store.XORM{\n\t\t\tMaxOpenConns: 1,\n\t\t\tMaxIdleConns: 1,\n\t\t},\n\t})\n\trequire.NoError(t, err, \"create in-memory store\")\n\n\trequire.NoError(t, s.Ping(), \"ping store\")\n\trequire.NoError(t, s.Migrate(ctx, true), \"migrate store\")\n\n\tt.Cleanup(func() { _ = s.Close() })\n\treturn s\n}\n\n// seedFixtures creates the minimal set of DB records every test needs:\n// one Forge, one owner User, one Repo linked to both.\nfunc seedFixtures(t *testing.T, s store.Store) *Fixtures {\n\tt.Helper()\n\n\tforge := &model.Forge{\n\t\tType: TestForgeType,\n\t\tURL:  \"https://forge.example.test\",\n\t}\n\trequire.NoError(t, s.ForgeCreate(forge), \"seed forge\")\n\n\towner := &model.User{\n\t\tForgeID:       forge.ID,\n\t\tForgeRemoteID: \"1\",\n\t\tLogin:         \"test-owner\",\n\t\tEmail:         \"owner@example.test\",\n\t}\n\trequire.NoError(t, s.CreateUser(owner), \"seed user\")\n\n\trepo := &model.Repo{\n\t\tForgeID:       forge.ID,\n\t\tForgeRemoteID: \"1\",\n\t\tUserID:        owner.ID,\n\t\tFullName:      \"test-owner/test-repo\",\n\t\tOwner:         \"test-owner\",\n\t\tName:          \"test-repo\",\n\t\tClone:         \"https://forge.example.test/test-owner/test-repo.git\",\n\t\tBranch:        \"main\",\n\t\tIsActive:      true,\n\t\tAllowPull:     true,\n\t}\n\trequire.NoError(t, s.CreateRepo(repo), \"seed repo\")\n\n\treturn &Fixtures{\n\t\tForge: forge,\n\t\tOwner: owner,\n\t\tRepo:  repo,\n\t}\n}\n"
  },
  {
    "path": "e2e/setup/wait.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage setup\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nconst (\n\tdefaultTimeout  = 30 * time.Second\n\tdefaultRetry    = 3\n\tshortTimeout    = 10 * time.Second\n\tdefaultInterval = 100 * time.Millisecond\n)\n\n// isTerminal returns true if the status is a final (non-running) state.\nfunc isTerminal(s model.StatusValue) bool {\n\tswitch s {\n\tcase model.StatusSuccess, model.StatusFailure, model.StatusKilled,\n\t\tmodel.StatusError, model.StatusDeclined, model.StatusCanceled:\n\t\treturn true\n\t}\n\treturn false\n}\n\n// WaitForPipeline polls the store until the pipeline with the given ID reaches\n// a terminal status, then returns it. Fails the test if timeout is exceeded.\nfunc WaitForPipeline(t *testing.T, s store.Store, pipelineID int64) *model.Pipeline {\n\tt.Helper()\n\treturn WaitForPipelineStatus(t, s, pipelineID, \"\", defaultTimeout)\n}\n\n// WaitForPipelineStatus polls until the pipeline reaches wantStatus (or any\n// terminal status if wantStatus is empty). Fails the test on timeout.\nfunc WaitForPipelineStatus(t *testing.T, s store.Store, pipelineID int64, wantStatus model.StatusValue, timeout time.Duration) *model.Pipeline {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tp, err := s.GetPipeline(pipelineID)\n\t\trequire.NoError(t, err, \"get pipeline %d\", pipelineID)\n\n\t\tif wantStatus != \"\" {\n\t\t\tif p.Status == wantStatus {\n\t\t\t\treturn p\n\t\t\t}\n\t\t} else if isTerminal(p.Status) {\n\t\t\treturn p\n\t\t}\n\n\t\ttime.Sleep(defaultInterval)\n\t}\n\n\tp, _ := s.GetPipeline(pipelineID)\n\tt.Fatalf(\"timeout waiting for pipeline %d: last status=%q (want %q)\", pipelineID, p.Status, wantStatus)\n\treturn nil\n}\n\n// WaitForAgentRegistered polls until all provided agents appear in the store\n// (by AgentID), then applies any deferred DB patches (e.g. OrgID).\n// Pass every *AgentEnv returned by StartAgent before triggering pipelines.\nfunc WaitForAgentRegistered(t *testing.T, s store.Store, agents ...*AgentEnv) {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(shortTimeout)\n\tfor time.Now().Before(deadline) {\n\t\tallFound := true\n\t\tfor _, env := range agents {\n\t\t\tif env.AgentID == 0 {\n\t\t\t\tallFound = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif _, err := s.AgentFind(env.AgentID); err != nil {\n\t\t\t\tallFound = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif allFound {\n\t\t\t// Apply any deferred OrgID patches.\n\t\t\tfor _, env := range agents {\n\t\t\t\tif env.requestOrgID == model.IDNotSet {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tagent, err := s.AgentFind(env.AgentID)\n\t\t\t\trequire.NoError(t, err, \"find agent %d to patch OrgID\", env.AgentID)\n\t\t\t\tagent.OrgID = env.requestOrgID\n\t\t\t\trequire.NoError(t, s.AgentUpdate(agent),\n\t\t\t\t\t\"patch OrgID on agent %d\", env.AgentID)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(defaultInterval)\n\t}\n\n\tt.Fatal(\"timeout: not all agents registered with the server\")\n}\n\n// WaitForStep polls the store until a named step in the given pipeline reaches\n// a terminal status. It returns the final step state. Fails the test on timeout.\nfunc WaitForStep(t *testing.T, s store.Store, pipeline *model.Pipeline, stepName string) *model.Step {\n\tt.Helper()\n\treturn WaitForStepStatus(t, s, pipeline, stepName, \"\", defaultTimeout)\n}\n\n// WaitForStepStatus polls until a named step reaches wantState (or any terminal\n// state when wantState is empty). This is useful after a pipeline.Cancel() call\n// where the agent sends its final step status asynchronously via gRPC Done(),\n// independently of the pipeline itself reaching a terminal status.\nfunc WaitForStepStatus(t *testing.T, s store.Store, pipeline *model.Pipeline, stepName string, wantState model.StatusValue, timeout time.Duration) *model.Step {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tsteps, err := s.StepList(pipeline.ID)\n\t\trequire.NoError(t, err, \"list steps for pipeline %d\", pipeline.ID)\n\n\t\tfor _, step := range steps {\n\t\t\tif step.Name != stepName {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif wantState != \"\" {\n\t\t\t\tif step.State == wantState {\n\t\t\t\t\treturn step\n\t\t\t\t}\n\t\t\t} else if isTerminal(step.State) {\n\t\t\t\treturn step\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(defaultInterval)\n\t}\n\n\tsteps, _ := s.StepList(pipeline.ID)\n\tvar lastState model.StatusValue\n\tfor _, step := range steps {\n\t\tif step.Name == stepName {\n\t\t\tlastState = step.State\n\t\t\tbreak\n\t\t}\n\t}\n\tif wantState != \"\" {\n\t\tt.Fatalf(\"timeout waiting for step %q in pipeline %d to reach state %q: last state=%q\",\n\t\t\tstepName, pipeline.ID, wantState, lastState)\n\t} else {\n\t\tt.Fatalf(\"timeout waiting for step %q in pipeline %d to reach terminal state: last state=%q\",\n\t\t\tstepName, pipeline.ID, lastState)\n\t}\n\treturn nil\n}\n\n// AssertWorkflowRanOnAgent asserts that the named workflow in the finished\n// pipeline was executed by the given agent. Use this to verify label-based\n// routing and org-agent preference.\nfunc AssertWorkflowRanOnAgent(t *testing.T, s store.Store, pipeline *model.Pipeline, workflowName string, agent *AgentEnv) {\n\tt.Helper()\n\n\tworkflows, err := s.WorkflowGetTree(pipeline)\n\trequire.NoError(t, err, \"get workflow tree for pipeline %d\", pipeline.ID)\n\n\tfor _, wf := range workflows {\n\t\tif wf.Name == workflowName {\n\t\t\tassert.Equalf(t, agent.AgentID, wf.AgentID,\n\t\t\t\t\"workflow %q should have run on agent %d (%s) but ran on agent %d\",\n\t\t\t\tworkflowName, agent.AgentID, agent.name, wf.AgentID)\n\t\t\treturn\n\t\t}\n\t}\n\tt.Errorf(\"workflow %q not found in pipeline %d\", workflowName, pipeline.ID)\n}\n\n// WaitForWorkersReady polls the queue until at least minWorkers worker slots\n// are active (i.e. agents have connected and are blocking on Poll). Call this\n// after WaitForAgentRegistered and before pipeline.Create in tests that rely\n// on specific routing: the org-id label is read from the DB at Poll time, so\n// the org-agent must have started its poll loop *after* its OrgID has been\n// patched — otherwise the global agent can win the race and steal the task\n// before the org-agent advertises its exact org-id label.\nfunc WaitForWorkersReady(t *testing.T, q queue.Queue, minWorkers int) {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(shortTimeout)\n\tfor time.Now().Before(deadline) {\n\t\tinfo := q.Info(context.Background())\n\t\tif info.Stats.Workers >= minWorkers {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(defaultInterval)\n\t}\n\n\tinfo := q.Info(context.Background())\n\tt.Fatalf(\"timeout waiting for %d workers to be ready in queue: got %d\", minWorkers, info.Stats.Workers)\n}\n\n// WaitForStepRunning polls the store until a named step in the pipeline with\n// the given ID reaches StatusRunning. This is used before triggering a cancel\n// so we know the dummy backend's sleepWithContext is genuinely blocking — if\n// we cancel before the step is running, the step may finish with StatusSuccess\n// before the cancel context propagates to WaitStep.\nfunc WaitForStepRunning(t *testing.T, s store.Store, pipelineID int64, stepName string) {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(shortTimeout)\n\tfor time.Now().Before(deadline) {\n\t\tp, err := s.GetPipeline(pipelineID)\n\t\trequire.NoError(t, err, \"get pipeline %d\", pipelineID)\n\n\t\tsteps, err := s.StepList(p.ID)\n\t\trequire.NoError(t, err, \"list steps for pipeline %d\", pipelineID)\n\n\t\tfor _, step := range steps {\n\t\t\tif step.Name == stepName && step.State == model.StatusRunning {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(defaultInterval)\n\t}\n\n\tt.Fatalf(\"timeout waiting for step %q in pipeline %d to reach StatusRunning\", stepName, pipelineID)\n}\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs?ref=master\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n  };\n\n  outputs =\n    { nixpkgs, flake-utils, ... }:\n    flake-utils.lib.eachDefaultSystem (\n      system:\n      let\n        pkgs = nixpkgs.legacyPackages.${system};\n      in\n      {\n        devShells.default =\n          with pkgs;\n          let\n            go = go_1_26;\n          in\n          pkgs.mkShell {\n            buildInputs = [\n              # generic\n              gnumake\n              gnutar\n              gzip\n              zip\n              tree\n\n              # frontend\n              nodejs_24\n              pnpm\n              typescript\n              typescript-language-server\n\n              # backend\n              go\n              glibc.static\n              gofumpt\n              golangci-lint\n              go-mockery\n              protobuf\n              sqlite\n              go-swag # for generate-openapi\n              addlicense\n              protoc-gen-go\n              protoc-gen-go-grpc\n              gcc\n\n              # docs\n              graphviz\n            ];\n            CFLAGS = \"-I${pkgs.glibc.dev}/include\";\n            LDFLAGS = \"-L${pkgs.glibc}/lib\";\n            GO = \"${go}/bin/go\";\n            GOROOT = \"${go}/share/go\";\n          };\n      }\n    );\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module go.woodpecker-ci.org/woodpecker/v3\n\ngo 1.26.0\n\nrequire (\n\tal.essio.dev/pkg/shellescape v1.6.0\n\tcharm.land/huh/v2 v2.0.3\n\tcode.gitea.io/sdk/gitea v0.25.0\n\tcodeberg.org/6543/go-yaml2json v1.0.0\n\tcodeberg.org/6543/xyaml v1.1.0\n\tcodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0\n\tgithub.com/6543/logfile-open v1.2.1\n\tgithub.com/adrg/xdg v0.5.3\n\tgithub.com/bmatcuk/doublestar/v4 v4.10.0\n\tgithub.com/cenkalti/backoff/v5 v5.0.3\n\tgithub.com/containerd/errdefs v1.0.0\n\tgithub.com/distribution/reference v0.6.0\n\tgithub.com/docker/cli v29.4.3+incompatible\n\tgithub.com/docker/go-connections v0.7.0\n\tgithub.com/docker/go-units v0.5.0\n\tgithub.com/drone/envsubst v1.0.3\n\tgithub.com/expr-lang/expr v1.17.8\n\tgithub.com/fsnotify/fsnotify v1.10.1\n\tgithub.com/gdgvda/cron v0.7.0\n\tgithub.com/getkin/kin-openapi v0.138.0\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/gitsight/go-vcsurl v1.0.1\n\tgithub.com/go-sql-driver/mysql v1.10.0\n\tgithub.com/go-viper/mapstructure/v2 v2.5.0\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/google/go-github/v86 v86.0.0\n\tgithub.com/hashicorp/go-hclog v1.6.3\n\tgithub.com/hashicorp/go-plugin v1.8.0\n\tgithub.com/jellydator/ttlcache/v3 v3.4.0\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/kinbiko/jsonassert v1.2.0\n\tgithub.com/lib/pq v1.12.3\n\tgithub.com/mattn/go-sqlite3 v1.14.44\n\tgithub.com/migueleliasweb/go-github-mock v1.5.0\n\tgithub.com/moby/moby/api v1.54.2\n\tgithub.com/moby/moby/client v0.4.1\n\tgithub.com/moby/term v0.5.2\n\tgithub.com/muesli/termenv v0.16.0\n\tgithub.com/neticdk/go-bitbucket v1.0.5\n\tgithub.com/oklog/ulid/v2 v2.1.1\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/rs/zerolog v1.35.1\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/swaggo/files v1.0.1\n\tgithub.com/swaggo/gin-swagger v1.6.1\n\tgithub.com/swaggo/swag v1.16.6\n\tgithub.com/tink-crypto/tink-go/v2 v2.6.0\n\tgithub.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482\n\tgithub.com/urfave/cli/v3 v3.8.0\n\tgithub.com/xeipuuv/gojsonschema v1.2.0\n\tgithub.com/yaronf/httpsign v0.5.1\n\tgithub.com/zalando/go-keyring v0.2.8\n\tgitlab.com/gitlab-org/api/client-go/v2 v2.25.0\n\tgo.uber.org/multierr v1.11.0\n\tgolang.org/x/crypto v0.51.0\n\tgolang.org/x/image v0.40.0\n\tgolang.org/x/net v0.54.0\n\tgolang.org/x/oauth2 v0.36.0\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/term v0.43.0\n\tgolang.org/x/text v0.37.0\n\tgoogle.golang.org/grpc v1.81.0\n\tgoogle.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af\n\tgopkg.in/yaml.v3 v3.0.1\n\tk8s.io/api v0.36.0\n\tk8s.io/apimachinery v0.36.0\n\tk8s.io/client-go v0.36.0\n\tsigs.k8s.io/yaml v1.6.0\n\tsrc.techknowlogick.com/xormigrate v1.7.1\n\txorm.io/builder v0.3.13\n\txorm.io/xorm v1.3.11\n)\n\nrequire (\n\tcharm.land/bubbles/v2 v2.0.0 // indirect\n\tcharm.land/bubbletea/v2 v2.0.2 // indirect\n\tcharm.land/lipgloss/v2 v2.0.1 // indirect\n\tfilippo.io/edwards25519 v1.2.0 // indirect\n\tgithub.com/42wim/httpsig v1.2.4 // indirect\n\tgithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect\n\tgithub.com/KyleBanks/depth v1.2.1 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/catppuccin/go v0.3.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.4.2 // indirect\n\tgithub.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.11.6 // indirect\n\tgithub.com/charmbracelet/x/exp/ordered v0.1.0 // indirect\n\tgithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.2 // indirect\n\tgithub.com/charmbracelet/x/termios v0.1.1 // indirect\n\tgithub.com/charmbracelet/x/windows v0.2.2 // indirect\n\tgithub.com/clipperhouse/displaywidth v0.11.0 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.7.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/danieljoos/wincred v1.2.3 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\n\tgithub.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect\n\tgithub.com/docker/docker-credential-helpers v0.8.0 // indirect\n\tgithub.com/dunglas/httpsfv v1.0.2 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.13.0 // indirect\n\tgithub.com/fatih/color v1.18.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.12 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-openapi/errors v0.22.6 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.22.4 // indirect\n\tgithub.com/go-openapi/jsonreference v0.21.4 // indirect\n\tgithub.com/go-openapi/spec v0.22.3 // indirect\n\tgithub.com/go-openapi/strfmt v0.25.0 // indirect\n\tgithub.com/go-openapi/swag v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/cmdutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/conv v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/fileutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/jsonname v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/jsonutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/loading v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/mangling v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/netutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/stringutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/typeutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/yamlutils v0.25.4 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.30.1 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/godbus/dbus/v5 v5.2.2 // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/golang/snappy v0.0.4 // indirect\n\tgithub.com/google/gnostic-models v0.7.0 // indirect\n\tgithub.com/google/go-github/v73 v73.0.0 // indirect\n\tgithub.com/google/go-querystring v1.2.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/gorilla/mux v1.8.1 // indirect\n\tgithub.com/hashicorp/go-cleanhttp v0.5.2 // indirect\n\tgithub.com/hashicorp/go-retryablehttp v0.7.8 // indirect\n\tgithub.com/hashicorp/go-version v1.9.0 // indirect\n\tgithub.com/hashicorp/yamux v0.1.2 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/julienschmidt/httprouter v1.3.0 // indirect\n\tgithub.com/klauspost/compress v1.18.4 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/lestrrat-go/blackmagic v1.0.4 // indirect\n\tgithub.com/lestrrat-go/dsig v1.0.0 // indirect\n\tgithub.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect\n\tgithub.com/lestrrat-go/httpcc v1.0.1 // indirect\n\tgithub.com/lestrrat-go/httprc v1.0.6 // indirect\n\tgithub.com/lestrrat-go/httprc/v3 v3.0.1 // indirect\n\tgithub.com/lestrrat-go/iter v1.0.2 // indirect\n\tgithub.com/lestrrat-go/jwx/v2 v2.1.2 // indirect\n\tgithub.com/lestrrat-go/jwx/v3 v3.0.12 // indirect\n\tgithub.com/lestrrat-go/option v1.0.1 // indirect\n\tgithub.com/lestrrat-go/option/v2 v2.0.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.3.0 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.20 // indirect\n\tgithub.com/mitchellh/hashstructure/v2 v2.0.2 // indirect\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/oasdiff/yaml v0.0.9 // indirect\n\tgithub.com/oasdiff/yaml3 v0.0.12 // indirect\n\tgithub.com/oklog/run v1.1.0 // indirect\n\tgithub.com/oklog/ulid v1.3.1 // indirect\n\tgithub.com/onsi/ginkgo v1.16.4 // indirect\n\tgithub.com/onsi/gomega v1.10.1 // indirect\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\n\tgithub.com/opencontainers/image-spec v1.1.1 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/perimeterx/marshmallow v1.1.5 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.17.0 // indirect\n\tgithub.com/quic-go/qpack v0.6.0 // indirect\n\tgithub.com/quic-go/quic-go v0.59.0 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect\n\tgithub.com/segmentio/asm v1.2.1 // indirect\n\tgithub.com/sirupsen/logrus v1.9.4 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/stretchr/objx v0.5.2 // indirect\n\tgithub.com/syndtr/goleveldb v1.0.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgithub.com/urfave/cli/v2 v2.25.3 // indirect\n\tgithub.com/valyala/fastjson v1.6.4 // indirect\n\tgithub.com/woodsbury/decimal128 v1.3.0 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect\n\tgithub.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect\n\tgo.mongodb.org/mongo-driver v1.17.6 // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect\n\tgo.opentelemetry.io/otel v1.43.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.43.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.43.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/arch v0.22.0 // indirect\n\tgolang.org/x/mod v0.35.0 // indirect\n\tgolang.org/x/sys v0.44.0 // indirect\n\tgolang.org/x/time v0.14.0 // indirect\n\tgolang.org/x/tools v0.44.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect\n\tgopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tk8s.io/klog/v2 v2.140.0 // indirect\n\tk8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect\n\tk8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect\n\tsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=\nal.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=\ncharm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=\ncharm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=\ncharm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=\ncharm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=\ncharm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=\ncharm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=\ncharm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=\ncharm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=\ncode.gitea.io/sdk/gitea v0.25.0 h1:wSJlL0Qv+ODY2OdF0L7fwt86wgf1C/0g3xIXZ6eC5zI=\ncode.gitea.io/sdk/gitea v0.25.0/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=\ncodeberg.org/6543/go-yaml2json v1.0.0 h1:heGqo9VEi7gY2yNqjj7X4ADs5nzlFIbGsJtgYDLrnig=\ncodeberg.org/6543/go-yaml2json v1.0.0/go.mod h1:mz61q14LWF4ZABrgMEDMmk3t9dPi6zgR1uBh2VKV2RQ=\ncodeberg.org/6543/xyaml v1.1.0 h1:0PWTy8OUqshshjrrnAXFWXSPUEa8R49DIh2ah07SxFc=\ncodeberg.org/6543/xyaml v1.1.0/go.mod h1:jI7afXLZUxeL4rNNsG1SlHh78L+gma9lK1bIebyFZwA=\ncodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 h1:s2fK+FBwvcYsmKDjNhmoe7B8q9zsgs0UrSlYe9r4XjM=\ncodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0/go.mod h1:Is2jTpS1dizeXm4skQv/ES3QVqnzcNhn2GzZXpiw9f8=\nfilippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=\nfilippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=\ngitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=\ngitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=\ngitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=\ngithub.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=\ngithub.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=\ngithub.com/6543/logfile-open v1.2.1 h1:az+TtNHclTAKaHfFCTSbuduMllANox1gM9qLQr7LV5I=\ngithub.com/6543/logfile-open v1.2.1/go.mod h1:ZoEy7pW2mexmQxiZIqPCeh8vUxVuiHYXmSZNbvEb51g=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=\ngithub.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=\ngithub.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=\ngithub.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=\ngithub.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=\ngithub.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=\ngithub.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=\ngithub.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=\ngithub.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=\ngithub.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=\ngithub.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=\ngithub.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=\ngithub.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=\ngithub.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=\ngithub.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=\ngithub.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=\ngithub.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=\ngithub.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=\ngithub.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=\ngithub.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=\ngithub.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=\ngithub.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=\ngithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=\ngithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=\ngithub.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=\ngithub.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=\ngithub.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=\ngithub.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=\ngithub.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=\ngithub.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=\ngithub.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=\ngithub.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=\ngithub.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=\ngithub.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=\ngithub.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=\ngithub.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=\ngithub.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=\ngithub.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=\ngithub.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=\ngithub.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=\ngithub.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=\ngithub.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=\ngithub.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=\ngithub.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=\ngithub.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=\ngithub.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=\ngithub.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU=\ngithub.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=\ngithub.com/docker/docker-credential-helpers v0.8.0 h1:YQFtbBQb4VrpoPxhFuzEBPQ9E16qz5SpHLS+uswaCp8=\ngithub.com/docker/docker-credential-helpers v0.8.0/go.mod h1:UGFXcuoQ5TxPiB54nHOZ32AWRqQdECoh/Mg0AlEYb40=\ngithub.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=\ngithub.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g=\ngithub.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g=\ngithub.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=\ngithub.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=\ngithub.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=\ngithub.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=\ngithub.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=\ngithub.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=\ngithub.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=\ngithub.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gdgvda/cron v0.7.0 h1:LFPZUTbCb5ZpzYxavbQDDbjd6nwTwkiNUWyulOdlY2I=\ngithub.com/gdgvda/cron v0.7.0/go.mod h1:caBF+mzTZGtQqFE05T1m6u9OmCASY3EK51XAICf3wio=\ngithub.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=\ngithub.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=\ngithub.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=\ngithub.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=\ngithub.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=\ngithub.com/gitsight/go-vcsurl v1.0.1 h1:wkijKsbVg9R2IBP97U7wOANeIW9WJJKkBwS9XqllzWo=\ngithub.com/gitsight/go-vcsurl v1.0.1/go.mod h1:qRFdKDa/0Lh9MT0xE+qQBYZ/01+mY1H40rZUHR24X9U=\ngithub.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=\ngithub.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=\ngithub.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=\ngithub.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo=\ngithub.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=\ngithub.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=\ngithub.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=\ngithub.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=\ngithub.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=\ngithub.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=\ngithub.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=\ngithub.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=\ngithub.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=\ngithub.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=\ngithub.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=\ngithub.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=\ngithub.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=\ngithub.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=\ngithub.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=\ngithub.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=\ngithub.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=\ngithub.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=\ngithub.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=\ngithub.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=\ngithub.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=\ngithub.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=\ngithub.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=\ngithub.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=\ngithub.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=\ngithub.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=\ngithub.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=\ngithub.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=\ngithub.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=\ngithub.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=\ngithub.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=\ngithub.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=\ngithub.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=\ngithub.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=\ngithub.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=\ngithub.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=\ngithub.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=\ngithub.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=\ngithub.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=\ngithub.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=\ngithub.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=\ngithub.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=\ngithub.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=\ngithub.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=\ngithub.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=\ngithub.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=\ngithub.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=\ngithub.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=\ngithub.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=\ngithub.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24=\ngithub.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw=\ngithub.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA=\ngithub.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8=\ngithub.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=\ngithub.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=\ngithub.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=\ngithub.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=\ngithub.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=\ngithub.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls=\ngithub.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM=\ngithub.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=\ngithub.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=\ngithub.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=\ngithub.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=\ngithub.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=\ngithub.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=\ngithub.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=\ngithub.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=\ngithub.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=\ngithub.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=\ngithub.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=\ngithub.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=\ngithub.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=\ngithub.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=\ngithub.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=\ngithub.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=\ngithub.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=\ngithub.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E=\ngithub.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=\ngithub.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=\ngithub.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=\ngithub.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=\ngithub.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=\ngithub.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=\ngithub.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=\ngithub.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=\ngithub.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=\ngithub.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=\ngithub.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=\ngithub.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=\ngithub.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=\ngithub.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=\ngithub.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=\ngithub.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=\ngithub.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=\ngithub.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=\ngithub.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=\ngithub.com/jackc/pgx/v4 v4.18.0/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE=\ngithub.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=\ngithub.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=\ngithub.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=\ngithub.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=\ngithub.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=\ngithub.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=\ngithub.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=\ngithub.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=\ngithub.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=\ngithub.com/kinbiko/jsonassert v1.2.0 h1:+/JthIVXdIrThrOtSN9ry0mNtWKXMWuvxR0nU7gQ+tI=\ngithub.com/kinbiko/jsonassert v1.2.0/go.mod h1:pCc3uudOt+lVAbkji9O0uw8MSVt4s+1ZJ0y8Ux2F1Og=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=\ngithub.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=\ngithub.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=\ngithub.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=\ngithub.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=\ngithub.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=\ngithub.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=\ngithub.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=\ngithub.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=\ngithub.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=\ngithub.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=\ngithub.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=\ngithub.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=\ngithub.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=\ngithub.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=\ngithub.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc=\ngithub.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y=\ngithub.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=\ngithub.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=\ngithub.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=\ngithub.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=\ngithub.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=\ngithub.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=\ngithub.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=\ngithub.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=\ngithub.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=\ngithub.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=\ngithub.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=\ngithub.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=\ngithub.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=\ngithub.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=\ngithub.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=\ngithub.com/migueleliasweb/go-github-mock v1.5.0 h1:dIr6vgVz8QY9sDiDopWxk6pDw4d7K/xIcCk/NQe4ajM=\ngithub.com/migueleliasweb/go-github-mock v1.5.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o=\ngithub.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=\ngithub.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=\ngithub.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=\ngithub.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=\ngithub.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=\ngithub.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=\ngithub.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=\ngithub.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=\ngithub.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=\ngithub.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/neticdk/go-bitbucket v1.0.5 h1:H/++KM+O0EXVDbgMadbAsKwqjLKi0vDwa+vGU9lMChg=\ngithub.com/neticdk/go-bitbucket v1.0.5/go.mod h1:4ZMxzmr5hi/EoLdydtR7h4dd4DpqK8tbnVLbAkscRc8=\ngithub.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=\ngithub.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=\ngithub.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=\ngithub.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=\ngithub.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=\ngithub.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=\ngithub.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=\ngithub.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=\ngithub.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=\ngithub.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=\ngithub.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=\ngithub.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=\ngithub.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=\ngithub.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=\ngithub.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=\ngithub.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=\ngithub.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=\ngithub.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=\ngithub.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=\ngithub.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=\ngithub.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=\ngithub.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=\ngithub.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=\ngithub.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=\ngithub.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=\ngithub.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=\ngithub.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=\ngithub.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=\ngithub.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=\ngithub.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=\ngithub.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=\ngithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=\ngithub.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=\ngithub.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=\ngithub.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=\ngithub.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=\ngithub.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=\ngithub.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=\ngithub.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=\ngithub.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=\ngithub.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=\ngithub.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=\ngithub.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=\ngithub.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=\ngithub.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=\ngithub.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=\ngithub.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=\ngithub.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=\ngithub.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=\ngithub.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4=\ngithub.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=\ngithub.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482 h1:xlSy8R55vuHbUM1c2mwfQ5MFeVTnm59BwQeWibPUD5A=\ngithub.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482/go.mod h1:cjSVza4yCaqQet06zO6QhYqXQYjGRqbUj8zok6mHDRU=\ngithub.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=\ngithub.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=\ngithub.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=\ngithub.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=\ngithub.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=\ngithub.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=\ngithub.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=\ngithub.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=\ngithub.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=\ngithub.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=\ngithub.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=\ngithub.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=\ngithub.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=\ngithub.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=\ngithub.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=\ngithub.com/yaronf/httpsign v0.5.1 h1:hYFnX4ND+tgsMAho95b65uj36ho4ND+U2fXvIbAoxl8=\ngithub.com/yaronf/httpsign v0.5.1/go.mod h1:euOXi3++HLtx5YlsJEWcIzF3ztK4TL2M2F0Wg3KL+V0=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=\ngithub.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=\ngithub.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=\ngithub.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=\ngitlab.com/gitlab-org/api/client-go/v2 v2.25.0 h1:ATTBB0Iiup5SRox2IPNSkkrGy/Any7FWBL1BOpZrpCU=\ngitlab.com/gitlab-org/api/client-go/v2 v2.25.0/go.mod h1:OSJITkIrT0UuA3JCucEK9UEGcC1PWBkQg5WW6W4nWuo=\ngo.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=\ngo.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=\ngo.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=\ngo.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=\ngo.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=\ngo.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=\ngo.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=\ngo.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=\ngo.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=\ngo.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=\ngo.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=\ngo.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=\ngo.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=\ngo.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=\ngo.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=\ngo.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=\ngo.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=\ngo.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=\ngolang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=\ngolang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=\ngolang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=\ngolang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=\ngolang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=\ngolang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=\ngolang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=\ngolang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=\ngolang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=\ngolang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=\ngolang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=\ngolang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=\ngolang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=\ngolang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=\ngolang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=\ngolang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=\ngolang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=\ngolang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=\ngonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=\ngoogle.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=\ngoogle.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=\ngopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=\ngotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nk8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=\nk8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=\nk8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=\nk8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=\nk8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=\nk8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=\nk8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=\nk8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=\nk8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=\nk8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=\nk8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=\nk8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=\nlukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=\nlukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=\nlukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=\nmodernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=\nmodernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=\nmodernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=\nmodernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=\nmodernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=\nmodernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=\nmodernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=\nmodernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=\nmodernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=\nmodernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=\nmodernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=\nmodernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=\nmodernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=\nmodernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=\nmodernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=\nmodernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=\nmodernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=\nmodernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=\nmodernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=\nmodernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=\nmodernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=\nmodernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=\nmodernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=\nmodernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=\nmodernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=\nmodernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=\nmodernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=\nmodernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=\nmodernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=\nmodernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=\nmodernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=\nmodernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=\nmodernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=\nmodernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=\nmodernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=\nmodernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nmodernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=\npgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=\npgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\nsrc.techknowlogick.com/xormigrate v1.7.1 h1:RKGLLUAqJ+zO8iZ7eOc7oLH7f0cs2gfXSZSvBRBHnlY=\nsrc.techknowlogick.com/xormigrate v1.7.1/go.mod h1:YGNBdj8prENlySwIKmfoEXp7ILGjAltyKFXD0qLgD7U=\nxorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=\nxorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=\nxorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=\nxorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=\nxorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=\nxorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=\n"
  },
  {
    "path": "nfpm/agent.yaml",
    "content": "name: woodpecker-agent\narch: amd64\nplatform: linux\nversion: ${VERSION_NUMBER}\ndescription: Woodpecker Agent\nhomepage: https://woodpecker-ci.org/\nlicense: Apache 2.0\nmaintainer: Woodpecker Authors <maintainer@woodpecker-ci.org>\nsection: daemon/system\nscripts:\n  preinstall: ./nfpm/woodpecker-system-user.preinstall.sh\ncontents:\n  - src: ./dist/agent/linux_amd64/woodpecker-agent\n    dst: /usr/local/bin/woodpecker-agent\n  - src: ./nfpm/woodpecker-agent.service\n    dst: /usr/local/lib/systemd/system/woodpecker-agent.service\n  - src: ./nfpm/woodpecker-agent.env.example\n    dst: /etc/woodpecker/woodpecker-agent.env.example\n  - dst: /var/lib/woodpecker/\n    type: dir\n    file_info:\n      owner: woodpecker\n      group: woodpecker\n      mode: 0750\n"
  },
  {
    "path": "nfpm/cli.yaml",
    "content": "name: woodpecker-cli\narch: amd64\nplatform: linux\nversion: ${VERSION_NUMBER}\ndescription: Woodpecker CLI\nhomepage: https://woodpecker-ci.org/\nlicense: Apache 2.0\nmaintainer: Woodpecker Authors <maintainer@woodpecker-ci.org>\nsection: utils\ncontents:\n  - src: ./dist/cli/linux_amd64/woodpecker-cli\n    dst: /usr/local/bin/woodpecker-cli\n"
  },
  {
    "path": "nfpm/server.yaml",
    "content": "name: woodpecker-server\narch: amd64\nplatform: linux\nversion: ${VERSION_NUMBER}\ndescription: Woodpecker Server\nhomepage: https://woodpecker-ci.org/\nlicense: Apache 2.0\nmaintainer: Woodpecker Authors <maintainer@woodpecker-ci.org>\nsection: daemon/system\nscripts:\n  preinstall: ./nfpm/woodpecker-system-user.preinstall.sh\ncontents:\n  - src: ./dist/server/linux_amd64/woodpecker-server\n    dst: /usr/local/bin/woodpecker-server\n  - src: ./nfpm/woodpecker-server.service\n    dst: /usr/local/lib/systemd/system/woodpecker-server.service\n  - src: ./nfpm/woodpecker-server.env.example\n    dst: /etc/woodpecker/woodpecker-server.env.example\n  - dst: /var/lib/woodpecker/\n    type: dir\n    file_info:\n      owner: woodpecker\n      group: woodpecker\n      mode: 0750\n"
  },
  {
    "path": "nfpm/woodpecker-agent.env.example",
    "content": "# Example for a woodpecker-agent.env file\n\n# Check the documentation for the agent:\n# https://woodpecker-ci.org/docs/administration/configuration/agent\n\n# Add all required environment variables for your setup in the form of VARIABLE=value\nVARIABLE=value\n"
  },
  {
    "path": "nfpm/woodpecker-agent.service",
    "content": "[Unit]\nDescription=WoodpeckerCI agent\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/agent\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env\nConditionPathExists=/etc/woodpecker/woodpecker-agent.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-agent.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-agent\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "nfpm/woodpecker-server.env.example",
    "content": "# Example for a woodpecker-server.env file\n\n# Check the documentation for the server:\n# https://woodpecker-ci.org/docs/administration/configuration/server\n\n# Add all required environment variables for your setup in the form of VARIABLE=value\nVARIABLE=value\n"
  },
  {
    "path": "nfpm/woodpecker-server.service",
    "content": "[Unit]\nDescription=WoodpeckerCI server\nDocumentation=https://woodpecker-ci.org/docs/administration/configuration/server\nRequires=network.target\nAfter=network.target\nConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env\nConditionPathExists=/etc/woodpecker/woodpecker-server.env\n\n[Service]\nType=simple\nEnvironmentFile=/etc/woodpecker/woodpecker-server.env\nUser=woodpecker\nGroup=woodpecker\nExecStart=/usr/local/bin/woodpecker-server\nWorkingDirectory=/var/lib/woodpecker/\nStateDirectory=woodpecker\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "nfpm/woodpecker-system-user.preinstall.sh",
    "content": "#!/bin/sh\nset -e\n\n# Create woodpecker group if it doesn't exist\nif ! getent group woodpecker > /dev/null 2>&1; then\n    groupadd --system woodpecker\nfi\n\n# Create woodpecker user if it doesn't exist\nif ! getent passwd woodpecker > /dev/null 2>&1; then\n    useradd \\\n        --system \\\n        --gid woodpecker \\\n        --no-create-home \\\n        --home-dir /var/lib/woodpecker \\\n        --shell /sbin/nologin \\\n        woodpecker\nfi\n"
  },
  {
    "path": "pipeline/backend/backend.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage backend\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc FindBackend(ctx context.Context, backends []types.Backend, backendName string) (types.Backend, error) {\n\tif backendName == \"auto-detect\" {\n\t\tfor _, engine := range backends {\n\t\t\tif engine.IsAvailable(ctx) {\n\t\t\t\treturn engine, nil\n\t\t\t}\n\t\t}\n\n\t\treturn nil, fmt.Errorf(\"can't detect an available backend engine\")\n\t}\n\n\tfor _, engine := range backends {\n\t\tif engine.Name() == backendName {\n\t\t\treturn engine, nil\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"backend engine '%s' not found\", backendName)\n}\n"
  },
  {
    "path": "pipeline/backend/common/script.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"encoding/base64\"\n)\n\nfunc GenerateContainerConf(commands []string, osType, workDir string) (env map[string]string, entry []string) {\n\tenv = make(map[string]string)\n\tif osType == \"windows\" {\n\t\tenv[\"CI_SCRIPT\"] = base64.StdEncoding.EncodeToString([]byte(generateScriptWindows(commands, workDir)))\n\t\tenv[\"SHELL\"] = \"powershell.exe\"\n\t\t// cspell:disable-next-line\n\t\tentry = []string{\"powershell\", \"-noprofile\", \"-noninteractive\", \"-command\", \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex\"}\n\t} else {\n\t\tenv[\"CI_SCRIPT\"] = base64.StdEncoding.EncodeToString([]byte(generateScriptPosix(commands, workDir)))\n\t\tenv[\"SHELL\"] = \"/bin/sh\"\n\t\tentry = []string{\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"}\n\t}\n\n\treturn env, entry\n}\n"
  },
  {
    "path": "pipeline/backend/common/script_posix.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"text/template\"\n\n\t\"al.essio.dev/pkg/shellescape\"\n)\n\n// generateScriptPosix is a helper function that generates a step script\n// for a linux container using the given.\nfunc generateScriptPosix(commands []string, workDir string) string {\n\tvar buf bytes.Buffer\n\n\tif err := setupScriptTmpl.Execute(&buf, map[string]string{\n\t\t\"WorkDir\": workDir,\n\t}); err != nil {\n\t\t// should never happen but well we have an error to trance\n\t\treturn fmt.Sprintf(\"echo 'failed to generate posix script from commands: %s'; exit 1\", err.Error())\n\t}\n\n\tfor _, command := range commands {\n\t\tfmt.Fprintf(&buf,\n\t\t\ttraceScript,\n\t\t\tshellescape.Quote(command),\n\t\t\tcommand,\n\t\t)\n\t}\n\n\treturn buf.String()\n}\n\n// setupScriptProto is a helper script this is added to the step script to ensure\n// a minimum set of environment variables are set correctly.\nconst setupScriptProto = `\nif [ -n \"$CI_NETRC_MACHINE\" ]; then\ncat <<EOF > $HOME/.netrc\nmachine $CI_NETRC_MACHINE\nlogin $CI_NETRC_USERNAME\npassword $CI_NETRC_PASSWORD\nEOF\nchmod 0600 $HOME/.netrc\nfi\nunset CI_NETRC_USERNAME\nunset CI_NETRC_PASSWORD\nunset CI_SCRIPT\nmkdir -p \"{{.WorkDir}}\"\ncd \"{{.WorkDir}}\"\n`\n\nvar setupScriptTmpl, _ = template.New(\"\").Parse(setupScriptProto)\n\n// traceScript is a helper script that is added to the step script\n// to trace a command.\nconst traceScript = `\necho + %s\n%s\n`\n"
  },
  {
    "path": "pipeline/backend/common/script_posix_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"testing\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGenerateScriptPosix(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom []string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: []string{\"echo ${PATH}\", \"go build\", \"go test\"},\n\t\t\twant: `\nif [ -n \"$CI_NETRC_MACHINE\" ]; then\ncat <<EOF > $HOME/.netrc\nmachine $CI_NETRC_MACHINE\nlogin $CI_NETRC_USERNAME\npassword $CI_NETRC_PASSWORD\nEOF\nchmod 0600 $HOME/.netrc\nfi\nunset CI_NETRC_USERNAME\nunset CI_NETRC_PASSWORD\nunset CI_SCRIPT\nmkdir -p \"/woodpecker/some\"\ncd \"/woodpecker/some\"\n\necho + 'echo ${PATH}'\necho ${PATH}\n\necho + 'go build'\ngo build\n\necho + 'go test'\ngo test\n`,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tscript := generateScriptPosix(test.from, \"/woodpecker/some\")\n\t\tassert.EqualValues(t, test.want, script, \"Want encoded script for %s\", test.from)\n\t}\n}\n\nfunc TestSetupScriptProtoParse(t *testing.T) {\n\t// just ensure that we have a working `setupScriptTmpl` on runntime\n\t_, err := template.New(\"\").Parse(setupScriptProto)\n\tassert.NoError(t, err)\n}\n"
  },
  {
    "path": "pipeline/backend/common/script_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst (\n\twindowsScriptBase64 = \"CiRFcnJvckFjdGlvblByZWZlcmVuY2UgPSAnU3RvcCc7CmlmICgtbm90IChUZXN0LVBhdGggIi93b29kcGVja2VyL3NvbWUiKSkgeyBOZXctSXRlbSAtUGF0aCAiL3dvb2RwZWNrZXIvc29tZSIgLUl0ZW1UeXBlIERpcmVjdG9yeSAtRm9yY2UgfTsKaWYgKC1ub3QgW0Vudmlyb25tZW50XTo6R2V0RW52aXJvbm1lbnRWYXJpYWJsZSgnSE9NRScpKSB7IFtFbnZpcm9ubWVudF06OlNldEVudmlyb25tZW50VmFyaWFibGUoJ0hPTUUnLCAnYzpccm9vdCcpIH07CmlmICgtbm90IChUZXN0LVBhdGggIiRlbnY6SE9NRSIpKSB7IE5ldy1JdGVtIC1QYXRoICIkZW52OkhPTUUiIC1JdGVtVHlwZSBEaXJlY3RvcnkgLUZvcmNlIH07CmlmICgkRW52OkNJX05FVFJDX01BQ0hJTkUpIHsKJG5ldHJjPVtzdHJpbmddOjpGb3JtYXQoInswfVxfbmV0cmMiLCRFbnY6SE9NRSk7CiJtYWNoaW5lICRFbnY6Q0lfTkVUUkNfTUFDSElORSIgPj4gJG5ldHJjOwoibG9naW4gJEVudjpDSV9ORVRSQ19VU0VSTkFNRSIgPj4gJG5ldHJjOwoicGFzc3dvcmQgJEVudjpDSV9ORVRSQ19QQVNTV09SRCIgPj4gJG5ldHJjOwp9OwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9ORVRSQ19QQVNTV09SRCIsJG51bGwpOwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9TQ1JJUFQiLCRudWxsKTsKY2QgIi93b29kcGVja2VyL3NvbWUiOwoKV3JpdGUtT3V0cHV0ICgnKyAiZWNobyBoZWxsbyB3b3JsZCInKTsKJiBlY2hvIGhlbGxvIHdvcmxkOyBpZiAoJExBU1RFWElUQ09ERSAtbmUgMCkge2V4aXQgJExBU1RFWElUQ09ERX0K\"\n\tposixScriptBase64   = \"CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc29tZSIKY2QgIi93b29kcGVja2VyL3NvbWUiCgplY2hvICsgJ2VjaG8gaGVsbG8gd29ybGQnCmVjaG8gaGVsbG8gd29ybGQK\"\n)\n\nfunc TestGenerateContainerConf(t *testing.T) {\n\tgotEnv, gotEntry := GenerateContainerConf([]string{\"echo hello world\"}, \"windows\", \"/woodpecker/some\")\n\tassert.Equal(t, windowsScriptBase64, gotEnv[\"CI_SCRIPT\"])\n\tassert.Equal(t, \"powershell.exe\", gotEnv[\"SHELL\"])\n\tassert.Equal(t, []string{\"powershell\", \"-noprofile\", \"-noninteractive\", \"-command\", \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex\"}, gotEntry)\n\tgotEnv, gotEntry = GenerateContainerConf([]string{\"echo hello world\"}, \"linux\", \"/woodpecker/some\")\n\tassert.Equal(t, posixScriptBase64, gotEnv[\"CI_SCRIPT\"])\n\tassert.Equal(t, \"/bin/sh\", gotEnv[\"SHELL\"])\n\tassert.Equal(t, []string{\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"}, gotEntry)\n}\n"
  },
  {
    "path": "pipeline/backend/common/script_win.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"strings\"\n\t\"text/template\"\n)\n\nfunc generateScriptWindows(commands []string, workDir string) string {\n\tvar buf bytes.Buffer\n\n\tif err := setupScriptWinTmpl.Execute(&buf, map[string]string{\n\t\t\"WorkDir\": workDir,\n\t}); err != nil {\n\t\t// should never happen but well we have an error to trance\n\t\treturn fmt.Sprintf(\"echo 'failed to generate posix script from commands: %s'; exit 1\", err.Error())\n\t}\n\n\tfor _, command := range commands {\n\t\tescaped := fmt.Sprintf(\"%q\", command)\n\t\tescaped = strings.ReplaceAll(escaped, \"$\", `\\$`)\n\t\tfmt.Fprintf(&buf,\n\t\t\ttraceScriptWin,\n\t\t\tescaped,\n\t\t\tcommand,\n\t\t)\n\t}\n\n\treturn buf.String()\n}\n\nconst setupScriptWinProto = `\n$ErrorActionPreference = 'Stop';\nif (-not (Test-Path \"{{.WorkDir}}\")) { New-Item -Path \"{{.WorkDir}}\" -ItemType Directory -Force };\nif (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\\root') };\nif (-not (Test-Path \"$env:HOME\")) { New-Item -Path \"$env:HOME\" -ItemType Directory -Force };\nif ($Env:CI_NETRC_MACHINE) {\n$netrc=[string]::Format(\"{0}\\_netrc\",$Env:HOME);\n\"machine $Env:CI_NETRC_MACHINE\" >> $netrc;\n\"login $Env:CI_NETRC_USERNAME\" >> $netrc;\n\"password $Env:CI_NETRC_PASSWORD\" >> $netrc;\n};\n[Environment]::SetEnvironmentVariable(\"CI_NETRC_PASSWORD\",$null);\n[Environment]::SetEnvironmentVariable(\"CI_SCRIPT\",$null);\ncd \"{{.WorkDir}}\";\n`\n\nvar setupScriptWinTmpl, _ = template.New(\"\").Parse(setupScriptWinProto)\n\n// traceScript is a helper script that is added to the step script\n// to trace a command.\nconst traceScriptWin = `\nWrite-Output ('+ %s');\n& %s; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n`\n"
  },
  {
    "path": "pipeline/backend/common/script_win_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"testing\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGenerateScriptWin(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom []string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: []string{\"echo %PATH%\", \"go build\", \"go test\"},\n\t\t\twant: `\n$ErrorActionPreference = 'Stop';\nif (-not (Test-Path \"/woodpecker/some\")) { New-Item -Path \"/woodpecker/some\" -ItemType Directory -Force };\nif (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\\root') };\nif (-not (Test-Path \"$env:HOME\")) { New-Item -Path \"$env:HOME\" -ItemType Directory -Force };\nif ($Env:CI_NETRC_MACHINE) {\n$netrc=[string]::Format(\"{0}\\_netrc\",$Env:HOME);\n\"machine $Env:CI_NETRC_MACHINE\" >> $netrc;\n\"login $Env:CI_NETRC_USERNAME\" >> $netrc;\n\"password $Env:CI_NETRC_PASSWORD\" >> $netrc;\n};\n[Environment]::SetEnvironmentVariable(\"CI_NETRC_PASSWORD\",$null);\n[Environment]::SetEnvironmentVariable(\"CI_SCRIPT\",$null);\ncd \"/woodpecker/some\";\n\nWrite-Output ('+ \"echo %PATH%\"');\n& echo %PATH%; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n\nWrite-Output ('+ \"go build\"');\n& go build; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n\nWrite-Output ('+ \"go test\"');\n& go test; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n`,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tscript := generateScriptWindows(test.from, \"/woodpecker/some\")\n\t\tassert.EqualValues(t, test.want, script, \"Want encoded script for %s\", test.from)\n\t}\n}\n\nfunc TestSetupScriptWinProtoParse(t *testing.T) {\n\t// just ensure that we have a working `setupScriptWinTmpl` on runntime\n\t_, err := template.New(\"\").Parse(setupScriptWinProto)\n\tassert.NoError(t, err)\n}\n"
  },
  {
    "path": "pipeline/backend/docker/backend_options.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"github.com/go-viper/mapstructure/v2\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// BackendOptions defines all the advanced options for the docker backend.\ntype BackendOptions struct {\n\tUser string `mapstructure:\"user\"`\n}\n\nfunc parseBackendOptions(step *backend_types.Step) (BackendOptions, error) {\n\tvar result BackendOptions\n\tif step == nil || step.BackendOptions == nil {\n\t\treturn result, nil\n\t}\n\terr := mapstructure.WeakDecode(step.BackendOptions[EngineName], &result)\n\treturn result, err\n}\n"
  },
  {
    "path": "pipeline/backend/docker/backend_options_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc Test_parseBackendOptions(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tstep    *backend_types.Step\n\t\twant    BackendOptions\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"nil options\",\n\t\t\tstep: &backend_types.Step{BackendOptions: nil},\n\t\t\twant: BackendOptions{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty options\",\n\t\t\tstep: &backend_types.Step{BackendOptions: map[string]any{}},\n\t\t\twant: BackendOptions{},\n\t\t},\n\t\t{\n\t\t\tname: \"with user option\",\n\t\t\tstep: &backend_types.Step{BackendOptions: map[string]any{\n\t\t\t\t\"docker\": map[string]any{\n\t\t\t\t\t\"user\": \"1000:1000\",\n\t\t\t\t},\n\t\t\t}},\n\t\t\twant: BackendOptions{User: \"1000:1000\"},\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid backend options\",\n\t\t\tstep:    &backend_types.Step{BackendOptions: map[string]any{\"docker\": \"invalid\"}},\n\t\t\twant:    BackendOptions{},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseBackendOptions(tt.step)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/docker/config.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n)\n\ntype config struct {\n\tenableIPv6    bool\n\tnetwork       string\n\tvolumes       []string\n\tresourceLimit resourceLimit\n\tstopTimeout   int64\n}\n\ntype resourceLimit struct {\n\tMemSwapLimit int64\n\tMemLimit     int64\n\tShmSize      int64\n\tCPUQuota     int64\n\tCPUShares    int64\n\tCPUSet       string\n}\n\nfunc configFromCli(c *cli.Command) (config, error) {\n\tconf := config{\n\t\tenableIPv6: c.Bool(\"backend-docker-ipv6\"),\n\t\tnetwork:    c.String(\"backend-docker-network\"),\n\t\tresourceLimit: resourceLimit{\n\t\t\tMemSwapLimit: c.Int64(\"backend-docker-limit-mem-swap\"),\n\t\t\tMemLimit:     c.Int64(\"backend-docker-limit-mem\"),\n\t\t\tShmSize:      c.Int64(\"backend-docker-limit-shm-size\"),\n\t\t\tCPUQuota:     c.Int64(\"backend-docker-limit-cpu-quota\"),\n\t\t\tCPUShares:    c.Int64(\"backend-docker-limit-cpu-shares\"),\n\t\t\tCPUSet:       c.String(\"backend-docker-limit-cpu-set\"),\n\t\t},\n\t\tstopTimeout: c.Int64(\"backend-docker-stop-timeout\"),\n\t}\n\n\tvolumes := strings.Split(c.String(\"backend-docker-volumes\"), \",\")\n\tconf.volumes = make([]string, 0, len(volumes))\n\t// Validate provided volume definitions\n\tfor _, v := range volumes {\n\t\tif v == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts, err := splitVolumeParts(v)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"can not parse volume config\")\n\t\t\treturn conf, fmt.Errorf(\"invalid volume '%s' provided in WOODPECKER_BACKEND_DOCKER_VOLUMES: %w\", v, err)\n\t\t}\n\t\tconf.volumes = append(conf.volumes, strings.Join(parts, \":\"))\n\t}\n\n\treturn conf, nil\n}\n"
  },
  {
    "path": "pipeline/backend/docker/convert.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\t\"net/netip\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/moby/moby/api/types/container\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// Valid container volumes must have at least two components, source and destination.\nconst minVolumeComponents = 2\n\n// returns a container configuration.\nfunc (e *docker) toConfig(step *types.Step, options BackendOptions) *container.Config {\n\te.windowsPathPatch(step)\n\n\tconfig := &container.Config{\n\t\tImage: step.Image,\n\t\tLabels: map[string]string{\n\t\t\t\"wp_uuid\": step.UUID,\n\t\t\t\"wp_step\": step.Name,\n\t\t},\n\t\tWorkingDir:   step.WorkingDir,\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t\tVolumes:      toVol(step.Volumes),\n\t\tUser:         options.User,\n\t}\n\tconfigEnv := make(map[string]string)\n\tmaps.Copy(configEnv, step.Environment)\n\n\tif len(step.Commands) > 0 {\n\t\tenv, entry := common.GenerateContainerConf(step.Commands, e.info.OSType, step.WorkingDir)\n\t\tmaps.Copy(configEnv, env)\n\t\tconfig.Entrypoint = entry\n\n\t\t// step.WorkingDir will be respected by the generated script\n\t\tconfig.WorkingDir = step.WorkspaceBase\n\t}\n\tif len(step.Entrypoint) > 0 {\n\t\tconfig.Entrypoint = step.Entrypoint\n\t}\n\n\tif len(configEnv) != 0 {\n\t\tconfig.Env = toEnv(configEnv)\n\t}\n\treturn config\n}\n\nfunc toContainerName(step *types.Step) string {\n\treturn \"wp_\" + step.UUID\n}\n\n// returns a container host configuration.\nfunc toHostConfig(step *types.Step, conf *config) (*container.HostConfig, error) {\n\tconfig := &container.HostConfig{\n\t\tResources: container.Resources{\n\t\t\tCPUQuota:   conf.resourceLimit.CPUQuota,\n\t\t\tCPUShares:  conf.resourceLimit.CPUShares,\n\t\t\tCpusetCpus: conf.resourceLimit.CPUSet,\n\t\t\tMemory:     conf.resourceLimit.MemLimit,\n\t\t\tMemorySwap: conf.resourceLimit.MemSwapLimit,\n\t\t},\n\t\tShmSize: conf.resourceLimit.ShmSize,\n\t\tLogConfig: container.LogConfig{\n\t\t\tType: \"json-file\",\n\t\t},\n\t\tPrivileged: step.Privileged,\n\t}\n\n\tif len(step.NetworkMode) != 0 {\n\t\tconfig.NetworkMode = container.NetworkMode(step.NetworkMode)\n\t}\n\tif len(step.DNS) != 0 {\n\t\taddrs := make([]netip.Addr, len(step.DNS))\n\t\tfor i, dns := range step.DNS {\n\t\t\ta, err := netip.ParseAddr(dns)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"could not parse DNS address [%s]: %w\", dns, err)\n\t\t\t}\n\t\t\taddrs[i] = a\n\t\t}\n\t\tconfig.DNS = addrs\n\t}\n\tif len(step.DNSSearch) != 0 {\n\t\tconfig.DNSSearch = step.DNSSearch\n\t}\n\textraHosts := []string{}\n\tfor _, hostAlias := range step.ExtraHosts {\n\t\textraHosts = append(extraHosts, hostAlias.Name+\":\"+hostAlias.IP)\n\t}\n\tif len(step.ExtraHosts) != 0 {\n\t\tconfig.ExtraHosts = extraHosts\n\t}\n\tif len(step.Devices) != 0 {\n\t\tconfig.Devices = toDev(step.Devices)\n\t}\n\tif len(step.Volumes) != 0 {\n\t\tconfig.Binds = step.Volumes\n\t}\n\tconfig.Tmpfs = map[string]string{}\n\tfor _, path := range step.Tmpfs {\n\t\tif !strings.Contains(path, \":\") {\n\t\t\tconfig.Tmpfs[path] = \"\"\n\t\t\tcontinue\n\t\t}\n\t\tparts, err := splitVolumeParts(path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tconfig.Tmpfs[parts[0]] = parts[1]\n\t}\n\n\treturn config, nil\n}\n\n// helper function that converts a slice of volume paths to a set of\n// unique volume names.\nfunc toVol(paths []string) map[string]struct{} {\n\tif len(paths) == 0 {\n\t\treturn nil\n\t}\n\tset := make(map[string]struct{})\n\tfor _, path := range paths {\n\t\tparts, err := splitVolumeParts(path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif len(parts) < minVolumeComponents {\n\t\t\tcontinue\n\t\t}\n\t\tset[parts[1]] = struct{}{}\n\t}\n\treturn set\n}\n\n// helper function that converts a key value map of environment variables to a\n// string slice in key=value format.\nfunc toEnv(env map[string]string) []string {\n\tvar envs []string\n\tfor k, v := range env {\n\t\tif k != \"\" {\n\t\t\tenvs = append(envs, k+\"=\"+v)\n\t\t}\n\t}\n\treturn envs\n}\n\n// toDev converts a slice of volume paths to a set of device mappings for\n// use in a Docker container config. It handles splitting the volume paths\n// into host and container paths, and setting default permissions.\nfunc toDev(paths []string) []container.DeviceMapping {\n\tvar devices []container.DeviceMapping\n\n\tfor _, path := range paths {\n\t\tparts, err := splitVolumeParts(path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif len(parts) < minVolumeComponents {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasSuffix(parts[1], \":ro\") || strings.HasSuffix(parts[1], \":rw\") {\n\t\t\tparts[1] = parts[1][:len(parts[1])-1]\n\t\t}\n\t\tdevices = append(devices, container.DeviceMapping{\n\t\t\tPathOnHost:        parts[0],\n\t\t\tPathInContainer:   parts[1],\n\t\t\tCgroupPermissions: \"rwm\",\n\t\t})\n\t}\n\treturn devices\n}\n\n// helper function that serializes the auth configuration as JSON\n// base64 payload.\nfunc encodeAuthToBase64(authConfig types.Auth) (string, error) {\n\tbuf, err := json.Marshal(authConfig)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.URLEncoding.EncodeToString(buf), nil\n}\n\n// splitVolumeParts splits a volume string into its constituent parts.\n//\n// The parts are:\n//\n//  1. The path on the host machine\n//  2. The path inside the container\n//  3. The read/write mode\n//\n// It handles Windows and Linux style volume paths.\nfunc splitVolumeParts(volumeParts string) ([]string, error) {\n\t// cspell:disable-next-line\n\tpattern := `^((?:[\\w]\\:)?[^\\:]*)\\:((?:[\\w]\\:)?[^\\:]*)(?:\\:([rwom]*))?`\n\tr, err := regexp.Compile(pattern)\n\tif err != nil {\n\t\treturn []string{}, err\n\t}\n\tif r.MatchString(volumeParts) {\n\t\tresults := r.FindStringSubmatch(volumeParts)[1:]\n\t\tvar cleanResults []string\n\t\tfor _, item := range results {\n\t\t\tif item != \"\" {\n\t\t\t\tcleanResults = append(cleanResults, item)\n\t\t\t}\n\t\t}\n\t\treturn cleanResults, nil\n\t}\n\treturn strings.Split(volumeParts, \":\"), nil\n}\n\nfunc toRef[T any](v T) *T {\n\treturn &v\n}\n"
  },
  {
    "path": "pipeline/backend/docker/convert_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"encoding/base64\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/moby/moby/api/types/container\"\n\t\"github.com/moby/moby/api/types/system\"\n\t\"github.com/stretchr/testify/assert\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestSplitVolumeParts(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom    string\n\t\tto      []string\n\t\tsuccess bool\n\t}{\n\t\t{\n\t\t\tfrom:    `Z::Z::rw`,\n\t\t\tto:      []string{`Z:`, `Z:`, `rw`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:\\:Z:\\:rw`,\n\t\t\tto:      []string{`Z:\\`, `Z:\\`, `rw`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:\\git\\refs:Z:\\git\\refs:rw`,\n\t\t\tto:      []string{`Z:\\git\\refs`, `Z:\\git\\refs`, `rw`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:\\git\\refs:Z:\\git\\refs`,\n\t\t\tto:      []string{`Z:\\git\\refs`, `Z:\\git\\refs`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:/:Z:/:rw`,\n\t\t\tto:      []string{`Z:/`, `Z:/`, `rw`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:/git/refs:Z:/git/refs:rw`,\n\t\t\tto:      []string{`Z:/git/refs`, `Z:/git/refs`, `rw`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `Z:/git/refs:Z:/git/refs`,\n\t\t\tto:      []string{`Z:/git/refs`, `Z:/git/refs`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `/test:/test`,\n\t\t\tto:      []string{`/test`, `/test`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `test:/test`,\n\t\t\tto:      []string{`test`, `/test`},\n\t\t\tsuccess: true,\n\t\t},\n\t\t{\n\t\t\tfrom:    `test:test`,\n\t\t\tto:      []string{`test`, `test`},\n\t\t\tsuccess: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tresults, err := splitVolumeParts(test.from)\n\t\tif test.success != (err == nil) {\n\t\t\tassert.Equal(t, test.success, reflect.DeepEqual(results, test.to))\n\t\t}\n\t}\n}\n\n// dummy vars to test against.\nvar (\n\ttestCmdStep = &backend_types.Step{\n\t\tName:        \"hello\",\n\t\tUUID:        \"f51821af-4cb8-435e-a3c2-3a684185d828\",\n\t\tType:        backend_types.StepTypeCommands,\n\t\tCommands:    []string{\"echo \\\"hello world\\\"\", \"ls\"},\n\t\tImage:       \"alpine\",\n\t\tEnvironment: map[string]string{\"SHELL\": \"/bin/zsh\"},\n\t}\n\n\ttestPluginStep = &backend_types.Step{\n\t\tName:        \"lint\",\n\t\tUUID:        \"d841ee40-e66e-4275-bb3f-55bf89744b21\",\n\t\tType:        backend_types.StepTypePlugin,\n\t\tImage:       \"mstruebing/editorconfig-checker\",\n\t\tEnvironment: make(map[string]string),\n\t}\n\n\ttestEngine = &docker{\n\t\tinfo: system.Info{\n\t\t\tArchitecture:    \"x86_64\",\n\t\t\tOSType:          \"linux\",\n\t\t\tDefaultRuntime:  \"runc\",\n\t\t\tDockerRootDir:   \"/var/lib/docker\",\n\t\t\tOperatingSystem: \"Archlinux\",\n\t\t\tName:            \"SOME_HOSTNAME\",\n\t\t},\n\t}\n)\n\nfunc TestToContainerName(t *testing.T) {\n\tassert.EqualValues(t, \"wp_f51821af-4cb8-435e-a3c2-3a684185d828\", toContainerName(testCmdStep))\n\tassert.EqualValues(t, \"wp_d841ee40-e66e-4275-bb3f-55bf89744b21\", toContainerName(testPluginStep))\n}\n\nfunc TestStepToConfig(t *testing.T) {\n\t// StepTypeCommands\n\tconf := testEngine.toConfig(testCmdStep, BackendOptions{})\n\tif assert.NotNil(t, conf) {\n\t\tassert.EqualValues(t, []string{\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"}, conf.Entrypoint)\n\t\tassert.Nil(t, conf.Cmd)\n\t\tassert.EqualValues(t, testCmdStep.UUID, conf.Labels[\"wp_uuid\"])\n\t}\n\n\t// StepTypePlugin\n\tconf = testEngine.toConfig(testPluginStep, BackendOptions{})\n\tif assert.NotNil(t, conf) {\n\t\tassert.Nil(t, conf.Cmd)\n\t\tassert.EqualValues(t, testPluginStep.UUID, conf.Labels[\"wp_uuid\"])\n\t}\n}\n\nfunc TestToEnv(t *testing.T) {\n\tassert.Nil(t, toEnv(nil))\n\tassert.EqualValues(t, []string{\"A=B\"}, toEnv(map[string]string{\"A\": \"B\"}))\n\tassert.ElementsMatch(t, []string{\"A=B=C\", \"T=T\"}, toEnv(map[string]string{\"A\": \"B=C\", \"\": \"Z\", \"T\": \"T\"}))\n}\n\nfunc TestToVol(t *testing.T) {\n\tassert.Nil(t, toVol(nil))\n\tassert.EqualValues(t, map[string]struct{}{\"/test\": {}}, toVol([]string{\"test:/test\"}))\n}\n\nfunc TestEncodeAuthToBase64(t *testing.T) {\n\tres, err := encodeAuthToBase64(backend_types.Auth{})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"e30=\", res)\n\n\tres, err = encodeAuthToBase64(backend_types.Auth{Username: \"user\", Password: \"pwd\"})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InB3ZCJ9\", res)\n}\n\nfunc TestToConfigSmall(t *testing.T) {\n\tengine := docker{info: system.Info{OSType: \"linux\", Architecture: \"riscv64\"}}\n\n\tconf := engine.toConfig(&backend_types.Step{\n\t\tName:     \"test\",\n\t\tUUID:     \"09238932\",\n\t\tCommands: []string{\"go test\"},\n\t}, BackendOptions{})\n\n\tassert.NotNil(t, conf)\n\tsort.Strings(conf.Env)\n\tassert.EqualValues(t, &container.Config{\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t\tEntrypoint:   []string{\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"},\n\t\tLabels: map[string]string{\n\t\t\t\"wp_step\": \"test\",\n\t\t\t\"wp_uuid\": \"09238932\",\n\t\t},\n\t\tEnv: []string{\n\t\t\t\"CI_SCRIPT=CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiIgpjZCAiIgoKZWNobyArICdnbyB0ZXN0JwpnbyB0ZXN0Cg==\",\n\t\t\t\"SHELL=/bin/sh\",\n\t\t},\n\t}, conf)\n}\n\nfunc TestToConfigFull(t *testing.T) {\n\tengine := docker{\n\t\tinfo: system.Info{OSType: \"linux\", Architecture: \"riscv64\"},\n\t\tconfig: config{\n\t\t\tenableIPv6: true,\n\t\t\tresourceLimit: resourceLimit{\n\t\t\t\tMemSwapLimit: 12,\n\t\t\t\tMemLimit:     13,\n\t\t\t\tShmSize:      14,\n\t\t\t\tCPUQuota:     15,\n\t\t\t\tCPUShares:    16,\n\t\t\t},\n\t\t},\n\t}\n\n\tconf := engine.toConfig(&backend_types.Step{\n\t\tName:          \"test\",\n\t\tUUID:          \"09238932\",\n\t\tType:          backend_types.StepTypeCommands,\n\t\tImage:         \"golang:1.2.3\",\n\t\tPull:          true,\n\t\tDetached:      true,\n\t\tPrivileged:    true,\n\t\tWorkingDir:    \"/src/abc\",\n\t\tWorkspaceBase: \"/src\",\n\t\tEnvironment:   map[string]string{\"TAGS\": \"sqlite\"},\n\t\tCommands:      []string{\"go test\", \"go vet ./...\"},\n\t\tExtraHosts:    []backend_types.HostAlias{{Name: \"t\", IP: \"1.2.3.4\"}},\n\t\tVolumes:       []string{\"/cache:/cache\"},\n\t\tTmpfs:         []string{\"/tmp\"},\n\t\tDevices:       []string{\"/dev/sdc\"},\n\t\tNetworks:      []backend_types.Conn{{Name: \"extra-net\", Aliases: []string{\"extra.net\"}}},\n\t\tDNS:           []string{\"9.9.9.9\", \"8.8.8.8\"},\n\t\tDNSSearch:     nil,\n\t\tOnFailure:     true,\n\t\tOnSuccess:     true,\n\t\tFailure:       \"fail\",\n\t\tAuthConfig:    backend_types.Auth{Username: \"user\", Password: \"123456\"},\n\t\tNetworkMode:   \"bridge\",\n\t\tPorts:         []backend_types.Port{{Number: 21}, {Number: 22}},\n\t}, BackendOptions{})\n\n\tassert.NotNil(t, conf)\n\tsort.Strings(conf.Env)\n\tassert.EqualValues(t, &container.Config{\n\t\tImage:        \"golang:1.2.3\",\n\t\tWorkingDir:   \"/src\",\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t\tEntrypoint:   []string{\"/bin/sh\", \"-c\", \"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"},\n\t\tLabels: map[string]string{\n\t\t\t\"wp_step\": \"test\",\n\t\t\t\"wp_uuid\": \"09238932\",\n\t\t},\n\t\tEnv: []string{\n\t\t\t\"CI_SCRIPT=CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3NyYy9hYmMiCmNkICIvc3JjL2FiYyIKCmVjaG8gKyAnZ28gdGVzdCcKZ28gdGVzdAoKZWNobyArICdnbyB2ZXQgLi8uLi4nCmdvIHZldCAuLy4uLgo=\",\n\t\t\t\"SHELL=/bin/sh\",\n\t\t\t\"TAGS=sqlite\",\n\t\t},\n\t\tVolumes: map[string]struct{}{\n\t\t\t\"/cache\": {},\n\t\t},\n\t}, conf)\n}\n\nfunc TestToWindowsConfig(t *testing.T) {\n\tengine := docker{\n\t\tinfo: system.Info{OSType: \"windows\", Architecture: \"x86_64\"},\n\t\tconfig: config{\n\t\t\tenableIPv6: true,\n\t\t},\n\t}\n\n\tconf := engine.toConfig(&backend_types.Step{\n\t\tName:          \"test\",\n\t\tUUID:          \"23434553\",\n\t\tType:          backend_types.StepTypeCommands,\n\t\tImage:         \"golang:1.2.3\",\n\t\tWorkingDir:    \"/src/abc\",\n\t\tWorkspaceBase: \"/src\",\n\t\tEnvironment: map[string]string{\n\t\t\t\"TAGS\":         \"sqlite\",\n\t\t\t\"CI_WORKSPACE\": \"/src\",\n\t\t},\n\t\tCommands:    []string{\"go test\", \"go vet ./...\"},\n\t\tExtraHosts:  []backend_types.HostAlias{{Name: \"t\", IP: \"1.2.3.4\"}},\n\t\tVolumes:     []string{\"wp_default_abc:/src\", \"/cache:/cache/some/more\", \"test:/test\"},\n\t\tNetworks:    []backend_types.Conn{{Name: \"extra-net\", Aliases: []string{\"extra.net\"}}},\n\t\tDNS:         []string{\"9.9.9.9\", \"8.8.8.8\"},\n\t\tFailure:     \"fail\",\n\t\tAuthConfig:  backend_types.Auth{Username: \"user\", Password: \"123456\"},\n\t\tNetworkMode: \"nat\",\n\t\tPorts:       []backend_types.Port{{Number: 21}, {Number: 22}},\n\t}, BackendOptions{})\n\n\tassert.NotNil(t, conf)\n\tsort.Strings(conf.Env)\n\tassert.EqualValues(t, &container.Config{\n\t\tImage:        \"golang:1.2.3\",\n\t\tWorkingDir:   \"C:/src\",\n\t\tAttachStdout: true,\n\t\tAttachStderr: true,\n\t\tEntrypoint:   []string{\"powershell\", \"-noprofile\", \"-noninteractive\", \"-command\", \"[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex\"},\n\t\tLabels: map[string]string{\n\t\t\t\"wp_step\": \"test\",\n\t\t\t\"wp_uuid\": \"23434553\",\n\t\t},\n\t\tEnv: []string{\n\t\t\t\"CI_SCRIPT=CiRFcnJvckFjdGlvblByZWZlcmVuY2UgPSAnU3RvcCc7CmlmICgtbm90IChUZXN0LVBhdGggIkM6L3NyYy9hYmMiKSkgeyBOZXctSXRlbSAtUGF0aCAiQzovc3JjL2FiYyIgLUl0ZW1UeXBlIERpcmVjdG9yeSAtRm9yY2UgfTsKaWYgKC1ub3QgW0Vudmlyb25tZW50XTo6R2V0RW52aXJvbm1lbnRWYXJpYWJsZSgnSE9NRScpKSB7IFtFbnZpcm9ubWVudF06OlNldEVudmlyb25tZW50VmFyaWFibGUoJ0hPTUUnLCAnYzpccm9vdCcpIH07CmlmICgtbm90IChUZXN0LVBhdGggIiRlbnY6SE9NRSIpKSB7IE5ldy1JdGVtIC1QYXRoICIkZW52OkhPTUUiIC1JdGVtVHlwZSBEaXJlY3RvcnkgLUZvcmNlIH07CmlmICgkRW52OkNJX05FVFJDX01BQ0hJTkUpIHsKJG5ldHJjPVtzdHJpbmddOjpGb3JtYXQoInswfVxfbmV0cmMiLCRFbnY6SE9NRSk7CiJtYWNoaW5lICRFbnY6Q0lfTkVUUkNfTUFDSElORSIgPj4gJG5ldHJjOwoibG9naW4gJEVudjpDSV9ORVRSQ19VU0VSTkFNRSIgPj4gJG5ldHJjOwoicGFzc3dvcmQgJEVudjpDSV9ORVRSQ19QQVNTV09SRCIgPj4gJG5ldHJjOwp9OwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9ORVRSQ19QQVNTV09SRCIsJG51bGwpOwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9TQ1JJUFQiLCRudWxsKTsKY2QgIkM6L3NyYy9hYmMiOwoKV3JpdGUtT3V0cHV0ICgnKyAiZ28gdGVzdCInKTsKJiBnbyB0ZXN0OyBpZiAoJExBU1RFWElUQ09ERSAtbmUgMCkge2V4aXQgJExBU1RFWElUQ09ERX0KCldyaXRlLU91dHB1dCAoJysgImdvIHZldCAuLy4uLiInKTsKJiBnbyB2ZXQgLi8uLi47IGlmICgkTEFTVEVYSVRDT0RFIC1uZSAwKSB7ZXhpdCAkTEFTVEVYSVRDT0RFfQo=\",\n\t\t\t\"CI_WORKSPACE=C:/src\",\n\t\t\t\"SHELL=powershell.exe\",\n\t\t\t\"TAGS=sqlite\",\n\t\t},\n\t\tVolumes: map[string]struct{}{\n\t\t\t\"C:/cache/some/more\": {},\n\t\t\t\"C:/src\":             {},\n\t\t\t\"C:/test\":            {},\n\t\t},\n\t}, conf)\n\n\tciScript, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(conf.Env[0], \"CI_SCRIPT=\"))\n\tif assert.NoError(t, err) {\n\t\tassert.EqualValues(t, `\n$ErrorActionPreference = 'Stop';\nif (-not (Test-Path \"C:/src/abc\")) { New-Item -Path \"C:/src/abc\" -ItemType Directory -Force };\nif (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\\root') };\nif (-not (Test-Path \"$env:HOME\")) { New-Item -Path \"$env:HOME\" -ItemType Directory -Force };\nif ($Env:CI_NETRC_MACHINE) {\n$netrc=[string]::Format(\"{0}\\_netrc\",$Env:HOME);\n\"machine $Env:CI_NETRC_MACHINE\" >> $netrc;\n\"login $Env:CI_NETRC_USERNAME\" >> $netrc;\n\"password $Env:CI_NETRC_PASSWORD\" >> $netrc;\n};\n[Environment]::SetEnvironmentVariable(\"CI_NETRC_PASSWORD\",$null);\n[Environment]::SetEnvironmentVariable(\"CI_SCRIPT\",$null);\ncd \"C:/src/abc\";\n\nWrite-Output ('+ \"go test\"');\n& go test; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n\nWrite-Output ('+ \"go vet ./...\"');\n& go vet ./...; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE}\n`, string(ciScript))\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/docker/convert_win.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nconst (\n\tosTypeWindows              = \"windows\"\n\tdefaultWindowsDriverLetter = \"C:\"\n)\n\nvar MustNotAddWindowsLetterPattern = regexp.MustCompile(`^(?:` +\n\t// Drive letter followed by colon and optional backslash (C: or C:\\)\n\t`[a-zA-Z]:(?:\\\\|$)|` +\n\n\t// Device path starting with \\\\ or // followed by .\\ or ./ (\\\\.\\  or //./  or \\\\./ or //.\\ )\n\t`(?:\\\\\\\\|//)\\.(?:\\\\|/).*|` +\n\n\t// UNC path starting with \\\\ or // followed by non-dot (\\server or //server)\n\t`(?:\\\\\\\\|//)[^.]|` +\n\n\t// Relative path starting with .\\ or ./ (.\\path or ./path)\n\t`\\.(?:\\\\|/)` +\n\t`)`)\n\nfunc (e *docker) windowsPathPatch(step *types.Step) {\n\t// only patch if target is windows\n\tif strings.ToLower(e.info.OSType) != osTypeWindows {\n\t\treturn\n\t}\n\n\t// patch volumes to have an letter if not already set\n\tfor i, vol := range step.Volumes {\n\t\tvolParts, err := splitVolumeParts(vol)\n\t\tif err != nil || len(volParts) < 2 {\n\t\t\t// ignore non valid volumes for now\n\t\t\tcontinue\n\t\t}\n\n\t\t// fix source destination\n\t\tif strings.HasPrefix(volParts[0], \"/\") {\n\t\t\tvolParts[0] = filepath.Join(defaultWindowsDriverLetter, volParts[0])\n\t\t}\n\n\t\t// fix mount destination\n\t\tif !MustNotAddWindowsLetterPattern.MatchString(volParts[1]) {\n\t\t\tvolParts[1] = filepath.Join(defaultWindowsDriverLetter, volParts[1])\n\t\t}\n\t\tstep.Volumes[i] = strings.Join(volParts, \":\")\n\t}\n\n\t// patch workspace\n\tif !MustNotAddWindowsLetterPattern.MatchString(step.WorkspaceBase) {\n\t\tstep.WorkspaceBase = filepath.Join(defaultWindowsDriverLetter, step.WorkspaceBase)\n\t}\n\tif !MustNotAddWindowsLetterPattern.MatchString(step.WorkingDir) {\n\t\tstep.WorkingDir = filepath.Join(defaultWindowsDriverLetter, step.WorkingDir)\n\t}\n\tif ciWorkspace, ok := step.Environment[\"CI_WORKSPACE\"]; ok {\n\t\tif !MustNotAddWindowsLetterPattern.MatchString(ciWorkspace) {\n\t\t\tstep.Environment[\"CI_WORKSPACE\"] = filepath.Join(defaultWindowsDriverLetter, ciWorkspace)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/docker/convert_win_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport \"testing\"\n\nfunc TestMustNotAddWindowsLetterPattern(t *testing.T) {\n\ttests := map[string]bool{\n\t\t`C:\\Users`:           true,\n\t\t`D:\\Data`:            true,\n\t\t`\\\\.\\PhysicalDrive0`: true,\n\t\t`//./COM1`:           true,\n\t\t`E:`:                 true,\n\t\t`\\\\server\\share`:     true, // UNC path\n\t\t`.\\relative\\path`:    true, // Relative path\n\t\t`./path`:             true, // Relative with forward slash\n\t\t`//server/share`:     true, // UNC with forward slashes\n\t\t`not/a/windows/path`: false,\n\t\t``:                   false,\n\t\t`/usr/local`:         false,\n\t\t`COM1`:               false,\n\t\t`\\\\.`:                false, // Incomplete device path\n\t\t`//`:                 false,\n\t}\n\n\tfor testCase, expected := range tests {\n\t\tresult := MustNotAddWindowsLetterPattern.MatchString(testCase)\n\t\tif result != expected {\n\t\t\tt.Errorf(\"Test case %q: expected %v but got %v\", testCase, expected, result)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/docker/docker.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/containerd/errdefs\"\n\t\"github.com/docker/go-connections/tlsconfig\"\n\t\"github.com/moby/moby/api/pkg/stdcopy\"\n\t\"github.com/moby/moby/api/types/network\"\n\t\"github.com/moby/moby/api/types/system\"\n\t\"github.com/moby/moby/client\"\n\t\"github.com/moby/moby/client/pkg/jsonmessage\"\n\t\"github.com/moby/term\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\t\"golang.org/x/sync/errgroup\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tcontainerKillTimeout               = 5 // seconds\n\tvolumeRetryWait      time.Duration = 1 * time.Second\n\tmaxRetry             uint          = 3\n)\n\ntype docker struct {\n\tclient client.APIClient\n\tinfo   system.Info\n\tconfig config\n}\n\nconst (\n\tEngineName          = \"docker\"\n\tnetworkDriverNAT    = \"nat\"\n\tnetworkDriverBridge = \"bridge\"\n\tvolumeDriver        = \"local\"\n)\n\n// New returns a new Docker Backend.\nfunc New() backend_types.Backend {\n\treturn &docker{\n\t\tclient: nil,\n\t}\n}\n\nfunc (e *docker) Name() string {\n\treturn EngineName\n}\n\nfunc (e *docker) IsAvailable(ctx context.Context) bool {\n\tif c, ok := ctx.Value(backend_types.CliCommand).(*cli.Command); ok {\n\t\tif c.IsSet(\"backend-docker-host\") {\n\t\t\treturn true\n\t\t}\n\t}\n\t_, err := os.Stat(\"/var/run/docker.sock\")\n\treturn err == nil\n}\n\nfunc httpClientOfOpts(dockerCertPath string, verifyTLS bool) *http.Client {\n\tif dockerCertPath == \"\" {\n\t\treturn nil\n\t}\n\n\toptions := tlsconfig.Options{\n\t\tCAFile:             filepath.Join(dockerCertPath, \"ca.pem\"),\n\t\tCertFile:           filepath.Join(dockerCertPath, \"cert.pem\"),\n\t\tKeyFile:            filepath.Join(dockerCertPath, \"key.pem\"),\n\t\tInsecureSkipVerify: !verifyTLS,\n\t}\n\ttlsConf, err := tlsconfig.Client(options)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not create http client out of docker backend options\")\n\t\treturn nil\n\t}\n\n\treturn &http.Client{\n\t\tTransport: httputil.NewUserAgentRoundTripper(\n\t\t\t&http.Transport{TLSClientConfig: tlsConf},\n\t\t\t\"backend-docker\"),\n\t\tCheckRedirect: client.CheckRedirect,\n\t}\n}\n\nfunc (e *docker) Flags() []cli.Flag {\n\treturn Flags\n}\n\n// Load new client for Docker Backend using environment variables.\nfunc (e *docker) Load(ctx context.Context) (*backend_types.BackendInfo, error) {\n\tc, ok := ctx.Value(backend_types.CliCommand).(*cli.Command)\n\tif !ok {\n\t\treturn nil, backend_types.ErrNoCliContextFound\n\t}\n\n\tvar dockerClientOpts []client.Opt\n\tif httpClient := httpClientOfOpts(c.String(\"backend-docker-cert\"), c.Bool(\"backend-docker-tls-verify\")); httpClient != nil {\n\t\tdockerClientOpts = append(dockerClientOpts, client.WithHTTPClient(httpClient))\n\t}\n\tif dockerHost := c.String(\"backend-docker-host\"); dockerHost != \"\" {\n\t\tdockerClientOpts = append(dockerClientOpts, client.WithHost(dockerHost))\n\t}\n\tif dockerAPIVersion := c.String(\"backend-docker-api-version\"); dockerAPIVersion != \"\" {\n\t\tdockerClientOpts = append(dockerClientOpts, client.WithAPIVersion(dockerAPIVersion))\n\t}\n\n\tcl, err := client.New(dockerClientOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\te.client = cl\n\n\tinfo, err := cl.Info(ctx, client.InfoOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\te.info = info.Info\n\n\te.config, err = configFromCli(c)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &backend_types.BackendInfo{\n\t\tPlatform: e.info.OSType + \"/\" + normalizeArchType(e.info.Architecture),\n\t}, nil\n}\n\nfunc (e *docker) SetupWorkflow(ctx context.Context, conf *backend_types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msg(\"create workflow environment\")\n\n\t_, err := e.client.VolumeCreate(ctx, client.VolumeCreateOptions{\n\t\tName:   conf.Volume,\n\t\tDriver: volumeDriver,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnetworkDriver := networkDriverBridge\n\tif e.info.OSType == \"windows\" {\n\t\tnetworkDriver = networkDriverNAT\n\t}\n\t_, err = e.client.NetworkCreate(ctx, conf.Network, client.NetworkCreateOptions{\n\t\tDriver:     networkDriver,\n\t\tEnableIPv6: &e.config.enableIPv6,\n\t})\n\treturn err\n}\n\nfunc (e *docker) StartStep(ctx context.Context, step *backend_types.Step, taskUUID string) error {\n\toptions, err := parseBackendOptions(step)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not parse backend options\")\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"start step %s\", step.Name)\n\n\tconfig := e.toConfig(step, options)\n\thostConfig, err := toHostConfig(step, &e.config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcontainerName := toContainerName(step)\n\n\t// create pull options with encoded authorization credentials.\n\tpullOpts := client.ImagePullOptions{}\n\tif step.AuthConfig.Username != \"\" && step.AuthConfig.Password != \"\" {\n\t\tpullOpts.RegistryAuth, _ = encodeAuthToBase64(step.AuthConfig)\n\t}\n\n\t// automatically pull the latest version of the image if requested\n\t// by the process configuration.\n\tif step.Pull {\n\t\tresponseBody, pErr := e.client.ImagePull(ctx, config.Image, pullOpts)\n\t\tif pErr == nil {\n\t\t\t// TODO(1936): show image pull progress in web-ui\n\t\t\tfd, isTerminal := term.GetFdInfo(os.Stdout)\n\t\t\tif err := jsonmessage.DisplayJSONMessagesStream(responseBody, os.Stdout, fd, isTerminal, nil); err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"DisplayJSONMessagesStream\")\n\t\t\t}\n\t\t\tresponseBody.Close()\n\t\t}\n\t\t// Fix \"Show warning when fail to auth to docker registry\"\n\t\t// (https://web.archive.org/web/20201023145804/https://github.com/drone/drone/issues/1917)\n\t\tif pErr != nil && step.AuthConfig.Password != \"\" {\n\t\t\treturn pErr\n\t\t}\n\t}\n\n\t// add default volumes to the host configuration\n\thostConfig.Binds = utils.DeduplicateStrings(append(hostConfig.Binds, e.config.volumes...))\n\n\t_, err = e.client.ContainerCreate(ctx, client.ContainerCreateOptions{\n\t\tConfig:     config,\n\t\tHostConfig: hostConfig,\n\t\tName:       containerName,\n\t})\n\tif errdefs.IsNotFound(err) {\n\t\t// automatically pull and try to re-create the image if the\n\t\t// failure is caused because the image does not exist.\n\t\tresponseBody, pErr := e.client.ImagePull(ctx, config.Image, pullOpts)\n\t\tif pErr != nil {\n\t\t\treturn pErr\n\t\t}\n\t\t// TODO(1936): show image pull progress in web-ui\n\t\tfd, isTerminal := term.GetFdInfo(os.Stdout)\n\t\tif err := jsonmessage.DisplayJSONMessagesStream(responseBody, os.Stdout, fd, isTerminal, nil); err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"DisplayJSONMessagesStream\")\n\t\t}\n\t\tresponseBody.Close()\n\n\t\t_, err = e.client.ContainerCreate(ctx, client.ContainerCreateOptions{\n\t\t\tConfig:     config,\n\t\t\tHostConfig: hostConfig,\n\t\t\tName:       containerName,\n\t\t})\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(step.NetworkMode) == 0 {\n\t\tfor _, net := range step.Networks {\n\t\t\t_, err = e.client.NetworkConnect(ctx, net.Name, client.NetworkConnectOptions{\n\t\t\t\tEndpointConfig: &network.EndpointSettings{\n\t\t\t\t\tAliases: net.Aliases,\n\t\t\t\t},\n\t\t\t\tContainer: containerName,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// join the container to an existing network\n\t\tif e.config.network != \"\" {\n\t\t\t_, err = e.client.NetworkConnect(ctx, e.config.network, client.NetworkConnectOptions{\n\t\t\t\tContainer: containerName,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\t_, err = e.client.ContainerStart(ctx, containerName, client.ContainerStartOptions{})\n\treturn err\n}\n\nfunc (e *docker) WaitStep(ctx context.Context, step *backend_types.Step, taskUUID string) (*backend_types.State, error) {\n\tlog := log.Logger.With().Str(\"taskUUID\", taskUUID).Str(\"stepUUID\", step.UUID).Logger()\n\tlog.Trace().Msgf(\"wait for step %s\", step.Name)\n\n\tcontainerName := toContainerName(step)\n\n\twait := e.client.ContainerWait(ctx, containerName, client.ContainerWaitOptions{})\n\tselect {\n\tcase resp := <-wait.Result:\n\t\tlog.Trace().Msgf(\"ContainerWait returned with resp: %v\", resp)\n\t\tif resp.Error != nil {\n\t\t\treturn nil, fmt.Errorf(\"ContainerWait error: %s\", resp.Error.Message)\n\t\t}\n\tcase err := <-wait.Error:\n\t\tlog.Trace().Msgf(\"ContainerWait returned with err: %v\", err)\n\t\treturn nil, err\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\t}\n\n\tinfo, err := e.client.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texitCode := info.Container.State.ExitCode\n\t// Windows Docker may return 4294967295 (uint32 max, i.e. int32(-1)) for abnormal exits.\n\tif int64(exitCode) == int64(4294967295) { //nolint:mnd // because it is int(^uint32(0))\n\t\texitCode = -1\n\t}\n\n\treturn &backend_types.State{\n\t\tExited:    true,\n\t\tExitCode:  exitCode,\n\t\tOOMKilled: info.Container.State.OOMKilled,\n\t}, nil\n}\n\nfunc (e *docker) TailStep(ctx context.Context, step *backend_types.Step, taskUUID string) (io.ReadCloser, error) {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"tail logs of step %s\", step.Name)\n\n\tlogs, err := e.client.ContainerLogs(ctx, toContainerName(step), client.ContainerLogsOptions{\n\t\tFollow:     true,\n\t\tShowStdout: true,\n\t\tShowStderr: true,\n\t\tDetails:    false,\n\t\tTimestamps: false,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trc, wc := io.Pipe()\n\n\t// de multiplex 'logs' who contains two streams, previously multiplexed together using StdWriter\n\tgo func() {\n\t\t_, _ = stdcopy.StdCopy(wc, wc, logs)\n\t\t_ = logs.Close()\n\t\t_ = wc.Close()\n\t}()\n\treturn rc, nil\n}\n\nfunc (e *docker) DestroyStep(ctx context.Context, step *backend_types.Step, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"stop step %s\", step.Name)\n\n\tcontainerName := toContainerName(step)\n\tvar stopErr error\n\n\t// we first signal to the container to stop ...\n\tif _, err := e.client.ContainerStop(ctx, containerName, client.ContainerStopOptions{\n\t\tTimeout: toRef(int(e.config.stopTimeout)),\n\t}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {\n\t\t// we do not return error yet as we try to kill it first\n\t\tstopErr = fmt.Errorf(\"could not stop container '%s': %w\", step.Name, err)\n\t}\n\n\t// ... and if stop does not work just force kill it\n\tif _, err := e.client.ContainerKill(ctx, containerName, client.ContainerKillOptions{\n\t\tSignal: \"9\",\n\t}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {\n\t\treturn errors.Join(stopErr, fmt.Errorf(\"could not kill container '%s': %w\", step.Name, err))\n\t}\n\n\t// now we clean up files left\n\tif _, err := e.client.ContainerRemove(ctx, containerName, removeOpts); err != nil && !isErrContainerNotFoundOrNotRunning(err) {\n\t\treturn fmt.Errorf(\"could not remove container '%s': %w\", step.Name, err)\n\t}\n\n\treturn nil\n}\n\nfunc (e *docker) DestroyWorkflow(ctx context.Context, conf *backend_types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"delete workflow environment\")\n\n\terrWG := errgroup.Group{}\n\n\tfor _, stage := range conf.Stages {\n\t\tfor _, step := range stage.Steps {\n\t\t\terrWG.Go(func() error {\n\t\t\t\treturn e.DestroyStep(ctx, step, taskUUID)\n\t\t\t})\n\t\t}\n\t}\n\n\tif err := errWG.Wait(); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not destroy all containers\")\n\t}\n\n\tvar err error\n\t_, _ = backoff.Retry(ctx, func() (any, error) {\n\t\t_, err = e.client.VolumeRemove(ctx, conf.Volume, client.VolumeRemoveOptions{\n\t\t\tForce: true,\n\t\t})\n\t\tif err == nil || !isErrVolumeInUse(err) {\n\t\t\t// if it worked or if we have no \"in use error\" do not retry\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}, backoff.WithMaxTries(maxRetry), backoff.WithBackOff(&backoff.ExponentialBackOff{\n\t\tInitialInterval: volumeRetryWait,\n\t\tMultiplier:      2, //nolint:mnd\n\t}))\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not remove volume '%s'\", conf.Volume)\n\t}\n\n\tif _, err := e.client.NetworkRemove(ctx, conf.Network, client.NetworkRemoveOptions{}); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not remove network '%s'\", conf.Network)\n\t}\n\treturn nil\n}\n\nvar removeOpts = client.ContainerRemoveOptions{\n\tRemoveVolumes: true,\n\tRemoveLinks:   false,\n\tForce:         false,\n}\n\n// normalizeArchType converts the arch type reported by docker info into\n// the runtime.GOARCH format\n// TODO: find out if we we need to convert other arch types too\nfunc normalizeArchType(s string) string {\n\tswitch s {\n\tcase \"x86_64\":\n\t\treturn \"amd64\"\n\tcase \"aarch64\":\n\t\treturn \"arm64\"\n\tdefault:\n\t\treturn s\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/docker/errors.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport \"strings\"\n\nfunc isErrContainerNotFoundOrNotRunning(err error) bool {\n\t// Error response from daemon: Cannot kill container: ...: No such container: ...\n\t// Error response from daemon: Cannot kill container: ...: Container ... is not running\"\n\t// Error response from podman daemon: can only kill running containers. ... is in state exited\n\t// Error response from daemon: removal of container ... is already in progress\n\t// Error: No such container: ...\n\treturn err != nil &&\n\t\t(strings.Contains(err.Error(), \"No such container\") ||\n\t\t\tstrings.Contains(err.Error(), \"is not running\") ||\n\t\t\tstrings.Contains(err.Error(), \"can only kill running containers\") ||\n\t\t\t(strings.Contains(err.Error(), \"removal of container\") && strings.Contains(err.Error(), \"is already in progress\")))\n}\n\nfunc isErrVolumeInUse(err error) bool {\n\treturn err != nil &&\n\t\tstrings.Contains(err.Error(), \"volume is in use\")\n}\n"
  },
  {
    "path": "pipeline/backend/docker/flags.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage docker\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\nvar Flags = []cli.Flag{\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_HOST\", \"DOCKER_HOST\"),\n\t\tName:    \"backend-docker-host\",\n\t\tUsage:   \"path to docker socket or url to the docker server\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_API_VERSION\", \"DOCKER_API_VERSION\"),\n\t\tName:    \"backend-docker-api-version\",\n\t\tUsage:   \"the version of the API to reach, leave empty for latest.\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_CERT_PATH\", \"DOCKER_CERT_PATH\"),\n\t\tName:    \"backend-docker-cert\",\n\t\tUsage:   \"path to load the TLS certificates for connecting to docker server\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_TLS_VERIFY\", \"DOCKER_TLS_VERIFY\"),\n\t\tName:    \"backend-docker-tls-verify\",\n\t\tUsage:   \"enable or disable TLS verification for connecting to docker server\",\n\t\tValue:   true,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6\"),\n\t\tName:    \"backend-docker-ipv6\",\n\t\tUsage:   \"backend docker enable IPV6\",\n\t\tValue:   false,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_NETWORK\"),\n\t\tName:    \"backend-docker-network\",\n\t\tUsage:   \"backend docker network\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_VOLUMES\"),\n\t\tName:    \"backend-docker-volumes\",\n\t\tUsage:   \"backend docker volumes (comma separated)\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_STOP_TIMEOUT\"),\n\t\tName:    \"backend-docker-stop-timeout\",\n\t\tUsage:   \"seconds Woodpecker waits for a container to stop gracefully before forcefully killing it\",\n\t\tValue:   20, //nolint:mnd\n\t},\n\t//\n\t// resource limit parameters\n\t//\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP\", \"WOODPECKER_LIMIT_MEM_SWAP\"),\n\t\tName:    \"backend-docker-limit-mem-swap\",\n\t\tUsage:   \"maximum memory used for swap in bytes\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_MEM\", \"WOODPECKER_LIMIT_MEM\"),\n\t\tName:    \"backend-docker-limit-mem\",\n\t\tUsage:   \"maximum memory allowed in bytes\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE\", \"WOODPECKER_LIMIT_SHM_SIZE\"),\n\t\tName:    \"backend-docker-limit-shm-size\",\n\t\tUsage:   \"docker /dev/shm allowed in bytes\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA\", \"WOODPECKER_LIMIT_CPU_QUOTA\"),\n\t\tName:    \"backend-docker-limit-cpu-quota\",\n\t\tUsage:   \"impose a cpu quota\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES\", \"WOODPECKER_LIMIT_CPU_SHARES\"),\n\t\tName:    \"backend-docker-limit-cpu-shares\",\n\t\tUsage:   \"change the cpu shares\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET\", \"WOODPECKER_LIMIT_CPU_SET\"),\n\t\tName:    \"backend-docker-limit-cpu-set\",\n\t\tUsage:   \"set the cpus allowed to execute containers\",\n\t},\n}\n"
  },
  {
    "path": "pipeline/backend/dummy/dummy.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage dummy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\ntype dummy struct {\n\tkv sync.Map\n}\n\nconst (\n\t// Step names to control behavior of dummy backend.\n\tWorkflowSetupFailUUID = \"WorkflowSetupShouldFail\"\n\tEnvKeyStepSleep       = \"SLEEP\"\n\tEnvKeyStepType        = \"EXPECT_TYPE\"\n\tEnvKeyStepStartFail   = \"STEP_START_FAIL\"\n\tEnvKeyStepExitCode    = \"STEP_EXIT_CODE\"\n\tEnvKeyStepTailFail    = \"STEP_TAIL_FAIL\"\n\tEnvKeyStepOOMKilled   = \"STEP_OOM_KILLED\"\n\n\t// Internal const.\n\tstepStateStarted   = \"started\"\n\tstepStateDone      = \"done\"\n\ttestServiceTimeout = 1 * time.Second\n\n\t// ExitCodeCanceled is the exit code returned when a step's context is\n\t// canceled while it is sleeping. 130 matches the SIGINT shell convention\n\t// (128 + signal 2) used by real container runtimes.\n\tExitCodeCanceled = 130\n)\n\n// stepKey returns the kv-store key for a step's state.\nfunc stepKey(taskUUID, stepUUID string) string {\n\treturn \"task_\" + taskUUID + \"_step_\" + stepUUID\n}\n\n// workflowKey returns the kv-store key for a workflow's state.\nfunc workflowKey(taskUUID string) string {\n\treturn \"task_\" + taskUUID\n}\n\n// New returns a dummy backend.\nfunc New() backend_types.Backend {\n\treturn &dummy{\n\t\tkv: sync.Map{},\n\t}\n}\n\nfunc (e *dummy) Name() string {\n\treturn \"dummy\"\n}\n\nfunc (e *dummy) IsAvailable(_ context.Context) bool {\n\treturn true\n}\n\nfunc (e *dummy) Flags() []cli.Flag {\n\treturn nil\n}\n\n// Load new client for Docker Backend using environment variables.\nfunc (e *dummy) Load(_ context.Context) (*backend_types.BackendInfo, error) {\n\treturn &backend_types.BackendInfo{\n\t\tPlatform: \"dummy\",\n\t}, nil\n}\n\nfunc (e *dummy) SetupWorkflow(_ context.Context, _ *backend_types.Config, taskUUID string) error {\n\tif taskUUID == WorkflowSetupFailUUID {\n\t\treturn fmt.Errorf(\"expected fail to setup workflow\")\n\t}\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msg(\"create workflow environment\")\n\te.kv.Store(workflowKey(taskUUID), make(chan struct{}))\n\treturn nil\n}\n\nfunc (e *dummy) StartStep(_ context.Context, step *backend_types.Step, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"start step %s\", step.Name)\n\n\t// internal state checks\n\t_, exist := e.kv.Load(workflowKey(taskUUID))\n\tif !exist {\n\t\treturn fmt.Errorf(\"expect env of workflow %s to exist but found none to destroy\", taskUUID)\n\t}\n\tkey := stepKey(taskUUID, step.UUID)\n\tstepState, stepExist := e.kv.Load(key)\n\tif stepExist {\n\t\t// Detect issues like https://github.com/woodpecker-ci/woodpecker/issues/3494\n\t\treturn fmt.Errorf(\"StartStep detected already started step '%s' (%s) in state: %s\", step.Name, step.UUID, stepState)\n\t}\n\n\tif stepStartFail, _ := strconv.ParseBool(step.Environment[EnvKeyStepStartFail]); stepStartFail {\n\t\treturn fmt.Errorf(\"expected fail to start step\")\n\t}\n\n\texpectStepType, testStepType := step.Environment[EnvKeyStepType]\n\tif testStepType && string(step.Type) != expectStepType {\n\t\treturn fmt.Errorf(\"expected step type '%s' but got '%s'\", expectStepType, step.Type)\n\t}\n\n\te.kv.Store(key, stepStateStarted)\n\treturn nil\n}\n\n// canceledState returns the state for a step whose context was canceled.\nfunc canceledState() *backend_types.State {\n\treturn &backend_types.State{ExitCode: ExitCodeCanceled, Exited: true}\n}\n\n// sleepWithContext blocks for the given duration or until ctx is canceled.\n// Returns true if canceled, false if the sleep completed normally.\nfunc sleepWithContext(ctx context.Context, stop <-chan struct{}, d time.Duration) (canceled bool) {\n\tif ctx.Err() != nil {\n\t\treturn true\n\t}\n\tt := time.NewTimer(d)\n\tdefer t.Stop()\n\tselect {\n\tcase <-t.C:\n\t\treturn false\n\tcase <-ctx.Done():\n\t\treturn true\n\tcase <-stop:\n\t\treturn false\n\t}\n}\n\nfunc (e *dummy) WaitStep(ctx context.Context, step *backend_types.Step, taskUUID string) (*backend_types.State, error) {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"wait for step %s\", step.Name)\n\n\trawWC, exist := e.kv.Load(workflowKey(taskUUID))\n\tif !exist {\n\t\terr := fmt.Errorf(\"expect env of workflow %s to exist but found none to destroy\", taskUUID)\n\t\treturn &backend_types.State{Error: err}, err\n\t}\n\twc, ok := rawWC.(chan struct{})\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"workflow stop chan not found\")\n\t}\n\n\tkey := stepKey(taskUUID, step.UUID)\n\n\t// check state\n\tstepState, stepExist := e.kv.Load(key)\n\tif !stepExist {\n\t\terr := fmt.Errorf(\"WaitStep expect step '%s' (%s) to be created but found none\", step.Name, step.UUID)\n\t\treturn &backend_types.State{Error: err}, err\n\t}\n\tif stepState != stepStateStarted {\n\t\terr := fmt.Errorf(\"WaitStep expect step '%s' (%s) to be '%s' but it is: %s\", step.Name, step.UUID, stepStateStarted, stepState)\n\t\treturn &backend_types.State{Error: err}, err\n\t}\n\n\tif sleep, sleepExist := step.Environment[EnvKeyStepSleep]; sleepExist {\n\t\ttoSleep, err := time.ParseDuration(sleep)\n\t\tif err != nil {\n\t\t\terr = fmt.Errorf(\"WaitStep fail to parse sleep duration: %w\", err)\n\t\t\treturn &backend_types.State{Error: err}, err\n\t\t}\n\t\tif sleepWithContext(ctx, wc, toSleep) {\n\t\t\te.kv.Store(key, stepStateDone)\n\t\t\treturn canceledState(), nil\n\t\t}\n\t} else if step.Type == backend_types.StepTypeService {\n\t\tif sleepWithContext(ctx, wc, testServiceTimeout) {\n\t\t\t// context for service closed — we can move forward\n\t\t} else {\n\t\t\terr := fmt.Errorf(\"WaitStep fail due to timeout of service after 1 second\")\n\t\t\treturn &backend_types.State{Error: err}, err\n\t\t}\n\t} else {\n\t\ttime.Sleep(time.Nanosecond)\n\t}\n\n\te.kv.Store(key, stepStateDone)\n\n\toomKilled, _ := strconv.ParseBool(step.Environment[EnvKeyStepOOMKilled])\n\texitCode := 0\n\n\tif code, exist := step.Environment[EnvKeyStepExitCode]; exist {\n\t\texitCode, _ = strconv.Atoi(strings.TrimSpace(code))\n\t}\n\n\treturn &backend_types.State{\n\t\tExitCode:  exitCode,\n\t\tExited:    true,\n\t\tOOMKilled: oomKilled,\n\t}, nil\n}\n\nfunc (e *dummy) TailStep(_ context.Context, step *backend_types.Step, taskUUID string) (io.ReadCloser, error) {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"tail logs of step %s\", step.Name)\n\n\t_, exist := e.kv.Load(workflowKey(taskUUID))\n\tif !exist {\n\t\treturn nil, fmt.Errorf(\"expect env of workflow %s to exist but found none to destroy\", taskUUID)\n\t}\n\n\tkey := stepKey(taskUUID, step.UUID)\n\n\t// check state\n\tstepState, stepExist := e.kv.Load(key)\n\tif !stepExist {\n\t\treturn nil, fmt.Errorf(\"TailStep expect step '%s' (%s) to be created but found none\", step.Name, step.UUID)\n\t}\n\tif stepState != stepStateStarted {\n\t\treturn nil, fmt.Errorf(\"TailStep expect step '%s' (%s) to be '%s' but it is: %s\", step.Name, step.UUID, stepStateStarted, stepState)\n\t}\n\n\tif tailShouldFail, _ := strconv.ParseBool(step.Environment[EnvKeyStepTailFail]); tailShouldFail {\n\t\treturn nil, fmt.Errorf(\"expected fail to read stdout of step\")\n\t}\n\n\treturn io.NopCloser(strings.NewReader(dummyExecStepOutput(step))), nil\n}\n\nfunc (e *dummy) DestroyStep(_ context.Context, step *backend_types.Step, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"stop step %s\", step.Name)\n\n\t_, exist := e.kv.Load(workflowKey(taskUUID))\n\tif !exist {\n\t\treturn nil\n\t}\n\n\tkey := stepKey(taskUUID, step.UUID)\n\n\t// check state\n\tstepState, stepExist := e.kv.Load(key)\n\tif !stepExist {\n\t\treturn fmt.Errorf(\"DestroyStep expect step '%s' (%s) to be created but found none\", step.Name, step.UUID)\n\t}\n\t// Allow destroying a step in 'started' state: this happens when the\n\t// workflow context is canceled before WaitStep completes.\n\tif stepState != stepStateDone && stepState != stepStateStarted {\n\t\treturn fmt.Errorf(\"DestroyStep expect step '%s' (%s) to be '%s' or '%s' but it is: %s\", step.Name, step.UUID, stepStateDone, stepStateStarted, stepState)\n\t}\n\n\te.kv.Delete(key)\n\treturn nil\n}\n\nfunc (e *dummy) DestroyWorkflow(_ context.Context, _ *backend_types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"delete workflow environment\")\n\n\trawWC, exist := e.kv.Load(workflowKey(taskUUID))\n\tif !exist {\n\t\treturn fmt.Errorf(\"expect env of workflow %s to exist but found none to destroy\", taskUUID)\n\t}\n\twc, ok := rawWC.(chan struct{})\n\tif !ok {\n\t\treturn fmt.Errorf(\"workflow stop chan not found\")\n\t}\n\tclose(wc)\n\n\te.kv.Delete(workflowKey(taskUUID))\n\treturn nil\n}\n\nfunc dummyExecStepOutput(step *backend_types.Step) string {\n\treturn fmt.Sprintf(`StepName: %s\nStepType: %s\nStepUUID: %s\nStepCommands:\n------------------\n%s\n------------------\n`, step.Name, step.Type, step.UUID, strings.Join(step.Commands, \"\\n\"))\n}\n"
  },
  {
    "path": "pipeline/backend/dummy/dummy_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage dummy_test\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestSmalPipelineDummyRun(t *testing.T) {\n\tdummyEngine := dummy.New()\n\tctx := t.Context()\n\n\tassert.True(t, dummyEngine.IsAvailable(ctx))\n\tassert.EqualValues(t, \"dummy\", dummyEngine.Name())\n\t_, err := dummyEngine.Load(ctx)\n\trequire.NoError(t, err)\n\n\tassert.Error(t, dummyEngine.SetupWorkflow(ctx, nil, dummy.WorkflowSetupFailUUID))\n\n\tt.Run(\"expect fail of step func with non setup workflow\", func(t *testing.T) {\n\t\tstep := &types.Step{Name: \"step1\", UUID: \"SID_1\"}\n\t\tnonExistWorkflowID := \"WID_NONE\"\n\n\t\terr := dummyEngine.StartStep(ctx, step, nonExistWorkflowID)\n\t\tassert.Error(t, err)\n\n\t\t_, err = dummyEngine.TailStep(ctx, step, nonExistWorkflowID)\n\t\tassert.Error(t, err)\n\n\t\t_, err = dummyEngine.WaitStep(ctx, step, nonExistWorkflowID)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"step exec successfully\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tName:        \"step1\",\n\t\t\tUUID:        \"SID_1\",\n\t\t\tType:        types.StepTypeCommands,\n\t\t\tEnvironment: map[string]string{},\n\t\t\tCommands:    []string{\"echo ja\", \"echo nein\"},\n\t\t}\n\t\tworkflowUUID := \"WID_1\"\n\n\t\tassert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID))\n\n\t\treader, err := dummyEngine.TailStep(ctx, step, workflowUUID)\n\t\tassert.NoError(t, err)\n\t\tlog, err := io.ReadAll(reader)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, `StepName: step1\nStepType: commands\nStepUUID: SID_1\nStepCommands:\n------------------\necho ja\necho nein\n------------------\n`, string(log))\n\n\t\tstate, err := dummyEngine.WaitStep(ctx, step, workflowUUID)\n\t\tassert.NoError(t, err)\n\t\tassert.NoError(t, state.Error)\n\t\tassert.EqualValues(t, 0, state.ExitCode)\n\n\t\tassert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID))\n\t})\n\n\tt.Run(\"step exec error\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tName:        \"dummy\",\n\t\t\tUUID:        \"SID_2\",\n\t\t\tType:        types.StepTypePlugin,\n\t\t\tEnvironment: map[string]string{dummy.EnvKeyStepType: \"plugin\", dummy.EnvKeyStepExitCode: \"1\"},\n\t\t}\n\t\tworkflowUUID := \"WID_1\"\n\n\t\tassert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID))\n\n\t\t_, err := dummyEngine.TailStep(ctx, step, workflowUUID)\n\t\tassert.NoError(t, err)\n\n\t\tstate, err := dummyEngine.WaitStep(ctx, step, workflowUUID)\n\t\tassert.NoError(t, err)\n\t\tassert.NoError(t, state.Error)\n\t\tassert.EqualValues(t, 1, state.ExitCode)\n\n\t\tassert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID))\n\t})\n\n\tt.Run(\"step tail error\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tName:        \"dummy\",\n\t\t\tUUID:        \"SID_2\",\n\t\t\tEnvironment: map[string]string{dummy.EnvKeyStepTailFail: \"true\"},\n\t\t}\n\t\tworkflowUUID := \"WID_1\"\n\n\t\tassert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID))\n\n\t\t_, err := dummyEngine.TailStep(ctx, step, workflowUUID)\n\t\tassert.Error(t, err)\n\n\t\t_, err = dummyEngine.WaitStep(ctx, step, workflowUUID)\n\t\tassert.NoError(t, err)\n\n\t\tassert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID))\n\t})\n\n\tt.Run(\"step start fail\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tName:        \"dummy\",\n\t\t\tUUID:        \"SID_2\",\n\t\t\tType:        types.StepTypeService,\n\t\t\tEnvironment: map[string]string{dummy.EnvKeyStepType: \"service\", dummy.EnvKeyStepStartFail: \"true\"},\n\t\t}\n\t\tworkflowUUID := \"WID_1\"\n\n\t\tassert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID))\n\n\t\tassert.Error(t, dummyEngine.StartStep(ctx, step, workflowUUID))\n\n\t\t_, err := dummyEngine.TailStep(ctx, step, workflowUUID)\n\t\tassert.Error(t, err)\n\n\t\tstate, err := dummyEngine.WaitStep(ctx, step, workflowUUID)\n\t\tassert.Error(t, err)\n\t\tassert.Error(t, state.Error)\n\t\tassert.EqualValues(t, 0, state.ExitCode)\n\n\t\tassert.Error(t, dummyEngine.DestroyStep(ctx, step, workflowUUID))\n\n\t\tassert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID))\n\t})\n}\n\nfunc TestWaitStepCanceledBySleep(t *testing.T) {\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tdummyEngine := dummy.New()\n\t_, err := dummyEngine.Load(ctx)\n\trequire.NoError(t, err)\n\n\tconst taskUUID = \"cancel-task\"\n\tassert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, taskUUID))\n\n\tstep := &types.Step{\n\t\tName: \"slow-step\",\n\t\tUUID: \"slow-uuid\",\n\t\tType: types.StepTypeCommands,\n\t\tEnvironment: map[string]string{\n\t\t\tdummy.EnvKeyStepSleep: \"30s\",\n\t\t},\n\t}\n\tassert.NoError(t, dummyEngine.StartStep(ctx, step, taskUUID))\n\n\t// Cancel before WaitStep — the pre-select ctx.Err() check handles this deterministically.\n\tcancel(nil)\n\n\tstate, err := dummyEngine.WaitStep(ctx, step, taskUUID)\n\tassert.NoError(t, err, \"WaitStep should not return an error on cancellation\")\n\tassert.True(t, state.Exited, \"step should be marked as exited\")\n\tassert.Equal(t, dummy.ExitCodeCanceled, state.ExitCode,\n\t\t\"canceled step must exit with code %d\", dummy.ExitCodeCanceled)\n\n\t// DestroyStep must succeed even though the step was canceled mid-sleep.\n\tassert.NoError(t, dummyEngine.DestroyStep(ctx, step, taskUUID))\n\tassert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, taskUUID))\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/backend_options.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"github.com/go-viper/mapstructure/v2\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// BackendOptions defines all the advanced options for the kubernetes backend.\ntype BackendOptions struct {\n\tResources          Resources              `mapstructure:\"resources\"`\n\tRuntimeClassName   *string                `mapstructure:\"runtimeClassName\"`\n\tServiceAccountName string                 `mapstructure:\"serviceAccountName\"`\n\tLabels             map[string]string      `mapstructure:\"labels\"`\n\tAnnotations        map[string]string      `mapstructure:\"annotations\"`\n\tNodeSelector       map[string]string      `mapstructure:\"nodeSelector\"`\n\tTolerations        []Toleration           `mapstructure:\"tolerations\"`\n\tAffinity           *kube_core_v1.Affinity `mapstructure:\"affinity\"`\n\tSecurityContext    *SecurityContext       `mapstructure:\"securityContext\"`\n\tSecrets            []SecretRef            `mapstructure:\"secrets\"`\n}\n\n// Resources defines two maps for kubernetes resource definitions.\ntype Resources struct {\n\tRequests map[string]string `mapstructure:\"requests\"`\n\tLimits   map[string]string `mapstructure:\"limits\"`\n}\n\n// Toleration defines Kubernetes toleration.\ntype Toleration struct {\n\tKey               string             `mapstructure:\"key\"`\n\tOperator          TolerationOperator `mapstructure:\"operator\"`\n\tValue             string             `mapstructure:\"value\"`\n\tEffect            TaintEffect        `mapstructure:\"effect\"`\n\tTolerationSeconds *int64             `mapstructure:\"tolerationSeconds\"`\n}\n\ntype TaintEffect string\n\nconst (\n\tTaintEffectNoSchedule       TaintEffect = \"NoSchedule\"\n\tTaintEffectPreferNoSchedule TaintEffect = \"PreferNoSchedule\"\n\tTaintEffectNoExecute        TaintEffect = \"NoExecute\"\n)\n\ntype TolerationOperator string\n\nconst (\n\tTolerationOpExists TolerationOperator = \"Exists\"\n\tTolerationOpEqual  TolerationOperator = \"Equal\"\n)\n\ntype SecurityContext struct {\n\tPrivileged               *bool                                `mapstructure:\"privileged\"`\n\tRunAsNonRoot             *bool                                `mapstructure:\"runAsNonRoot\"`\n\tRunAsUser                *int64                               `mapstructure:\"runAsUser\"`\n\tRunAsGroup               *int64                               `mapstructure:\"runAsGroup\"`\n\tFSGroup                  *int64                               `mapstructure:\"fsGroup\"`\n\tFsGroupChangePolicy      *kube_core_v1.PodFSGroupChangePolicy `mapstructure:\"fsGroupChangePolicy\"`\n\tSeccompProfile           *SecProfile                          `mapstructure:\"seccompProfile\"`\n\tApparmorProfile          *SecProfile                          `mapstructure:\"apparmorProfile\"`\n\tAllowPrivilegeEscalation *bool                                `mapstructure:\"allowPrivilegeEscalation\"`\n\tCapabilities             *Capabilities                        `mapstructure:\"capabilities\"`\n}\n\ntype SecProfile struct {\n\tType             SecProfileType `mapstructure:\"type\"`\n\tLocalhostProfile string         `mapstructure:\"localhostProfile\"`\n}\n\ntype SecProfileType string\n\ntype Capabilities struct {\n\tDrop []string `mapstructure:\"drop\"`\n}\n\n// SecretRef defines Kubernetes secret reference.\ntype SecretRef struct {\n\tName   string       `mapstructure:\"name\"`\n\tKey    string       `mapstructure:\"key\"`\n\tTarget SecretTarget `mapstructure:\"target\"`\n}\n\n// SecretTarget defines secret mount target.\ntype SecretTarget struct {\n\tEnv  string `mapstructure:\"env\"`\n\tFile string `mapstructure:\"file\"`\n}\n\nconst (\n\tSecProfileTypeRuntimeDefault SecProfileType = \"RuntimeDefault\"\n\tSecProfileTypeLocalhost      SecProfileType = \"Localhost\"\n)\n\nfunc parseBackendOptions(step *backend_types.Step) (BackendOptions, error) {\n\tvar result BackendOptions\n\tif step == nil || step.BackendOptions == nil {\n\t\treturn result, nil\n\t}\n\terr := mapstructure.WeakDecode(step.BackendOptions[EngineName], &result)\n\treturn result, err\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/backend_options_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc Test_parseBackendOptions(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tstep    *backend_types.Step\n\t\twant    BackendOptions\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"nil options\",\n\t\t\tstep: &backend_types.Step{BackendOptions: nil},\n\t\t\twant: BackendOptions{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty options\",\n\t\t\tstep: &backend_types.Step{BackendOptions: map[string]any{}},\n\t\t\twant: BackendOptions{},\n\t\t},\n\t\t{\n\t\t\tname: \"full k8s options\",\n\t\t\tstep: &backend_types.Step{\n\t\t\t\tBackendOptions: map[string]any{\n\t\t\t\t\t\"kubernetes\": map[string]any{\n\t\t\t\t\t\t\"nodeSelector\":       map[string]string{\"storage\": \"ssd\"},\n\t\t\t\t\t\t\"serviceAccountName\": \"wp-svc-acc\",\n\t\t\t\t\t\t\"labels\":             map[string]string{\"app\": \"test\"},\n\t\t\t\t\t\t\"annotations\":        map[string]string{\"apps.kubernetes.io/pod-index\": \"0\"},\n\t\t\t\t\t\t\"tolerations\": []map[string]any{\n\t\t\t\t\t\t\t{\"key\": \"net-port\", \"value\": \"100Mbit\", \"effect\": TaintEffectNoSchedule},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"affinity\": map[string]any{\n\t\t\t\t\t\t\t\"podAffinity\": map[string]any{\n\t\t\t\t\t\t\t\t\"requiredDuringSchedulingIgnoredDuringExecution\": []map[string]any{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"labelSelector\": map[string]any{},\n\t\t\t\t\t\t\t\t\t\t\"matchLabelKeys\": []string{\n\t\t\t\t\t\t\t\t\t\t\t\"woodpecker-ci.org/task-uuid\",\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\"topologyKey\": \"kubernetes.io/hostname\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"resources\": map[string]any{\n\t\t\t\t\t\t\t\"requests\": map[string]string{\"memory\": \"128Mi\", \"cpu\": \"1000m\"},\n\t\t\t\t\t\t\t\"limits\":   map[string]string{\"memory\": \"256Mi\", \"cpu\": \"2\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"securityContext\": map[string]any{\n\t\t\t\t\t\t\t\"privileged\":   newBool(true),\n\t\t\t\t\t\t\t\"runAsNonRoot\": newBool(true),\n\t\t\t\t\t\t\t\"runAsUser\":    newInt64(101),\n\t\t\t\t\t\t\t\"runAsGroup\":   newInt64(101),\n\t\t\t\t\t\t\t\"fsGroup\":      newInt64(101),\n\t\t\t\t\t\t\t\"seccompProfile\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":             \"Localhost\",\n\t\t\t\t\t\t\t\t\"localhostProfile\": \"profiles/audit.json\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"apparmorProfile\": map[string]any{\n\t\t\t\t\t\t\t\t\"type\":             \"Localhost\",\n\t\t\t\t\t\t\t\t\"localhostProfile\": \"k8s-apparmor-example-deny-write\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"secrets\": []map[string]any{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"name\": \"aws\",\n\t\t\t\t\t\t\t\t\"key\":  \"access-key\",\n\t\t\t\t\t\t\t\t\"target\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"env\": \"AWS_SECRET_ACCESS_KEY\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"name\": \"reg-cred\",\n\t\t\t\t\t\t\t\t\"key\":  \".dockerconfigjson\",\n\t\t\t\t\t\t\t\t\"target\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"file\": \"~/.docker/config.json\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: BackendOptions{\n\t\t\t\tNodeSelector:       map[string]string{\"storage\": \"ssd\"},\n\t\t\t\tServiceAccountName: \"wp-svc-acc\",\n\t\t\t\tLabels:             map[string]string{\"app\": \"test\"},\n\t\t\t\tAnnotations:        map[string]string{\"apps.kubernetes.io/pod-index\": \"0\"},\n\t\t\t\tTolerations:        []Toleration{{Key: \"net-port\", Value: \"100Mbit\", Effect: TaintEffectNoSchedule}},\n\t\t\t\tAffinity: &kube_core_v1.Affinity{\n\t\t\t\t\tPodAffinity: &kube_core_v1.PodAffinity{\n\t\t\t\t\t\tRequiredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.PodAffinityTerm{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tLabelSelector: &kube_meta_v1.LabelSelector{},\n\t\t\t\t\t\t\t\tMatchLabelKeys: []string{\n\t\t\t\t\t\t\t\t\t\"woodpecker-ci.org/task-uuid\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tTopologyKey: \"kubernetes.io/hostname\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tResources: Resources{\n\t\t\t\t\tRequests: map[string]string{\"memory\": \"128Mi\", \"cpu\": \"1000m\"},\n\t\t\t\t\tLimits:   map[string]string{\"memory\": \"256Mi\", \"cpu\": \"2\"},\n\t\t\t\t},\n\t\t\t\tSecurityContext: &SecurityContext{\n\t\t\t\t\tPrivileged:   newBool(true),\n\t\t\t\t\tRunAsNonRoot: newBool(true),\n\t\t\t\t\tRunAsUser:    newInt64(101),\n\t\t\t\t\tRunAsGroup:   newInt64(101),\n\t\t\t\t\tFSGroup:      newInt64(101),\n\t\t\t\t\tSeccompProfile: &SecProfile{\n\t\t\t\t\t\tType:             \"Localhost\",\n\t\t\t\t\t\tLocalhostProfile: \"profiles/audit.json\",\n\t\t\t\t\t},\n\t\t\t\t\tApparmorProfile: &SecProfile{\n\t\t\t\t\t\tType:             \"Localhost\",\n\t\t\t\t\t\tLocalhostProfile: \"k8s-apparmor-example-deny-write\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSecrets: []SecretRef{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:   \"aws\",\n\t\t\t\t\t\tKey:    \"access-key\",\n\t\t\t\t\t\tTarget: SecretTarget{Env: \"AWS_SECRET_ACCESS_KEY\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:   \"reg-cred\",\n\t\t\t\t\t\tKey:    \".dockerconfigjson\",\n\t\t\t\t\t\tTarget: SecretTarget{File: \"~/.docker/config.json\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"number options\",\n\t\t\tstep: &backend_types.Step{BackendOptions: map[string]any{\n\t\t\t\t\"kubernetes\": map[string]any{\n\t\t\t\t\t\"resources\": map[string]any{\n\t\t\t\t\t\t\"requests\": map[string]int{\"memory\": 128, \"cpu\": 1000},\n\t\t\t\t\t\t\"limits\":   map[string]int{\"memory\": 256, \"cpu\": 2},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\twant: BackendOptions{\n\t\t\t\tResources: Resources{\n\t\t\t\t\tRequests: map[string]string{\"memory\": \"128\", \"cpu\": \"1000\"},\n\t\t\t\t\tLimits:   map[string]string{\"memory\": \"256\", \"cpu\": \"2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseBackendOptions(tt.step)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/flags.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n)\n\nvar Flags = []cli.Flag{\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_NAMESPACE\"),\n\t\tName:    \"backend-k8s-namespace\",\n\t\tUsage:   \"backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name.\",\n\t\tValue:   \"woodpecker\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION\"),\n\t\tName:    \"backend-k8s-namespace-per-org\",\n\t\tUsage:   \"Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization.\",\n\t\tValue:   false,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_VOLUME_SIZE\"),\n\t\tName:    \"backend-k8s-volume-size\",\n\t\tUsage:   \"backend k8s volume size (default 10G)\",\n\t\tValue:   \"10G\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_STORAGE_CLASS\"),\n\t\tName:    \"backend-k8s-storage-class\",\n\t\tUsage:   \"backend k8s storage class\",\n\t\tValue:   \"\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_STORAGE_RWX\"),\n\t\tName:    \"backend-k8s-storage-rwx\",\n\t\tUsage:   \"backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true)\",\n\t\tValue:   true,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_LABELS\"),\n\t\tName:    \"backend-k8s-pod-labels\",\n\t\tUsage:   \"backend k8s additional Agent-wide worker pod labels\",\n\t\tValue:   \"\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP\"),\n\t\tName:    \"backend-k8s-pod-labels-allow-from-step\",\n\t\tUsage:   \"whether to allow using labels from step's backend options\",\n\t\tValue:   false,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS\"),\n\t\tName:    \"backend-k8s-pod-annotations\",\n\t\tUsage:   \"backend k8s additional Agent-wide worker pod annotations\",\n\t\tValue:   \"\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR\"),\n\t\tName:    \"backend-k8s-pod-node-selector\",\n\t\tUsage:   \"backend k8s Agent-wide worker pod node selector\",\n\t\tValue:   \"\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_TOLERATIONS\"),\n\t\tName:    \"backend-k8s-pod-tolerations\",\n\t\tUsage:   \"backend k8s Agent-wide worker pod tolerations\",\n\t\tValue:   \"\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP\"),\n\t\tName:    \"backend-k8s-pod-annotations-allow-from-step\",\n\t\tUsage:   \"whether to allow using annotations from step's backend options\",\n\t\tValue:   false,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP\"),\n\t\tName:    \"backend-k8s-pod-tolerations-allow-from-step\",\n\t\tUsage:   \"whether to allow using tolerations from step's backend options\",\n\t\tValue:   true,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_AFFINITY\"),\n\t\tName:    \"backend-k8s-pod-affinity\",\n\t\tUsage:   \"backend k8s Agent-wide worker pod affinity, in YAML format\",\n\t\tValue:   \"\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP\"),\n\t\tName:    \"backend-k8s-pod-affinity-allow-from-step\",\n\t\tUsage:   \"whether to allow using affinity from step's backend options\",\n\t\tValue:   false,\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_SECCTX_NONROOT\"), // cspell:words secctx nonroot\n\t\tName:    \"backend-k8s-secctx-nonroot\",\n\t\tUsage:   \"`run as non root` Kubernetes security context option\",\n\t},\n\t&cli.StringSliceFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES\"),\n\t\tName:    \"backend-k8s-pod-image-pull-secret-names\",\n\t\tUsage:   \"backend k8s pull secret names for private registries\",\n\t\tConfig: cli.StringConfig{\n\t\t\tTrimSpace: true,\n\t\t},\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_ALLOW_NATIVE_SECRETS\"),\n\t\tName:    \"backend-k8s-allow-native-secrets\",\n\t\tUsage:   \"whether to allow existing Kubernetes secrets to be referenced from steps\",\n\t\tValue:   false,\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_PRIORITY_CLASS\"),\n\t\tName:    \"backend-k8s-priority-class\",\n\t\tUsage:   \"which kubernetes priority class to assign to created job pods\",\n\t\tValue:   \"\",\n\t},\n\t&cli.Int64Flag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_K8S_STOP_TIMEOUT\"),\n\t\tName:    \"backend-k8s-stop-timeout\",\n\t\tUsage:   \"seconds Woodpecker waits for pods to stop gracefully before forcefully killing them\",\n\t\tValue:   20,\n\t},\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/kubernetes.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"maps\"\n\t\"os\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\t_ \"k8s.io/client-go/plugin/pkg/client/auth/gcp\" // To authenticate to GCP K8s clusters\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/cache\"\n\t\"sigs.k8s.io/yaml\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nconst (\n\tEngineName = \"kubernetes\"\n\t// TODO: 5 seconds is against best practice, k3s didn't work otherwise\n\tdefaultResyncDuration = 5 * time.Second\n\tmaxRetryDuration      = 1 * time.Minute\n)\n\ntype kube struct {\n\tclient kubernetes.Interface\n\tconfig *config\n\tgoos   string\n}\n\ntype config struct {\n\tNamespace                   string\n\tEnableNamespacePerOrg       bool\n\tStorageClass                string\n\tVolumeSize                  string\n\tStorageRwx                  bool\n\tPodLabels                   map[string]string\n\tPodLabelsAllowFromStep      bool\n\tPodAnnotations              map[string]string\n\tPodAnnotationsAllowFromStep bool\n\tPodNodeSelector             map[string]string\n\tPodTolerationsAllowFromStep bool\n\tPodTolerations              []Toleration\n\tPodAffinity                 *kube_core_v1.Affinity\n\tPodAffinityAllowFromStep    bool\n\tImagePullSecretNames        []string\n\tSecurityContext             SecurityContextConfig\n\tNativeSecretsAllowFromStep  bool\n\tPriorityClassName           string\n\tStopTimeout                 int64\n}\n\nfunc (c *config) GetNamespace(orgID int64) string {\n\tif c.EnableNamespacePerOrg {\n\t\treturn strings.ToLower(fmt.Sprintf(\"%s-%s\", c.Namespace, strconv.FormatInt(orgID, 10)))\n\t}\n\treturn c.Namespace\n}\n\ntype SecurityContextConfig struct {\n\tRunAsNonRoot bool\n\tFSGroup      *int64\n}\n\nfunc (c *config) newDefaultDeleteOptions() kube_meta_v1.DeleteOptions {\n\tpropagationPolicy := kube_meta_v1.DeletePropagationBackground\n\n\treturn kube_meta_v1.DeleteOptions{\n\t\tGracePeriodSeconds: &c.StopTimeout,\n\t\tPropagationPolicy:  &propagationPolicy,\n\t}\n}\n\nfunc configFromCliContext(ctx context.Context) (*config, error) {\n\tif ctx != nil {\n\t\tif c, ok := ctx.Value(types.CliCommand).(*cli.Command); ok {\n\t\t\tconfig := config{\n\t\t\t\tNamespace:                   c.String(\"backend-k8s-namespace\"),\n\t\t\t\tEnableNamespacePerOrg:       c.Bool(\"backend-k8s-namespace-per-org\"),\n\t\t\t\tStorageClass:                c.String(\"backend-k8s-storage-class\"),\n\t\t\t\tVolumeSize:                  c.String(\"backend-k8s-volume-size\"),\n\t\t\t\tStorageRwx:                  c.Bool(\"backend-k8s-storage-rwx\"),\n\t\t\t\tPriorityClassName:           c.String(\"backend-k8s-priority-class\"),\n\t\t\t\tPodLabels:                   make(map[string]string), // just init empty map to prevent nil panic\n\t\t\t\tPodLabelsAllowFromStep:      c.Bool(\"backend-k8s-pod-labels-allow-from-step\"),\n\t\t\t\tPodAnnotations:              make(map[string]string), // just init empty map to prevent nil panic\n\t\t\t\tPodAnnotationsAllowFromStep: c.Bool(\"backend-k8s-pod-annotations-allow-from-step\"),\n\t\t\t\tPodTolerationsAllowFromStep: c.Bool(\"backend-k8s-pod-tolerations-allow-from-step\"),\n\t\t\t\tPodNodeSelector:             make(map[string]string), // just init empty map to prevent nil panic\n\t\t\t\tPodAffinityAllowFromStep:    c.Bool(\"backend-k8s-pod-affinity-allow-from-step\"),\n\t\t\t\tImagePullSecretNames:        c.StringSlice(\"backend-k8s-pod-image-pull-secret-names\"),\n\t\t\t\tSecurityContext: SecurityContextConfig{\n\t\t\t\t\tRunAsNonRoot: c.Bool(\"backend-k8s-secctx-nonroot\"), // cspell:words secctx nonroot\n\t\t\t\t\tFSGroup:      newInt64(defaultFSGroup),\n\t\t\t\t},\n\t\t\t\tNativeSecretsAllowFromStep: c.Bool(\"backend-k8s-allow-native-secrets\"),\n\t\t\t\tStopTimeout:                c.Int64(\"backend-k8s-stop-timeout\"),\n\t\t\t}\n\t\t\t// Unmarshal label and annotation settings here to ensure they're valid on startup\n\t\t\tif labels := c.String(\"backend-k8s-pod-labels\"); labels != \"\" {\n\t\t\t\tif err := yaml.Unmarshal([]byte(labels), &config.PodLabels); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"could not unmarshal pod labels '%s'\", c.String(\"backend-k8s-pod-labels\"))\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif annotations := c.String(\"backend-k8s-pod-annotations\"); annotations != \"\" {\n\t\t\t\tif err := yaml.Unmarshal([]byte(c.String(\"backend-k8s-pod-annotations\")), &config.PodAnnotations); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"could not unmarshal pod annotations '%s'\", c.String(\"backend-k8s-pod-annotations\"))\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif nodeSelector := c.String(\"backend-k8s-pod-node-selector\"); nodeSelector != \"\" {\n\t\t\t\tif err := yaml.Unmarshal([]byte(nodeSelector), &config.PodNodeSelector); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"could not unmarshal pod node selector '%s'\", nodeSelector)\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif podTolerations := c.String(\"backend-k8s-pod-tolerations\"); podTolerations != \"\" {\n\t\t\t\tif err := yaml.Unmarshal([]byte(podTolerations), &config.PodTolerations); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"could not unmarshal pod tolerations '%s'\", podTolerations)\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif podAffinity := c.String(\"backend-k8s-pod-affinity\"); podAffinity != \"\" {\n\t\t\t\tif err := yaml.Unmarshal([]byte(podAffinity), &config.PodAffinity); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"could not unmarshal pod affinity '%s'\", podAffinity)\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn &config, nil\n\t\t}\n\t}\n\n\treturn nil, types.ErrNoCliContextFound\n}\n\n// New returns a new Kubernetes Backend.\nfunc New() types.Backend {\n\treturn &kube{}\n}\n\nfunc (e *kube) Name() string {\n\treturn EngineName\n}\n\nfunc (e *kube) IsAvailable(context.Context) bool {\n\thost := os.Getenv(\"KUBERNETES_SERVICE_HOST\")\n\treturn len(host) > 0\n}\n\nfunc (e *kube) Flags() []cli.Flag {\n\treturn Flags\n}\n\nfunc (e *kube) Load(ctx context.Context) (*types.BackendInfo, error) {\n\tconfig, err := configFromCliContext(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\te.config = config\n\n\tvar kubeClient kubernetes.Interface\n\t_, err = rest.InClusterConfig()\n\tif err != nil {\n\t\tkubeClient, err = getClientOutOfCluster()\n\t} else {\n\t\tkubeClient, err = getClientInsideOfCluster()\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\te.client = kubeClient\n\n\t// TODO(2693): use info resp of kubeClient to define platform var\n\te.goos = runtime.GOOS\n\treturn &types.BackendInfo{\n\t\tPlatform: runtime.GOOS + \"/\" + runtime.GOARCH,\n\t}, nil\n}\n\nfunc (e *kube) getConfig() *config {\n\tif e.config == nil {\n\t\treturn nil\n\t}\n\tc := *e.config\n\tc.PodLabels = maps.Clone(e.config.PodLabels)\n\tc.PodAnnotations = maps.Clone(e.config.PodAnnotations)\n\tc.PodNodeSelector = maps.Clone(e.config.PodNodeSelector)\n\tc.ImagePullSecretNames = slices.Clone(e.config.ImagePullSecretNames)\n\treturn &c\n}\n\n// SetupWorkflow sets up the pipeline environment.\nfunc (e *kube) SetupWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"Setting up Kubernetes primitives\")\n\n\tnamespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID)\n\n\tif e.config.EnableNamespacePerOrg {\n\t\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"Ensure organization namespace: %s\", namespace)\n\t\terr := mkNamespace(ctx, e.client.CoreV1().Namespaces(), namespace)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"Creating workflow volume\")\n\t_, err := startVolume(ctx, e, conf.Volume, namespace)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"Creating workflow headless service\")\n\t_, err = startHeadlessService(ctx, e, namespace, taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// StartStep starts the pipeline step.\nfunc (e *kube) StartStep(ctx context.Context, step *types.Step, taskUUID string) error {\n\toptions, err := parseBackendOptions(step)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not parse backend options\")\n\t}\n\n\tif needsRegistrySecret(step) {\n\t\terr = startRegistrySecret(ctx, e, step)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif needsStepSecret(step) {\n\t\terr = startStepSecret(ctx, e, step)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"starting step: %s\", step.Name)\n\t_, err = startPod(ctx, e, step, options, taskUUID)\n\treturn err\n}\n\n// WaitStep waits for the pipeline step to complete and returns\n// the completion results.\nfunc (e *kube) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) {\n\tpodName, err := stepToPodName(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"waiting for pod: %s\", podName)\n\n\tfinished := make(chan struct{})\n\tvar finishedOnce sync.Once\n\n\tpodUpdated := func(_, newPod any) {\n\t\tpod, ok := newPod.(*kube_core_v1.Pod)\n\t\tif !ok {\n\t\t\tlog.Error().Msgf(\"could not parse pod: %v\", newPod)\n\t\t\treturn\n\t\t}\n\n\t\tif pod.Name == podName {\n\t\t\tif isImagePullBackOffState(pod) || isInvalidImageName(pod) {\n\t\t\t\tfinishedOnce.Do(func() { close(finished) })\n\t\t\t}\n\n\t\t\tswitch pod.Status.Phase {\n\t\t\tcase kube_core_v1.PodSucceeded, kube_core_v1.PodFailed, kube_core_v1.PodUnknown:\n\t\t\t\tfinishedOnce.Do(func() { close(finished) })\n\t\t\t}\n\t\t}\n\t}\n\n\tsi := informers.NewSharedInformerFactoryWithOptions(e.client, defaultResyncDuration, informers.WithNamespace(e.config.GetNamespace(step.OrgID)))\n\tif _, err := si.Core().V1().Pods().Informer().AddEventHandler(\n\t\tcache.ResourceEventHandlerFuncs{\n\t\t\tUpdateFunc: podUpdated,\n\t\t},\n\t); err != nil {\n\t\treturn nil, err\n\t}\n\n\tstop := make(chan struct{})\n\tsi.Start(stop)\n\tdefer close(stop)\n\n\tselect {\n\tcase <-finished:\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\t}\n\n\tpod, err := e.client.CoreV1().Pods(e.config.GetNamespace(step.OrgID)).Get(ctx, podName, kube_meta_v1.GetOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif isImagePullBackOffState(pod) || isInvalidImageName(pod) {\n\t\treturn nil, fmt.Errorf(\"could not pull image for pod %s\", podName)\n\t}\n\n\tif len(pod.Status.ContainerStatuses) == 0 {\n\t\treturn nil, fmt.Errorf(\"no container statuses found for pod %s\", podName)\n\t}\n\n\tcs := pod.Status.ContainerStatuses[0]\n\n\tif cs.State.Terminated == nil {\n\t\terr := fmt.Errorf(\"no terminated state found for container %s/%s\", podName, cs.Name)\n\t\tlog.Error().Str(\"taskUUID\", taskUUID).Str(\"pod\", podName).Str(\"container\", cs.Name).Interface(\"state\", cs.State).Msg(err.Error())\n\t\treturn nil, err\n\t}\n\n\tbs := &types.State{\n\t\tExitCode:  int(cs.State.Terminated.ExitCode),\n\t\tExited:    true,\n\t\tOOMKilled: false,\n\t}\n\n\treturn bs, nil\n}\n\n// TailStep tails the pipeline step logs.\nfunc (e *kube) TailStep(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) {\n\tpodName, err := stepToPodName(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"tail logs of pod: %s\", podName)\n\n\tup := make(chan struct{})\n\tvar upOnce sync.Once\n\n\tpodUpdated := func(_, newPod any) {\n\t\tpod, ok := newPod.(*kube_core_v1.Pod)\n\t\tif !ok {\n\t\t\tlog.Error().Msgf(\"could not parse pod: %v\", newPod)\n\t\t\treturn\n\t\t}\n\n\t\tif pod.Name == podName {\n\t\t\tif isImagePullBackOffState(pod) || isInvalidImageName(pod) {\n\t\t\t\tupOnce.Do(func() { close(up) })\n\t\t\t}\n\t\t\tswitch pod.Status.Phase {\n\t\t\tcase kube_core_v1.PodRunning, kube_core_v1.PodSucceeded, kube_core_v1.PodFailed:\n\t\t\t\tupOnce.Do(func() { close(up) })\n\t\t\t}\n\t\t}\n\t}\n\n\tsi := informers.NewSharedInformerFactoryWithOptions(e.client, defaultResyncDuration, informers.WithNamespace(e.config.GetNamespace(step.OrgID)))\n\tif _, err := si.Core().V1().Pods().Informer().AddEventHandler(\n\t\tcache.ResourceEventHandlerFuncs{\n\t\t\tUpdateFunc: podUpdated,\n\t\t},\n\t); err != nil {\n\t\treturn nil, err\n\t}\n\n\tstop := make(chan struct{})\n\tsi.Start(stop)\n\tdefer close(stop)\n\n\tselect {\n\tcase <-up:\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\t}\n\n\topts := &kube_core_v1.PodLogOptions{\n\t\tFollow:    true,\n\t\tContainer: podName,\n\t}\n\n\tlogs, err := backoff.Retry(ctx,\n\t\tfunc() (io.ReadCloser, error) {\n\t\t\treturn e.client.CoreV1().RESTClient().Get().\n\t\t\t\tNamespace(e.config.GetNamespace(step.OrgID)).\n\t\t\t\tName(podName).\n\t\t\t\tResource(\"pods\").\n\t\t\t\tSubResource(\"log\").\n\t\t\t\tVersionedParams(opts, scheme.ParameterCodec).\n\t\t\t\tStream(ctx)\n\t\t},\n\t\tbackoff.WithBackOff(backoff.NewExponentialBackOff()),\n\t\tbackoff.WithMaxElapsedTime(maxRetryDuration),\n\t\tbackoff.WithNotify(func(err error, delay time.Duration) {\n\t\t\tlog.Warn().Err(err).Str(\"pod\", podName).Dur(\"backoff\", delay).Msg(\"failed to open pod log stream, retrying with backoff\")\n\t\t}),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trc, wc := io.Pipe()\n\n\tgo func() {\n\t\tdefer logs.Close()\n\t\tdefer wc.Close()\n\n\t\t_, err = io.Copy(wc, logs)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}()\n\treturn rc, nil\n}\n\nfunc (e *kube) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error {\n\tvar errs []error\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"Stopping step: %s\", step.Name)\n\tif needsRegistrySecret(step) {\n\t\terr := stopRegistrySecret(ctx, e, step, e.config.newDefaultDeleteOptions())\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\tif needsStepSecret(step) {\n\t\terr := stopStepSecret(ctx, e, step, e.config.newDefaultDeleteOptions())\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\n\terr := stopPod(ctx, e, step, e.config.newDefaultDeleteOptions())\n\tif err != nil {\n\t\terrs = append(errs, err)\n\t}\n\treturn errors.Join(errs...)\n}\n\n// DestroyWorkflow destroys the pipeline environment.\nfunc (e *kube) DestroyWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msg(\"deleting Kubernetes primitives\")\n\n\tfor _, stage := range conf.Stages {\n\t\tfor _, step := range stage.Steps {\n\t\t\terr := stopPod(ctx, e, step, e.config.newDefaultDeleteOptions())\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tnamespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID)\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"deleting workflow headless service\")\n\terr := e.stopHeadlessService(ctx, e, namespace, taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"deleting workflow volume\")\n\terr = stopVolume(ctx, e, conf.Volume, e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID), e.config.newDefaultDeleteOptions())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/kubernetes_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"runtime\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v3\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestGettingConfig(t *testing.T) {\n\tengine := kube{\n\t\tconfig: &config{\n\t\t\tNamespace:            \"default\",\n\t\t\tStorageClass:         \"hdd\",\n\t\t\tVolumeSize:           \"1G\",\n\t\t\tStorageRwx:           false,\n\t\t\tPodLabels:            map[string]string{\"l1\": \"v1\"},\n\t\t\tPodAnnotations:       map[string]string{\"a1\": \"v1\"},\n\t\t\tImagePullSecretNames: []string{\"regcred\"},\n\t\t\tSecurityContext:      SecurityContextConfig{RunAsNonRoot: false},\n\t\t},\n\t}\n\tconfig := engine.getConfig()\n\tconfig.Namespace = \"wp\"\n\tconfig.StorageClass = \"ssd\"\n\tconfig.StorageRwx = true\n\tconfig.PodLabels = nil\n\tconfig.PodAnnotations[\"a2\"] = \"v2\"\n\tconfig.ImagePullSecretNames = append(config.ImagePullSecretNames, \"docker.io\")\n\tconfig.SecurityContext.RunAsNonRoot = true\n\n\tassert.Equal(t, \"default\", engine.config.Namespace)\n\tassert.Equal(t, \"hdd\", engine.config.StorageClass)\n\tassert.Equal(t, \"1G\", engine.config.VolumeSize)\n\tassert.False(t, engine.config.StorageRwx)\n\tassert.Len(t, engine.config.PodLabels, 1)\n\tassert.Len(t, engine.config.PodAnnotations, 1)\n\tassert.Len(t, engine.config.ImagePullSecretNames, 1)\n\tassert.False(t, engine.config.SecurityContext.RunAsNonRoot)\n}\n\nfunc TestSetupWorkflow(t *testing.T) {\n\tnamespace := \"foo\"\n\tvolumeName := \"volume-name\"\n\tvolumePath := volumeName + \":/woodpecker\"\n\tnetworkName := \"test-network\"\n\ttaskUUID := \"11301\"\n\n\tengine := kube{\n\t\tconfig: &config{\n\t\t\tNamespace:            namespace,\n\t\t\tStorageClass:         \"hdd\",\n\t\t\tVolumeSize:           \"1G\",\n\t\t\tStorageRwx:           false,\n\t\t\tPodLabels:            map[string]string{\"l1\": \"v1\"},\n\t\t\tPodAnnotations:       map[string]string{\"a1\": \"v1\"},\n\t\t\tImagePullSecretNames: []string{\"regcred\"},\n\t\t\tSecurityContext:      SecurityContextConfig{RunAsNonRoot: false},\n\t\t},\n\t\tclient: fake.NewClientset(),\n\t}\n\n\tserviceWithPorts := types.Step{\n\t\tOrgID:    42,\n\t\tName:     \"service\",\n\t\tUUID:     \"123\",\n\t\tType:     types.StepTypeService,\n\t\tVolumes:  []string{volumePath},\n\t\tNetworks: []types.Conn{{Name: networkName, Aliases: []string{\"alias\"}}},\n\t\tPorts: []types.Port{\n\t\t\t{Number: 8080, Protocol: \"tcp\"},\n\t\t},\n\t}\n\n\tconf := &types.Config{\n\t\tVolume:  volumePath,\n\t\tNetwork: networkName,\n\t\tStages: []*types.Stage{\n\t\t\t{\n\t\t\t\tSteps: []*types.Step{\n\t\t\t\t\t&serviceWithPorts,\n\t\t\t\t\t{\n\t\t\t\t\t\tOrgID:    42,\n\t\t\t\t\t\tUUID:     \"234\",\n\t\t\t\t\t\tName:     \"service2\",\n\t\t\t\t\t\tType:     types.StepTypeService,\n\t\t\t\t\t\tVolumes:  []string{volumePath},\n\t\t\t\t\t\tNetworks: []types.Conn{{Name: networkName, Aliases: []string{\"alias\"}}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tSteps: []*types.Step{\n\t\t\t\t\t{\n\t\t\t\t\t\tOrgID:    42,\n\t\t\t\t\t\tUUID:     \"456\",\n\t\t\t\t\t\tName:     \"step-1\",\n\t\t\t\t\t\tVolumes:  []string{volumePath},\n\t\t\t\t\t\tNetworks: []types.Conn{{Name: networkName, Aliases: []string{\"alias\"}}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\terr := engine.SetupWorkflow(context.Background(), conf, taskUUID)\n\tassert.NoError(t, err, \"SetupWorkflow should not error with minimal config and fake client\")\n\n\t_, err = engine.client.CoreV1().PersistentVolumeClaims(namespace).Get(context.Background(), \"volume-name\", kube_meta_v1.GetOptions{})\n\tassert.NoError(t, err, \"persistent volume should be created during workflow setup\")\n\n\t_, err = engine.client.CoreV1().Services(namespace).Get(context.Background(), \"wp-hsvc-\"+taskUUID, kube_meta_v1.GetOptions{})\n\tassert.NoError(t, err, \"headless service should be created during workflow setup\")\n}\n\nfunc TestAffinityFromCliContext(t *testing.T) {\n\tt.Setenv(\"WOODPECKER_BACKEND_K8S_NAMESPACE\", \"\")\n\tt.Setenv(\"WOODPECKER_BACKEND_K8S_POD_AFFINITY\", `{\n\t\t\"podAffinity\": {\n\t\t\t\"requiredDuringSchedulingIgnoredDuringExecution\": [\n\t\t\t{\n\t\t\t\t\"labelSelector\": {},\n\t\t\t\t\"matchLabelKeys\": [\n\t\t\t\t\"woodpecker-ci.org/task-uuid\"\n\t\t\t\t],\n\t\t\t\t\"topologyKey\": \"kubernetes.io/hostname\"\n\t\t\t}\n\t\t\t]\n\t\t}\n\t\t}`)\n\tt.Setenv(\"WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP\", \"false\")\n\n\tcmd := &cli.Command{\n\t\tFlags: Flags,\n\t\tAction: func(ctx context.Context, c *cli.Command) error {\n\t\t\tctx = context.WithValue(ctx, types.CliCommand, c)\n\t\t\tconfig, err := configFromCliContext(ctx)\n\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NotNil(t, config)\n\t\t\tassert.False(t, config.PodAffinityAllowFromStep)\n\n\t\t\t// Verify affinity was parsed\n\t\t\trequire.NotNil(t, config.PodAffinity)\n\t\t\trequire.NotNil(t, config.PodAffinity.PodAffinity)\n\t\t\trequire.Len(t, config.PodAffinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution, 1)\n\n\t\t\tterm := config.PodAffinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution[0]\n\t\t\tassert.Equal(t, \"kubernetes.io/hostname\", term.TopologyKey)\n\t\t\tassert.Equal(t, []string{\"woodpecker-ci.org/task-uuid\"}, term.MatchLabelKeys)\n\n\t\t\treturn nil\n\t\t},\n\t}\n\terr := cmd.Run(context.Background(), []string{\"test\"})\n\trequire.NoError(t, err)\n}\n\nfunc makeStep(uuid string) *types.Step {\n\treturn &types.Step{\n\t\tUUID:  uuid,\n\t\tName:  \"step-\" + uuid,\n\t\tOrgID: 1,\n\t}\n}\n\nfunc makeEngine(client *fake.Clientset) *kube {\n\treturn &kube{\n\t\tclient: client,\n\t\tconfig: &config{\n\t\t\tNamespace: \"test-ns\",\n\t\t},\n\t}\n}\n\nfunc createPod(\n\tt *testing.T,\n\tclient *fake.Clientset,\n\tstep *types.Step,\n\tnamespace string,\n) string {\n\tt.Helper()\n\tpodName, err := stepToPodName(step)\n\trequire.NoError(t, err)\n\n\tpod := &kube_core_v1.Pod{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{\n\t\t\tName:      podName,\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tStatus: kube_core_v1.PodStatus{\n\t\t\tPhase: kube_core_v1.PodPending,\n\t\t},\n\t}\n\t_, err = client.CoreV1().Pods(namespace).Create(\n\t\tcontext.Background(), pod, kube_meta_v1.CreateOptions{},\n\t)\n\trequire.NoError(t, err)\n\treturn podName\n}\n\nfunc TestWaitStepReturnsOnContextCancel(t *testing.T) {\n\tclient := fake.NewClientset()\n\tengine := makeEngine(client)\n\tstep := makeStep(\"ctx-cancel-01\")\n\tnamespace := \"test-ns\"\n\n\tcreatePod(t, client, step, namespace)\n\n\tctx, cancel := context.WithCancelCause(context.Background())\n\n\ttype result struct {\n\t\tstate *types.State\n\t\terr   error\n\t}\n\tch := make(chan result, 1)\n\n\tgo func() {\n\t\ts, err := engine.WaitStep(ctx, step, \"task-1\")\n\t\tch <- result{s, err}\n\t}()\n\n\t// Give the informer time to start and begin watching.\n\ttime.Sleep(200 * time.Millisecond)\n\n\tcancel(nil)\n\n\tselect {\n\tcase r := <-ch:\n\t\tassert.Nil(t, r.state)\n\t\tassert.ErrorIs(t, r.err, context.Canceled)\n\tcase <-time.After(3 * time.Second):\n\t\tt.Fatal(\"WaitStep did not return after context cancellation\")\n\t}\n}\n\nfunc TestWaitStepNoGoroutineLeak(t *testing.T) {\n\tclient := fake.NewClientset()\n\tengine := makeEngine(client)\n\tnamespace := \"test-ns\"\n\tnumSteps := 10\n\n\tsteps := make([]*types.Step, numSteps)\n\tfor i := range numSteps {\n\t\tsteps[i] = makeStep(fmt.Sprintf(\"leak-%02d\", i))\n\t\tcreatePod(t, client, steps[i], namespace)\n\t}\n\n\truntime.GC()\n\ttime.Sleep(100 * time.Millisecond)\n\tbaselineGoroutines := runtime.NumGoroutine()\n\n\tvar wg sync.WaitGroup\n\tfor i := range numSteps {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\n\t\t\tctx, cancel := context.WithCancelCause(context.Background())\n\n\t\t\tgo func() {\n\t\t\t\t_, _ = engine.WaitStep(ctx, steps[i], fmt.Sprintf(\"task-%d\", i))\n\t\t\t}()\n\n\t\t\ttime.Sleep(200 * time.Millisecond)\n\t\t\tcancel(nil)\n\t\t}()\n\t}\n\twg.Wait()\n\n\ttime.Sleep(1 * time.Second)\n\n\tafterCancelGoroutines := runtime.NumGoroutine()\n\tleaked := afterCancelGoroutines - baselineGoroutines\n\n\tassert.Less(t, leaked, numSteps,\n\t\t\"goroutines leaked after canceling %d WaitStep calls: got %d leaked\",\n\t\tnumSteps, leaked)\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/namespace.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\ntype K8sNamespaceClient interface {\n\tGet(ctx context.Context, name string, opts kube_meta_v1.GetOptions) (*kube_core_v1.Namespace, error)\n\tCreate(ctx context.Context, namespace *kube_core_v1.Namespace, opts kube_meta_v1.CreateOptions) (*kube_core_v1.Namespace, error)\n}\n\nfunc mkNamespace(ctx context.Context, client K8sNamespaceClient, namespace string) error {\n\t_, err := client.Get(ctx, namespace, kube_meta_v1.GetOptions{})\n\tif err == nil {\n\t\tlog.Trace().Str(\"namespace\", namespace).Msg(\"Kubernetes namespace already exists\")\n\t\treturn nil\n\t}\n\n\tif !errors.IsNotFound(err) {\n\t\tlog.Trace().Err(err).Str(\"namespace\", namespace).Msg(\"failed to check Kubernetes namespace existence\")\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"namespace\", namespace).Msg(\"creating Kubernetes namespace\")\n\n\t_, err = client.Create(ctx, &kube_core_v1.Namespace{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{Name: namespace},\n\t}, kube_meta_v1.CreateOptions{})\n\tif err != nil {\n\t\tlog.Error().Err(err).Str(\"namespace\", namespace).Msg(\"failed to create Kubernetes namespace\")\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"namespace\", namespace).Msg(\"Kubernetes namespace created successfully\")\n\treturn nil\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/namespace_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_errors \"k8s.io/apimachinery/pkg/api/errors\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n)\n\ntype mockNamespaceClient struct {\n\tgetError     error\n\tcreateError  error\n\tgetCalled    bool\n\tcreateCalled bool\n\tcreatedNS    *kube_core_v1.Namespace\n}\n\nfunc (m *mockNamespaceClient) Get(_ context.Context, name string, _ kube_meta_v1.GetOptions) (*kube_core_v1.Namespace, error) {\n\tm.getCalled = true\n\tif m.getError != nil {\n\t\treturn nil, m.getError\n\t}\n\treturn &kube_core_v1.Namespace{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{Name: name},\n\t}, nil\n}\n\nfunc (m *mockNamespaceClient) Create(_ context.Context, ns *kube_core_v1.Namespace, _ kube_meta_v1.CreateOptions) (*kube_core_v1.Namespace, error) {\n\tm.createCalled = true\n\tm.createdNS = ns\n\treturn ns, m.createError\n}\n\nfunc TestMkNamespace(t *testing.T) {\n\ttests := []struct {\n\t\tname               string\n\t\tnamespace          string\n\t\tsetupMock          func(*mockNamespaceClient)\n\t\texpectError        bool\n\t\terrorContains      string\n\t\texpectGetCalled    bool\n\t\texpectCreateCalled bool\n\t}{\n\t\t{\n\t\t\tname:      \"should succeed when namespace already exists\",\n\t\t\tnamespace: \"existing-namespace\",\n\t\t\tsetupMock: func(m *mockNamespaceClient) {\n\t\t\t\tm.getError = nil // namespace exists\n\t\t\t},\n\t\t\texpectError:        false,\n\t\t\texpectGetCalled:    true,\n\t\t\texpectCreateCalled: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"should create namespace when it doesn't exist\",\n\t\t\tnamespace: \"new-namespace\",\n\t\t\tsetupMock: func(m *mockNamespaceClient) {\n\t\t\t\tm.getError = kube_errors.NewNotFound(schema.GroupResource{Resource: \"namespaces\"}, \"new-namespace\")\n\t\t\t\tm.createError = nil\n\t\t\t},\n\t\t\texpectError:        false,\n\t\t\texpectGetCalled:    true,\n\t\t\texpectCreateCalled: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"should fail when Get namespace returns generic error\",\n\t\t\tnamespace: \"error-namespace\",\n\t\t\tsetupMock: func(m *mockNamespaceClient) {\n\t\t\t\tm.getError = errors.New(\"api server unavailable\")\n\t\t\t},\n\t\t\texpectError:        true,\n\t\t\terrorContains:      \"api server unavailable\",\n\t\t\texpectGetCalled:    true,\n\t\t\texpectCreateCalled: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"should fail when Create namespace returns error\",\n\t\t\tnamespace: \"create-fail-namespace\",\n\t\t\tsetupMock: func(m *mockNamespaceClient) {\n\t\t\t\tm.getError = kube_errors.NewNotFound(schema.GroupResource{Resource: \"namespaces\"}, \"create-fail-namespace\")\n\t\t\t\tm.createError = errors.New(\"insufficient permissions\")\n\t\t\t},\n\t\t\texpectError:        true,\n\t\t\terrorContains:      \"insufficient permissions\",\n\t\t\texpectGetCalled:    true,\n\t\t\texpectCreateCalled: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := &mockNamespaceClient{}\n\t\t\ttt.setupMock(client)\n\n\t\t\terr := mkNamespace(t.Context(), client, tt.namespace)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tif tt.errorContains != \"\" {\n\t\t\t\t\tassert.Contains(t, err.Error(), tt.errorContains)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.expectGetCalled, client.getCalled, \"Get call expectation\")\n\t\t\tassert.Equal(t, tt.expectCreateCalled, client.createCalled, \"Create call expectation\")\n\n\t\t\tif tt.expectCreateCalled && client.createCalled {\n\t\t\t\tassert.NotNil(t, client.createdNS, \"Created namespace should not be nil\")\n\t\t\t\tassert.Equal(t, tt.namespace, client.createdNS.Name, \"Created namespace should have correct name\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/pod.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\t\"k8s.io/apimachinery/pkg/api/resource\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nconst (\n\t// StepLabelLegacy is the legacy label name from before the introduction of the woodpecker-ci.org namespace.\n\t// This will be removed in the future.\n\tStepLabelLegacy          = \"step\"\n\tStepLabel                = \"woodpecker-ci.org/step\"\n\tTaskUUIDLabel            = \"woodpecker-ci.org/task-uuid\"\n\tpodPrefix                = \"wp-\"\n\tdefaultFSGroup     int64 = 1000\n\tinitContainerImage       = \"busybox:stable-musl\"\n)\n\nfunc mkPod(step *types.Step, config *config, podName, goos string, options BackendOptions, taskUUID string) (*kube_core_v1.Pod, error) {\n\tvar err error\n\n\tnsp := newNativeSecretsProcessor(config, options.Secrets)\n\terr = nsp.process()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmeta, err := podMeta(step, config, options, podName, taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tspec, err := podSpec(step, config, options, nsp, taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcontainer, err := podContainer(step, podName, goos, options, nsp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tspec.Containers = append(spec.Containers, container)\n\n\tinitContainer := podInitContainer(&spec, &container)\n\tif initContainer != nil {\n\t\tspec.InitContainers = append(spec.InitContainers, *initContainer)\n\t}\n\n\tpod := &kube_core_v1.Pod{\n\t\tObjectMeta: meta,\n\t\tSpec:       spec,\n\t}\n\n\treturn pod, nil\n}\n\nfunc stepToPodName(step *types.Step) (name string, err error) {\n\tif isService(step) {\n\t\treturn serviceName(step)\n\t}\n\treturn podName(step)\n}\n\nfunc podName(step *types.Step) (string, error) {\n\treturn dnsName(podPrefix + step.UUID)\n}\n\nfunc podMeta(step *types.Step, config *config, options BackendOptions, podName, taskUUID string) (kube_meta_v1.ObjectMeta, error) {\n\tvar err error\n\tmeta := kube_meta_v1.ObjectMeta{\n\t\tName:        podName,\n\t\tNamespace:   config.GetNamespace(step.OrgID),\n\t\tAnnotations: podAnnotations(config, options),\n\t}\n\n\tmeta.Labels, err = podLabels(step, config, options, taskUUID)\n\tif err != nil {\n\t\treturn meta, err\n\t}\n\n\treturn meta, nil\n}\n\nfunc podLabels(step *types.Step, config *config, options BackendOptions, taskUUID string) (map[string]string, error) {\n\tvar err error\n\tlabels := make(map[string]string)\n\n\tfor k, v := range step.WorkflowLabels {\n\t\t// Only copy user labels if allowed by agent config.\n\t\t// Internal labels are filtered on the server-side.\n\t\tif config.PodLabelsAllowFromStep || strings.HasPrefix(k, pipeline.InternalLabelPrefix) {\n\t\t\tlabels[k], err = toDNSName(v)\n\t\t\tif err != nil {\n\t\t\t\treturn labels, err\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(options.Labels) > 0 {\n\t\tif config.PodLabelsAllowFromStep {\n\t\t\tlog.Trace().Msgf(\"using labels from the backend options: %v\", options.Labels)\n\t\t\t// TODO should we filter out label with internal prefix?\n\t\t\tmaps.Copy(labels, options.Labels)\n\t\t} else {\n\t\t\tlog.Debug().Msg(\"Pod labels were defined in backend options, but its using disallowed by instance configuration\")\n\t\t}\n\t}\n\tif len(config.PodLabels) > 0 {\n\t\tlog.Trace().Msgf(\"using labels from the configuration: %v\", config.PodLabels)\n\t\t// TODO should we filter out label with internal prefix?\n\t\tmaps.Copy(labels, config.PodLabels)\n\t}\n\tif isService(step) {\n\t\tlabels[ServiceLabel], _ = serviceName(step)\n\t}\n\tlabels[StepLabelLegacy], err = stepLabel(step)\n\tif err != nil {\n\t\treturn labels, err\n\t}\n\tlabels[StepLabel], err = stepLabel(step)\n\tif err != nil {\n\t\treturn labels, err\n\t}\n\n\tif len(taskUUID) > 0 {\n\t\tlabels[TaskUUIDLabel] = taskUUID\n\t}\n\n\treturn labels, nil\n}\n\nfunc stepLabel(step *types.Step) (string, error) {\n\treturn toDNSName(step.Name)\n}\n\nfunc podAnnotations(config *config, options BackendOptions) map[string]string {\n\tannotations := make(map[string]string)\n\n\tif len(options.Annotations) > 0 {\n\t\tif config.PodAnnotationsAllowFromStep {\n\t\t\tlog.Trace().Msgf(\"using annotations from the backend options: %v\", options.Annotations)\n\t\t\tmaps.Copy(annotations, options.Annotations)\n\t\t} else {\n\t\t\tlog.Debug().Msg(\"Pod annotations were defined in backend options, but its using disallowed by instance configuration \")\n\t\t}\n\t}\n\tif len(config.PodAnnotations) > 0 {\n\t\tlog.Trace().Msgf(\"using annotations from the configuration: %v\", config.PodAnnotations)\n\t\tmaps.Copy(annotations, config.PodAnnotations)\n\t}\n\n\treturn annotations\n}\n\nfunc podSpec(step *types.Step, config *config, options BackendOptions, nsp nativeSecretsProcessor, taskUUID string) (kube_core_v1.PodSpec, error) {\n\tsubdomain, err := subdomain(taskUUID)\n\tif err != nil {\n\t\treturn kube_core_v1.PodSpec{}, err\n\t}\n\n\tspec := kube_core_v1.PodSpec{\n\t\tRestartPolicy:      kube_core_v1.RestartPolicyNever,\n\t\tRuntimeClassName:   options.RuntimeClassName,\n\t\tServiceAccountName: options.ServiceAccountName,\n\t\tPriorityClassName:  config.PriorityClassName,\n\t\tHostAliases:        hostAliases(step.ExtraHosts),\n\t\tHostname:           getHostnameOrEmpty(step.Name),\n\t\tSubdomain:          subdomain,\n\t\tDNSConfig:          dnsConfig(config.GetNamespace(step.OrgID), subdomain),\n\t\tNodeSelector:       nodeSelector(options.NodeSelector, config.PodNodeSelector, step.Environment[\"CI_SYSTEM_PLATFORM\"]),\n\t\tTolerations:        tolerations(options.Tolerations),\n\t\tAffinity:           affinity(options.Affinity, config.PodAffinity, config.PodAffinityAllowFromStep),\n\t\tSecurityContext:    podSecurityContext(options.SecurityContext, config.SecurityContext, step.Privileged),\n\t}\n\n\t// If there are tolerations and they are allowed\n\tif config.PodTolerationsAllowFromStep && len(options.Tolerations) != 0 {\n\t\tspec.Tolerations = tolerations(options.Tolerations)\n\t} else {\n\t\tspec.Tolerations = tolerations(config.PodTolerations)\n\t}\n\n\tspec.Volumes, err = pvcVolumes(step.Volumes)\n\tif err != nil {\n\t\treturn spec, err\n\t}\n\n\tif len(step.DNS) != 0 || len(step.DNSSearch) != 0 {\n\t\tspec.DNSConfig = &kube_core_v1.PodDNSConfig{}\n\t\tif len(step.DNS) != 0 {\n\t\t\tspec.DNSConfig.Nameservers = step.DNS\n\t\t}\n\t\tif len(step.DNSSearch) != 0 {\n\t\t\tspec.DNSConfig.Searches = step.DNSSearch\n\t\t}\n\t}\n\n\tlog.Trace().Msgf(\"using the image pull secrets: %v\", config.ImagePullSecretNames)\n\tspec.ImagePullSecrets = secretsReferences(config.ImagePullSecretNames)\n\tif needsRegistrySecret(step) {\n\t\tlog.Trace().Msgf(\"using an image pull secret from registries\")\n\t\tname, err := registrySecretName(step)\n\t\tif err != nil {\n\t\t\treturn spec, err\n\t\t}\n\t\tspec.ImagePullSecrets = append(spec.ImagePullSecrets, secretReference(name))\n\t}\n\n\tspec.Volumes = append(spec.Volumes, nsp.volumes...)\n\n\treturn spec, nil\n}\n\nfunc podContainer(step *types.Step, podName, goos string, options BackendOptions, nsp nativeSecretsProcessor) (kube_core_v1.Container, error) {\n\tvar err error\n\tcontainer := kube_core_v1.Container{\n\t\tName:            podName,\n\t\tImage:           step.Image,\n\t\tWorkingDir:      step.WorkingDir,\n\t\tPorts:           containerPorts(step.Ports),\n\t\tSecurityContext: containerSecurityContext(options.SecurityContext, step.Privileged),\n\t}\n\n\tif step.Pull {\n\t\tcontainer.ImagePullPolicy = kube_core_v1.PullAlways\n\t}\n\n\tif len(step.Commands) > 0 {\n\t\tscriptEnv, command := common.GenerateContainerConf(step.Commands, goos, step.WorkingDir)\n\t\tcontainer.Command = command\n\t\tmaps.Copy(step.Environment, scriptEnv)\n\n\t\t// step.WorkingDir will be respected by the generated script\n\t\tcontainer.WorkingDir = step.WorkspaceBase\n\t}\n\tif len(step.Entrypoint) > 0 {\n\t\tcontainer.Command = step.Entrypoint\n\t}\n\n\tstepSecret, err := stepSecretName(step)\n\tif err != nil {\n\t\treturn container, err\n\t}\n\n\t// filter environment variables to non-secrets and secrets, refer secrets from step secrets\n\tenvs, secs := filterSecrets(step.Environment, step.SecretMapping)\n\tenvsFromSecrets := mapToEnvVarsFromStepSecrets(secs, stepSecret)\n\tcontainer.Env = append(mapToEnvVars(envs), envsFromSecrets...)\n\n\tcontainer.Resources, err = resourceRequirements(options.Resources)\n\tif err != nil {\n\t\treturn container, err\n\t}\n\n\tcontainer.VolumeMounts, err = volumeMounts(step.Volumes)\n\tif err != nil {\n\t\treturn container, err\n\t}\n\n\tcontainer.EnvFrom = append(container.EnvFrom, nsp.envFromSources...)\n\tcontainer.Env = append(container.Env, nsp.envVars...)\n\tcontainer.VolumeMounts = append(container.VolumeMounts, nsp.mounts...)\n\n\treturn container, nil\n}\n\n// podInitContainer determines whether an init container is required to prepare the\n// main step container's working directory with the correct permissions.\n// If it is required, it returns the init container spec, otherwise it returns an empty container spec.\nfunc podInitContainer(podSpec *kube_core_v1.PodSpec, container *kube_core_v1.Container) *kube_core_v1.Container {\n\t// if pod is running as root, we don't need an init container to precreate the workingDir\n\t// since kubelet already precreates it (as root:root)\n\tif podSpec.SecurityContext == nil ||\n\t\tpodSpec.SecurityContext.RunAsUser == nil ||\n\t\t*podSpec.SecurityContext.RunAsUser == 0 {\n\t\treturn nil\n\t}\n\n\tvolumeMounts := []kube_core_v1.VolumeMount{}\n\n\tfor _, mount := range container.VolumeMounts {\n\t\t// we only add volume mounts to the init container if the workingDir is under the mount path\n\t\t// otherwise the init container won't have permission to create the workingDir\n\t\t// when workingDir is exactly the same as mountPath, permissions are already handled by the FsGroupChangePolicy\n\t\tif strings.HasPrefix(container.WorkingDir, mount.MountPath+\"/\") {\n\t\t\tvolumeMounts = append(volumeMounts, mount)\n\t\t}\n\t}\n\t// if workingDir is not covered by any volume mount, we don't need an init container to precreate it\n\tif len(volumeMounts) == 0 {\n\t\treturn nil\n\t}\n\n\treturn &kube_core_v1.Container{\n\t\tName:            \"init-\" + container.Name,\n\t\tImage:           initContainerImage,\n\t\tImagePullPolicy: kube_core_v1.PullAlways,\n\t\tArgs:            []string{\"mkdir\", \"-p\", container.WorkingDir},\n\t\tSecurityContext: &kube_core_v1.SecurityContext{\n\t\t\tCapabilities: &kube_core_v1.Capabilities{\n\t\t\t\tDrop: []kube_core_v1.Capability{\"ALL\"},\n\t\t\t},\n\t\t\tAllowPrivilegeEscalation: newBool(false),\n\t\t},\n\t\tResources: kube_core_v1.ResourceRequirements{\n\t\t\tRequests: kube_core_v1.ResourceList{\n\t\t\t\tkube_core_v1.ResourceCPU:    resource.MustParse(\"5m\"),\n\t\t\t\tkube_core_v1.ResourceMemory: resource.MustParse(\"5Mi\"),\n\t\t\t},\n\t\t\tLimits: kube_core_v1.ResourceList{\n\t\t\t\tkube_core_v1.ResourceCPU:    resource.MustParse(\"5m\"),\n\t\t\t\tkube_core_v1.ResourceMemory: resource.MustParse(\"5Mi\"),\n\t\t\t},\n\t\t},\n\t\tVolumeMounts: volumeMounts,\n\t}\n}\n\nfunc mapToEnvVarsFromStepSecrets(secs []string, stepSecretName string) []kube_core_v1.EnvVar {\n\tvar ev []kube_core_v1.EnvVar\n\tfor _, key := range secs {\n\t\tev = append(ev, kube_core_v1.EnvVar{\n\t\t\tName: key,\n\t\t\tValueFrom: &kube_core_v1.EnvVarSource{\n\t\t\t\tSecretKeyRef: &kube_core_v1.SecretKeySelector{\n\t\t\t\t\tLocalObjectReference: kube_core_v1.LocalObjectReference{\n\t\t\t\t\t\tName: stepSecretName,\n\t\t\t\t\t},\n\t\t\t\t\tKey: key,\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\treturn ev\n}\n\nfunc filterSecrets(environment, secrets map[string]string) (map[string]string, []string) {\n\tev := map[string]string{}\n\tvar secs []string\n\n\tfor k, v := range environment {\n\t\tif _, found := secrets[k]; found {\n\t\t\tsecs = append(secs, k)\n\t\t} else {\n\t\t\tev[k] = v\n\t\t}\n\t}\n\treturn ev, secs\n}\n\nfunc pvcVolumes(volumes []string) ([]kube_core_v1.Volume, error) {\n\tvar vols []kube_core_v1.Volume\n\n\tfor _, v := range volumes {\n\t\tvolumeName, err := volumeName(v)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvols = append(vols, pvcVolume(volumeName))\n\t}\n\n\treturn vols, nil\n}\n\nfunc pvcVolume(name string) kube_core_v1.Volume {\n\tpvcSource := kube_core_v1.PersistentVolumeClaimVolumeSource{\n\t\tClaimName: name,\n\t\tReadOnly:  false,\n\t}\n\treturn kube_core_v1.Volume{\n\t\tName: name,\n\t\tVolumeSource: kube_core_v1.VolumeSource{\n\t\t\tPersistentVolumeClaim: &pvcSource,\n\t\t},\n\t}\n}\n\nfunc volumeMounts(volumes []string) ([]kube_core_v1.VolumeMount, error) {\n\tvar mounts []kube_core_v1.VolumeMount\n\n\tfor _, v := range volumes {\n\t\tvolumeName, err := volumeName(v)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tmount := volumeMount(volumeName, volumeMountPath(v))\n\t\tmounts = append(mounts, mount)\n\t}\n\treturn mounts, nil\n}\n\nfunc volumeMount(name, path string) kube_core_v1.VolumeMount {\n\treturn kube_core_v1.VolumeMount{\n\t\tName:      name,\n\t\tMountPath: path,\n\t}\n}\n\nfunc containerPorts(ports []types.Port) []kube_core_v1.ContainerPort {\n\tcontainerPorts := make([]kube_core_v1.ContainerPort, len(ports))\n\tfor i, port := range ports {\n\t\tcontainerPorts[i] = containerPort(port)\n\t}\n\treturn containerPorts\n}\n\nfunc containerPort(port types.Port) kube_core_v1.ContainerPort {\n\treturn kube_core_v1.ContainerPort{\n\t\tContainerPort: int32(port.Number),\n\t\tProtocol:      kube_core_v1.Protocol(strings.ToUpper(port.Protocol)),\n\t}\n}\n\n// Here is the service IPs (placed in /etc/hosts in the Pod).\nfunc hostAliases(extraHosts []types.HostAlias) []kube_core_v1.HostAlias {\n\tvar hostAliases []kube_core_v1.HostAlias\n\tfor _, extraHost := range extraHosts {\n\t\thostAlias := hostAlias(extraHost)\n\t\thostAliases = append(hostAliases, hostAlias)\n\t}\n\treturn hostAliases\n}\n\nfunc hostAlias(extraHost types.HostAlias) kube_core_v1.HostAlias {\n\treturn kube_core_v1.HostAlias{\n\t\tIP:        extraHost.IP,\n\t\tHostnames: []string{extraHost.Name},\n\t}\n}\n\nfunc resourceRequirements(resources Resources) (kube_core_v1.ResourceRequirements, error) {\n\tvar err error\n\trequirements := kube_core_v1.ResourceRequirements{}\n\n\trequirements.Requests, err = resourceList(resources.Requests)\n\tif err != nil {\n\t\treturn requirements, err\n\t}\n\n\trequirements.Limits, err = resourceList(resources.Limits)\n\tif err != nil {\n\t\treturn requirements, err\n\t}\n\n\treturn requirements, nil\n}\n\nfunc resourceList(resources map[string]string) (kube_core_v1.ResourceList, error) {\n\trequestResources := kube_core_v1.ResourceList{}\n\tfor key, val := range resources {\n\t\tresName := kube_core_v1.ResourceName(key)\n\t\tresVal, err := resource.ParseQuantity(val)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"resource request '%s' quantity '%s': %w\", key, val, err)\n\t\t}\n\t\trequestResources[resName] = resVal\n\t}\n\treturn requestResources, nil\n}\n\nfunc nodeSelector(backendNodeSelector, configNodeSelector map[string]string, platform string) map[string]string {\n\tnodeSelector := make(map[string]string)\n\n\tif platform != \"\" {\n\t\tarch := strings.Split(platform, \"/\")[1]\n\t\tnodeSelector[kube_core_v1.LabelArchStable] = arch\n\t\tlog.Trace().Msgf(\"using the node selector from the Agent's platform: %v\", nodeSelector)\n\t}\n\n\tif len(configNodeSelector) > 0 {\n\t\tlog.Trace().Msgf(\"appending labels to the node selector from the configuration: %v\", configNodeSelector)\n\t\tmaps.Copy(nodeSelector, configNodeSelector)\n\t}\n\n\tif len(backendNodeSelector) > 0 {\n\t\tlog.Trace().Msgf(\"appending labels to the node selector from the backend options: %v\", backendNodeSelector)\n\t\tmaps.Copy(nodeSelector, backendNodeSelector)\n\t}\n\n\treturn nodeSelector\n}\n\nfunc tolerations(backendTolerations []Toleration) []kube_core_v1.Toleration {\n\tvar tolerations []kube_core_v1.Toleration\n\n\tif len(backendTolerations) > 0 {\n\t\tlog.Trace().Msgf(\"tolerations that will be used in the backend options: %v\", backendTolerations)\n\t\tfor _, backendToleration := range backendTolerations {\n\t\t\ttoleration := toleration(backendToleration)\n\t\t\ttolerations = append(tolerations, toleration)\n\t\t}\n\t}\n\n\treturn tolerations\n}\n\nfunc toleration(backendToleration Toleration) kube_core_v1.Toleration {\n\treturn kube_core_v1.Toleration{\n\t\tKey:               backendToleration.Key,\n\t\tOperator:          kube_core_v1.TolerationOperator(backendToleration.Operator),\n\t\tValue:             backendToleration.Value,\n\t\tEffect:            kube_core_v1.TaintEffect(backendToleration.Effect),\n\t\tTolerationSeconds: backendToleration.TolerationSeconds,\n\t}\n}\n\nfunc affinity(stepAffinity, agentAffinity *kube_core_v1.Affinity, allowFromStep bool) *kube_core_v1.Affinity {\n\tif stepAffinity != nil {\n\t\tif allowFromStep {\n\t\t\tlog.Trace().Msg(\"using affinity from step backend options\")\n\t\t\treturn stepAffinity\n\t\t} else {\n\t\t\tlog.Debug().Msg(\"Step affinity is disallowed by instance configuration, ignoring it\")\n\t\t}\n\t}\n\n\tif agentAffinity != nil {\n\t\tlog.Trace().Msg(\"using affinity from agent configuration\")\n\t\treturn agentAffinity\n\t}\n\n\tlog.Trace().Msg(\"no affinity configured\")\n\treturn nil\n}\n\nfunc podSecurityContext(sc *SecurityContext, secCtxConf SecurityContextConfig, stepPrivileged bool) *kube_core_v1.PodSecurityContext {\n\tvar (\n\t\tnonRoot             *bool\n\t\tuser                *int64\n\t\tgroup               *int64\n\t\tfsGroup             *int64\n\t\tfsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy\n\t\tseccomp             *kube_core_v1.SeccompProfile\n\t\tapparmor            *kube_core_v1.AppArmorProfile\n\t)\n\n\tif secCtxConf.RunAsNonRoot {\n\t\tnonRoot = newBool(true)\n\t}\n\tif secCtxConf.FSGroup != nil {\n\t\tfsGroup = secCtxConf.FSGroup\n\t}\n\n\tif sc != nil {\n\t\t// only allow to set user if its not root or step is privileged\n\t\tif sc.RunAsUser != nil && (*sc.RunAsUser != 0 || stepPrivileged) {\n\t\t\tuser = sc.RunAsUser\n\t\t}\n\n\t\t// only allow to set group if its not root or step is privileged\n\t\tif sc.RunAsGroup != nil && (*sc.RunAsGroup != 0 || stepPrivileged) {\n\t\t\tgroup = sc.RunAsGroup\n\t\t}\n\n\t\t// only allow to set fsGroup if its not root or step is privileged\n\t\tif sc.FSGroup != nil && (*sc.FSGroup != 0 || stepPrivileged) {\n\t\t\tfsGroup = sc.FSGroup\n\t\t}\n\n\t\t// if unset, set fsGroup to 1000 by default to support non-root images\n\t\tif sc.FSGroup != nil {\n\t\t\tfsGroup = sc.FSGroup\n\t\t}\n\n\t\t// only allow to set nonRoot if it's not set globally already\n\t\tif nonRoot == nil && sc.RunAsNonRoot != nil {\n\t\t\tnonRoot = sc.RunAsNonRoot\n\t\t}\n\n\t\tseccomp = seccompProfile(sc.SeccompProfile)\n\t\tapparmor = apparmorProfile(sc.ApparmorProfile)\n\t\tfsGroupChangePolicy = sc.FsGroupChangePolicy\n\t}\n\n\tif nonRoot == nil && user == nil && group == nil && fsGroup == nil && seccomp == nil && apparmor == nil {\n\t\treturn nil\n\t}\n\n\tsecurityContext := &kube_core_v1.PodSecurityContext{\n\t\tRunAsNonRoot:        nonRoot,\n\t\tRunAsUser:           user,\n\t\tRunAsGroup:          group,\n\t\tFSGroup:             fsGroup,\n\t\tFSGroupChangePolicy: fsGroupChangePolicy,\n\t\tSeccompProfile:      seccomp,\n\t\tAppArmorProfile:     apparmor,\n\t}\n\tlog.Trace().Msgf(\"pod security context that will be used: %v\", securityContext)\n\treturn securityContext\n}\n\nfunc seccompProfile(scp *SecProfile) *kube_core_v1.SeccompProfile {\n\tif scp == nil || len(scp.Type) == 0 {\n\t\treturn nil\n\t}\n\tlog.Trace().Msgf(\"using seccomp profile: %v\", scp)\n\n\tseccompProfile := &kube_core_v1.SeccompProfile{\n\t\tType: kube_core_v1.SeccompProfileType(scp.Type),\n\t}\n\tif len(scp.LocalhostProfile) > 0 {\n\t\tseccompProfile.LocalhostProfile = &scp.LocalhostProfile\n\t}\n\n\treturn seccompProfile\n}\n\nfunc apparmorProfile(scp *SecProfile) *kube_core_v1.AppArmorProfile {\n\tif scp == nil || len(scp.Type) == 0 {\n\t\treturn nil\n\t}\n\tlog.Trace().Msgf(\"using AppArmor profile: %v\", scp)\n\n\tapparmorProfile := &kube_core_v1.AppArmorProfile{\n\t\tType: kube_core_v1.AppArmorProfileType(scp.Type),\n\t}\n\tif len(scp.LocalhostProfile) > 0 {\n\t\tapparmorProfile.LocalhostProfile = &scp.LocalhostProfile\n\t}\n\n\treturn apparmorProfile\n}\n\nfunc containerCapabilities(capabilities *Capabilities) *kube_core_v1.Capabilities {\n\tif capabilities == nil || len(capabilities.Drop) == 0 {\n\t\treturn nil\n\t}\n\n\tdrop := make([]kube_core_v1.Capability, len(capabilities.Drop))\n\n\tfor i, c := range capabilities.Drop {\n\t\tdrop[i] = kube_core_v1.Capability(c)\n\t}\n\n\treturn &kube_core_v1.Capabilities{\n\t\tDrop: drop,\n\t}\n}\n\nfunc containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_core_v1.SecurityContext {\n\tvar (\n\t\tprivileged               *bool\n\t\tallowPrivilegeEscalation *bool\n\t\tcapabilities             *kube_core_v1.Capabilities\n\t)\n\n\t// A container may only run privileged when the step itself is privileged.\n\t// If the step is privileged, the container is privileged by default unless\n\t// explicitly disabled via securityContext.privileged=false.\n\tif stepPrivileged && (sc == nil || sc.Privileged == nil || *sc.Privileged) {\n\t\tprivileged = newBool(true)\n\t}\n\n\tif sc != nil {\n\t\t// allowPrivilegeEscalation can only be set to false.\n\t\tif sc.AllowPrivilegeEscalation != nil && !*sc.AllowPrivilegeEscalation {\n\t\t\tallowPrivilegeEscalation = sc.AllowPrivilegeEscalation\n\t\t}\n\n\t\tcapabilities = containerCapabilities(sc.Capabilities)\n\t}\n\n\tif privileged == nil && capabilities == nil && allowPrivilegeEscalation == nil {\n\t\treturn nil\n\t}\n\n\tsecurityContext := &kube_core_v1.SecurityContext{\n\t\tPrivileged:               privileged,\n\t\tAllowPrivilegeEscalation: allowPrivilegeEscalation,\n\t\tCapabilities:             capabilities,\n\t}\n\n\tlog.Trace().Msgf(\"container security context that will be used: %v\", securityContext)\n\treturn securityContext\n}\n\nfunc mapToEnvVars(m map[string]string) []kube_core_v1.EnvVar {\n\tvar ev []kube_core_v1.EnvVar\n\tfor k, v := range m {\n\t\tev = append(ev, kube_core_v1.EnvVar{\n\t\t\tName:  k,\n\t\t\tValue: v,\n\t\t})\n\t}\n\treturn ev\n}\n\nfunc dnsConfig(namespace, subdomain string) *kube_core_v1.PodDNSConfig {\n\treturn &kube_core_v1.PodDNSConfig{\n\t\tSearches: []string{fmt.Sprintf(\"%s.%s.svc.cluster.local\", subdomain, namespace)},\n\t}\n}\n\nfunc startPod(ctx context.Context, engine *kube, step *types.Step, options BackendOptions, taskUUID string) (*kube_core_v1.Pod, error) {\n\tpodName, err := stepToPodName(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tengineConfig := engine.getConfig()\n\tpod, err := mkPod(step, engineConfig, podName, engine.goos, options, taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Msgf(\"creating pod: %s\", pod.Name)\n\treturn engine.client.CoreV1().Pods(engineConfig.GetNamespace(step.OrgID)).Create(ctx, pod, kube_meta_v1.CreateOptions{})\n}\n\nfunc stopPod(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error {\n\tpodName, err := stepToPodName(step)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"name\", podName).Msg(\"deleting pod\")\n\n\terr = engine.client.CoreV1().Pods(engine.config.GetNamespace(step.OrgID)).Delete(ctx, podName, deleteOpts)\n\tif errors.IsNotFound(err) {\n\t\t// Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps.\n\t\treturn nil\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/pod_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/kinbiko/jsonassert\"\n\t\"github.com/stretchr/testify/assert\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nconst taskUUID = \"11301\"\n\nfunc TestPodName(t *testing.T) {\n\tname, err := podName(&types.Step{UUID: \"01he8bebctabr3kgk0qj36d2me-0\"})\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-01he8bebctabr3kgk0qj36d2me-0\", name)\n\n\t_, err = podName(&types.Step{UUID: \"01he8bebctabr3kgk0qj36d2me\\\\0a\"})\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = podName(&types.Step{UUID: \"01he8bebctabr3kgk0qj36d2me-0-services-0..woodpecker-runtime.svc.cluster.local\"})\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n}\n\nfunc TestStepToPodName(t *testing.T) {\n\tname, err := stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"clone\", Type: types.StepTypeClone})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-01he8bebctabr3kg\", name)\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"cache\", Type: types.StepTypeCache})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-01he8bebctabr3kg\", name)\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"release\", Type: types.StepTypePlugin})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-01he8bebctabr3kg\", name)\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"prepare-env\", Type: types.StepTypeCommands})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-01he8bebctabr3kg\", name)\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"postgres\", Type: types.StepTypeService, Ports: []types.Port{{Number: 5432}}})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-svc-01he8bebctabr3kg-postgres\", name)\n\t// Service\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"postgres\", Detached: true, Type: types.StepTypeService, Ports: []types.Port{{Number: 5432}}})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-svc-01he8bebctabr3kg-postgres\", name)\n\t// Detached long running container\n\tname, err = stepToPodName(&types.Step{UUID: \"01he8bebctabr3kg\", Name: \"long running\", Detached: true})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-01he8bebctabr3kg\", name)\n}\n\nfunc TestPodMeta(t *testing.T) {\n\tmeta, err := podMeta(&types.Step{\n\t\tName:        \"postgres\",\n\t\tUUID:        \"01he8bebctabr3kg\",\n\t\tType:        types.StepTypeService,\n\t\tImage:       \"postgres:16\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tEnvironment: map[string]string{\"CI\": \"woodpecker\"},\n\t\tPorts:       []types.Port{{Number: 5432}},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, BackendOptions{}, \"wp-01he8bebctabr3kg-0\", taskUUID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-svc-01he8bebctabr3kg-postgres\", meta.Labels[ServiceLabel])\n\tassert.EqualValues(t, taskUUID, meta.Labels[TaskUUIDLabel])\n\n\t// Service\n\tmeta, err = podMeta(&types.Step{\n\t\tName:        \"postgres\",\n\t\tUUID:        \"01he8bebctabr3kg\",\n\t\tDetached:    true,\n\t\tType:        types.StepTypeService,\n\t\tImage:       \"postgres:16\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tEnvironment: map[string]string{\"CI\": \"woodpecker\"},\n\t\tPorts:       []types.Port{{Number: 5432}},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, BackendOptions{}, \"wp-01he8bebctabr3kg-0\", taskUUID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"wp-svc-01he8bebctabr3kg-postgres\", meta.Labels[ServiceLabel])\n\n\t// Detached long running container\n\tmeta, err = podMeta(&types.Step{\n\t\tName:        \"long running\",\n\t\tUUID:        \"01he8bebctabr3kg\",\n\t\tDetached:    true,\n\t\tImage:       \"postgres:16\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tEnvironment: map[string]string{\"CI\": \"woodpecker\"},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, BackendOptions{}, \"wp-01he8bebctabr3kg-0\", taskUUID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"\", meta.Labels[ServiceLabel])\n}\n\nfunc TestStepLabel(t *testing.T) {\n\tname, err := stepLabel(&types.Step{Name: \"Build image\"})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"build-image\", name)\n\n\t_, err = stepLabel(&types.Step{Name: \".build.image\"})\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n}\n\nfunc TestPodHostnameSanitized(t *testing.T) {\n\tpod, err := mkPod(&types.Step{\n\t\tName:        \"Update repos\",\n\t\tImage:       \"alpine:latest\",\n\t\tUUID:        \"01he8bebctabr3kgk0qj36d2me-1\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tEnvironment: map[string]string{},\n\t}, &config{Namespace: \"woodpecker\"}, \"wp-01he8bebctabr3kgk0qj36d2me-1\", \"linux/amd64\", BackendOptions{}, taskUUID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"update-repos\", pod.Spec.Hostname)\n}\n\nfunc TestTinyPod(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"build-via-gradle\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"build-via-gradle\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"volumes\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"workspace\",\n\t\t\t\t\t\"persistentVolumeClaim\": {\n\t\t\t\t\t\t\"claimName\": \"workspace\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"gradle:8.4.0-jdk21\",\n\t\t\t\t\t\"command\": [\n\t\t\t\t\t\t\"/bin/sh\",\n\t\t\t\t\t\t\"-c\",\n\t\t\t\t\t\t\"echo $CI_SCRIPT | base64 -d | /bin/sh -e\"\n\t\t\t\t\t],\n\t\t\t\t\t\"env\": [\n\t\t\t\t\t\t\"<<UNORDERED>>\",\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"CI\",\n\t\t\t\t\t\t\t\"value\": \"woodpecker\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"SHELL\",\n\t\t\t\t\t\t\t\"value\": \"/bin/sh\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"CI_SCRIPT\",\n\t\t\t\t\t\t\t\"value\": \"CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc3JjIgpjZCAiL3dvb2RwZWNrZXIvc3JjIgoKZWNobyArICdncmFkbGUgYnVpbGQnCmdyYWRsZSBidWlsZAo=\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"resources\": {},\n\t\t\t\t\t\"volumeMounts\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"workspace\",\n\t\t\t\t\t\t\t\"mountPath\": \"/woodpecker/src\"\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"build-via-gradle\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:        \"build-via-gradle\",\n\t\tImage:       \"gradle:8.4.0-jdk21\",\n\t\tUUID:        \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tPull:        false,\n\t\tPrivileged:  false,\n\t\tCommands:    []string{\"gradle build\"},\n\t\tVolumes:     []string{\"workspace:/woodpecker/src\"},\n\t\tEnvironment: map[string]string{\"CI\": \"woodpecker\"},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestFullPod(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"app\": \"test\",\n\t\t\t\t\"part-of\": \"woodpecker-ci\",\n\t\t\t\t\"step\": \"go-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"go-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t},\n\t\t\t\"annotations\": {\n\t\t\t\t\"apps.kubernetes.io/pod-index\": \"0\",\n\t\t\t\t\"kubernetes.io/limit-ranger\": \"LimitRanger plugin set: cpu, memory request and limit for container\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"volumes\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"woodpecker-cache\",\n\t\t\t\t\t\"persistentVolumeClaim\": {\n\t\t\t\t\t\t\"claimName\": \"woodpecker-cache\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"meltwater/drone-cache\",\n\t\t\t\t\t\"command\": [\n\t\t\t\t\t\t\"/bin/sh\",\n\t\t\t\t\t\t\"-c\"\n\t\t\t\t\t],\n\t\t\t\t\t\"ports\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"containerPort\": 1234\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"containerPort\": 2345,\n\t\t\t\t\t\t\t\"protocol\": \"TCP\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"containerPort\": 3456,\n\t\t\t\t\t\t\t\"protocol\": \"UDP\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"env\": [\n\t\t\t\t\t\t\"<<UNORDERED>>\",\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"CGO\",\n\t\t\t\t\t\t\t\"value\": \"0\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"CI_SCRIPT\",\n\t\t\t\t\t\t\t\"value\": \"CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc3JjIgpjZCAiL3dvb2RwZWNrZXIvc3JjIgoKZWNobyArICdnbyBnZXQnCmdvIGdldAoKZWNobyArICdnbyB0ZXN0JwpnbyB0ZXN0Cg==\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"SHELL\",\n\t\t\t\t\t\t\t\"value\": \"/bin/sh\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"resources\": {\n\t\t\t\t\t\t\"limits\": {\n\t\t\t\t\t\t\t\"cpu\": \"2\",\n\t\t\t\t\t\t\t\"memory\": \"256Mi\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"requests\": {\n\t\t\t\t\t\t\t\"cpu\": \"1\",\n\t\t\t\t\t\t\t\"memory\": \"128Mi\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"volumeMounts\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"woodpecker-cache\",\n\t\t\t\t\t\t\t\"mountPath\": \"/woodpecker/src/cache\"\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"imagePullPolicy\": \"Always\",\n\t\t\t\t\t\"securityContext\": {\n\t\t\t\t\t\t\"privileged\": true,\n\t\t\t\t\t\t\"allowPrivilegeEscalation\": false,\n\t\t\t\t\t\t\"capabilities\": {\n\t\t\t\t\t\t\t\"drop\": [\"ALL\"]\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"nodeSelector\": {\n\t\t\t\t\"storage\": \"ssd\",\n\t\t\t\t\"topology.kubernetes.io/region\": \"eu-central-1\"\n\t\t\t},\n\t\t\t\"runtimeClassName\": \"runc\",\n\t\t\t\"serviceAccountName\": \"wp-svc-acc\",\n\t\t\t\"securityContext\": {\n\t\t\t\t\"runAsUser\": 101,\n\t\t\t\t\"runAsGroup\": 101,\n\t\t\t\t\"runAsNonRoot\": true,\n\t\t\t\t\"fsGroup\": 101,\n\t\t\t\t\"fsGroupChangePolicy\": \"OnRootMismatch\",\n\t\t\t\t\"appArmorProfile\": {\n\t\t\t\t\t\"type\": \"Localhost\",\n\t\t\t\t\t\"localhostProfile\": \"k8s-apparmor-example-deny-write\"\n\t\t\t\t},\n\t\t\t\t\"seccompProfile\": {\n\t\t\t\t\t\"type\": \"Localhost\",\n\t\t\t\t\t\"localhostProfile\": \"profiles/audit.json\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"imagePullSecrets\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"regcred\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"another-pull-secret\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"tolerations\": [\n\t\t\t\t{\n\t\t\t\t\t\"key\": \"net-port\",\n\t\t\t\t\t\"value\": \"100Mbit\",\n\t\t\t\t\t\"effect\": \"NoSchedule\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"hostAliases\": [\n\t\t\t\t{\n\t\t\t\t\t\"ip\": \"1.1.1.1\",\n\t\t\t\t\t\"hostnames\": [\n\t\t\t\t\t\t\"cloudflare\"\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"ip\": \"2606:4700:4700::64\",\n\t\t\t\t\t\"hostnames\": [\n\t\t\t\t\t\t\"cf.v6\"\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"go-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\truntimeClass := \"runc\"\n\thostAliases := []types.HostAlias{\n\t\t{Name: \"cloudflare\", IP: \"1.1.1.1\"},\n\t\t{Name: \"cf.v6\", IP: \"2606:4700:4700::64\"},\n\t}\n\tports := []types.Port{\n\t\t{Number: 1234},\n\t\t{Number: 2345, Protocol: \"tcp\"},\n\t\t{Number: 3456, Protocol: \"udp\"},\n\t}\n\tfsGroupChangePolicy := kube_core_v1.PodFSGroupChangePolicy(\"OnRootMismatch\")\n\tsecCtx := SecurityContext{\n\t\tPrivileged:               newBool(true),\n\t\tRunAsNonRoot:             newBool(true),\n\t\tRunAsUser:                newInt64(101),\n\t\tRunAsGroup:               newInt64(101),\n\t\tFSGroup:                  newInt64(101),\n\t\tFsGroupChangePolicy:      &fsGroupChangePolicy,\n\t\tAllowPrivilegeEscalation: newBool(false),\n\t\tCapabilities: &Capabilities{\n\t\t\tDrop: []string{\"ALL\"},\n\t\t},\n\t\tSeccompProfile: &SecProfile{\n\t\t\tType:             \"Localhost\",\n\t\t\tLocalhostProfile: \"profiles/audit.json\",\n\t\t},\n\t\tApparmorProfile: &SecProfile{\n\t\t\tType:             \"Localhost\",\n\t\t\tLocalhostProfile: \"k8s-apparmor-example-deny-write\",\n\t\t},\n\t}\n\tpod, err := mkPod(&types.Step{\n\t\tUUID:        \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tName:        \"go-test\",\n\t\tImage:       \"meltwater/drone-cache\",\n\t\tWorkingDir:  \"/woodpecker/src\",\n\t\tPull:        true,\n\t\tPrivileged:  true,\n\t\tCommands:    []string{\"go get\", \"go test\"},\n\t\tEntrypoint:  []string{\"/bin/sh\", \"-c\"},\n\t\tVolumes:     []string{\"woodpecker-cache:/woodpecker/src/cache\"},\n\t\tEnvironment: map[string]string{\"CGO\": \"0\"},\n\t\tExtraHosts:  hostAliases,\n\t\tPorts:       ports,\n\t\tAuthConfig: types.Auth{\n\t\t\tUsername: \"foo\",\n\t\t\tPassword: \"bar\",\n\t\t},\n\t}, &config{\n\t\tNamespace:                   \"woodpecker\",\n\t\tImagePullSecretNames:        []string{\"regcred\", \"another-pull-secret\"},\n\t\tPodLabels:                   map[string]string{\"app\": \"test\"},\n\t\tPodLabelsAllowFromStep:      true,\n\t\tPodAnnotations:              map[string]string{\"apps.kubernetes.io/pod-index\": \"0\"},\n\t\tPodAnnotationsAllowFromStep: true,\n\t\tPodTolerationsAllowFromStep: true,\n\t\tPodNodeSelector:             map[string]string{\"topology.kubernetes.io/region\": \"eu-central-1\"},\n\t\tSecurityContext:             SecurityContextConfig{RunAsNonRoot: false},\n\t},\n\t\t\"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\"linux/amd64\",\n\t\tBackendOptions{\n\t\t\tLabels:             map[string]string{\"part-of\": \"woodpecker-ci\"},\n\t\t\tAnnotations:        map[string]string{\"kubernetes.io/limit-ranger\": \"LimitRanger plugin set: cpu, memory request and limit for container\"},\n\t\t\tNodeSelector:       map[string]string{\"storage\": \"ssd\"},\n\t\t\tRuntimeClassName:   &runtimeClass,\n\t\t\tServiceAccountName: \"wp-svc-acc\",\n\t\t\tTolerations:        []Toleration{{Key: \"net-port\", Value: \"100Mbit\", Effect: TaintEffectNoSchedule}},\n\t\t\tResources: Resources{\n\t\t\t\tRequests: map[string]string{\"memory\": \"128Mi\", \"cpu\": \"1000m\"},\n\t\t\t\tLimits:   map[string]string{\"memory\": \"256Mi\", \"cpu\": \"2\"},\n\t\t\t},\n\t\t\tSecurityContext: &secCtx,\n\t\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestPodPrivilege(t *testing.T) {\n\tcreateTestPod := func(stepPrivileged, globalRunAsRoot bool, secCtx SecurityContext) (*kube_core_v1.Pod, error) {\n\t\treturn mkPod(&types.Step{\n\t\t\tName:       \"go-test\",\n\t\t\tImage:      \"golang:1.16\",\n\t\t\tUUID:       \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\tPrivileged: stepPrivileged,\n\t\t}, &config{\n\t\t\tNamespace:       \"woodpecker\",\n\t\t\tSecurityContext: SecurityContextConfig{RunAsNonRoot: globalRunAsRoot},\n\t\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\t\tSecurityContext: &secCtx,\n\t\t}, \"11301\")\n\t}\n\n\t// securty context is requesting user and group 101 (non-root)\n\tsecCtx := SecurityContext{\n\t\tRunAsUser:  newInt64(101),\n\t\tRunAsGroup: newInt64(101),\n\t\tFSGroup:    newInt64(101),\n\t}\n\tpod, err := createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(101), *pod.Spec.SecurityContext.RunAsUser)\n\tassert.Equal(t, int64(101), *pod.Spec.SecurityContext.RunAsGroup)\n\tassert.Equal(t, int64(101), *pod.Spec.SecurityContext.FSGroup)\n\n\t// securty context is requesting root, but step is not privileged\n\tsecCtx = SecurityContext{\n\t\tRunAsUser:  newInt64(0),\n\t\tRunAsGroup: newInt64(0),\n\t\tFSGroup:    newInt64(0),\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.Equal(t, &kube_core_v1.PodSecurityContext{\n\t\tSELinuxOptions:           (*kube_core_v1.SELinuxOptions)(nil),\n\t\tWindowsOptions:           (*kube_core_v1.WindowsSecurityContextOptions)(nil),\n\t\tRunAsUser:                (*int64)(nil),\n\t\tRunAsGroup:               (*int64)(nil),\n\t\tRunAsNonRoot:             (*bool)(nil),\n\t\tSupplementalGroups:       []int64(nil),\n\t\tSupplementalGroupsPolicy: (*kube_core_v1.SupplementalGroupsPolicy)(nil),\n\t\tFSGroup:                  newInt64(0),\n\t\tSysctls:                  []kube_core_v1.Sysctl(nil),\n\t\tFSGroupChangePolicy:      (*kube_core_v1.PodFSGroupChangePolicy)(nil),\n\t\tSeccompProfile:           (*kube_core_v1.SeccompProfile)(nil),\n\t\tAppArmorProfile:          (*kube_core_v1.AppArmorProfile)(nil),\n\t}, pod.Spec.SecurityContext)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext)\n\n\t// step is not privileged, but security context is requesting privileged\n\tsecCtx = SecurityContext{\n\t\tPrivileged: newBool(true),\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.SecurityContext)\n\tassert.Equal(t, (*kube_core_v1.PodSecurityContext)(nil), pod.Spec.SecurityContext)\n\n\t// step is privileged and security context is requesting privileged\n\tsecCtx = SecurityContext{\n\t\tPrivileged: newBool(true),\n\t}\n\tpod, err = createTestPod(true, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.True(t, *pod.Spec.Containers[0].SecurityContext.Privileged)\n\n\t// step is privileged and no security context is provided\n\tsecCtx = SecurityContext{}\n\tpod, err = createTestPod(true, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.True(t, *pod.Spec.Containers[0].SecurityContext.Privileged)\n\n\t// global runAsNonRoot is true and override is requested value by security context\n\tsecCtx = SecurityContext{\n\t\tRunAsNonRoot: newBool(false),\n\t}\n\tpod, err = createTestPod(false, true, secCtx)\n\tassert.NoError(t, err)\n\tassert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot)\n\n\t// non-privileged step with allowPrivilegeEscalation=false: applied\n\tsecCtx = SecurityContext{\n\t\tAllowPrivilegeEscalation: newBool(false),\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, pod.Spec.Containers[0].SecurityContext)\n\tassert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged)\n\n\t// non-privileged step with allowPrivilegeEscalation=true: ignored\n\tsecCtx = SecurityContext{\n\t\tAllowPrivilegeEscalation: newBool(true),\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext)\n\n\t// privileged step with allowPrivilegeEscalation=true: ignored\n\tsecCtx = SecurityContext{\n\t\tAllowPrivilegeEscalation: newBool(true),\n\t}\n\tpod, err = createTestPod(true, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation)\n\n\t// non-privileged step with capabilities drop: applied\n\tsecCtx = SecurityContext{\n\t\tCapabilities: &Capabilities{Drop: []string{\"ALL\"}},\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, pod.Spec.Containers[0].SecurityContext)\n\tassert.Equal(t, []kube_core_v1.Capability{\"ALL\"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext.Capabilities.Add)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged)\n\n\t// non-privileged step with drop capabilities and allowPrivilegeEscalation=false: both applied\n\tsecCtx = SecurityContext{\n\t\tAllowPrivilegeEscalation: newBool(false),\n\t\tCapabilities:             &Capabilities{Drop: []string{\"ALL\"}},\n\t}\n\tpod, err = createTestPod(false, false, secCtx)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, pod.Spec.Containers[0].SecurityContext)\n\tassert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged)\n\tassert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation)\n\tassert.Equal(t, []kube_core_v1.Capability{\"ALL\"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop)\n}\n\nfunc TestScratchPod(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"curl-google\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"curl-google\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"quay.io/curl/curl\",\n\t\t\t\t\t\"command\": [\n\t\t\t\t\t\t\"/usr/bin/curl\",\n\t\t\t\t\t\t\"-v\",\n\t\t\t\t\t\t\"google.com\"\n\t\t\t\t\t],\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"curl-google\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:       \"curl-google\",\n\t\tImage:      \"quay.io/curl/curl\",\n\t\tUUID:       \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tEntrypoint: []string{\"/usr/bin/curl\", \"-v\", \"google.com\"},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestSecrets(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-3kgk0qj36d2me01he8bebctabr-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"test-secrets\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"test-secrets\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"volumes\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"workspace\",\n\t\t\t\t\t\"persistentVolumeClaim\": {\n\t\t\t\t\t\t\"claimName\": \"workspace\"\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"reg-cred\",\n\t\t\t\t\t\"secret\": {\n\t\t\t\t\t\t\"secretName\": \"reg-cred\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-3kgk0qj36d2me01he8bebctabr-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"envFrom\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"secretRef\": {\n\t\t\t\t\t\t\t\t\"name\": \"ghcr-push-secret\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"env\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"CGO\",\n\t\t\t\t\t\t\t\"value\": \"0\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"AWS_ACCESS_KEY_ID\",\n\t\t\t\t\t\t\t\"valueFrom\": {\n\t\t\t\t\t\t\t\t\"secretKeyRef\": {\n\t\t\t\t\t\t\t\t\t\"name\": \"aws-ecr\",\n\t\t\t\t\t\t\t\t\t\"key\": \"AWS_ACCESS_KEY_ID\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"AWS_SECRET_ACCESS_KEY\",\n\t\t\t\t\t\t\t\"valueFrom\": {\n\t\t\t\t\t\t\t\t\"secretKeyRef\": {\n\t\t\t\t\t\t\t\t\t\"name\": \"aws-ecr\",\n\t\t\t\t\t\t\t\t\t\"key\": \"access-key\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"resources\": {},\n\t\t\t\t\t\"volumeMounts\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"workspace\",\n\t\t\t\t\t\t\t\"mountPath\": \"/woodpecker/src\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"reg-cred\",\n\t\t\t\t\t\t\t\"mountPath\": \"~/.docker/config.json\",\n\t\t\t\t\t\t\t\"subPath\": \".dockerconfigjson\",\n\t\t\t\t\t\t\t\"readOnly\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"test-secrets\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:        \"test-secrets\",\n\t\tImage:       \"alpine\",\n\t\tUUID:        \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tEnvironment: map[string]string{\"CGO\": \"0\"},\n\t\tVolumes:     []string{\"workspace:/woodpecker/src\"},\n\t}, &config{\n\t\tNamespace:                  \"woodpecker\",\n\t\tNativeSecretsAllowFromStep: true,\n\t}, \"wp-3kgk0qj36d2me01he8bebctabr-0\", \"linux/amd64\", BackendOptions{\n\t\tSecrets: []SecretRef{\n\t\t\t{\n\t\t\t\tName: \"ghcr-push-secret\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"aws-ecr\",\n\t\t\t\tKey:  \"AWS_ACCESS_KEY_ID\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:   \"aws-ecr\",\n\t\t\t\tKey:    \"access-key\",\n\t\t\t\tTarget: SecretTarget{Env: \"AWS_SECRET_ACCESS_KEY\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:   \"reg-cred\",\n\t\t\t\tKey:    \".dockerconfigjson\",\n\t\t\t\tTarget: SecretTarget{File: \"~/.docker/config.json\"},\n\t\t\t},\n\t\t},\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestPodTolerations(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"tolerations\": [\n\t\t\t\t{\n\t\t\t\t\t\"key\": \"foo\",\n\t\t\t\t\t\"value\": \"bar\",\n\t\t\t\t\t\"effect\": \"NoSchedule\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"key\": \"baz\",\n\t\t\t\t\t\"value\": \"qux\",\n\t\t\t\t\t\"effect\": \"NoExecute\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"toleration-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tglobalTolerations := []Toleration{\n\t\t{Key: \"foo\", Value: \"bar\", Effect: TaintEffectNoSchedule},\n\t\t{Key: \"baz\", Value: \"qux\", Effect: TaintEffectNoExecute},\n\t}\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:  \"toleration-test\",\n\t\tImage: \"alpine\",\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t}, &config{\n\t\tNamespace:                   \"woodpecker\",\n\t\tPodTolerations:              globalTolerations,\n\t\tPodTolerationsAllowFromStep: false,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestPodTolerationsAllowFromStep(t *testing.T) {\n\tconst expectedDisallow = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"toleration-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\tconst expectedAllow = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"toleration-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"tolerations\": [\n\t\t\t\t{\n\t\t\t\t\t\"key\": \"custom\",\n\t\t\t\t\t\"value\": \"value\",\n\t\t\t\t\t\"effect\": \"NoSchedule\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"toleration-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tstepTolerations := []Toleration{\n\t\t{Key: \"custom\", Value: \"value\", Effect: TaintEffectNoSchedule},\n\t}\n\n\tstep := &types.Step{\n\t\tName:  \"toleration-test\",\n\t\tImage: \"alpine\",\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t}\n\n\tpod, err := mkPod(step, &config{\n\t\tNamespace:                   \"woodpecker\",\n\t\tPodTolerationsAllowFromStep: false,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tTolerations: stepTolerations,\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expectedDisallow)\n\n\tpod, err = mkPod(step, &config{\n\t\tNamespace:                   \"woodpecker\",\n\t\tPodTolerationsAllowFromStep: true,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tTolerations: stepTolerations,\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err = json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja = jsonassert.New(t)\n\tja.Assertf(string(podJSON), expectedAllow)\n}\n\nfunc TestStepSecret(t *testing.T) {\n\tconst expected = `{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0-step-secret\",\n\t\t\t\"namespace\": \"woodpecker\"\n\t\t},\n\t\t\"type\": \"Opaque\",\n\t\t\"stringData\": {\n\t\t\t\"VERY_SECRET\": \"secret_value\"\n\t\t}\n\t}`\n\n\tsecret, err := mkStepSecret(&types.Step{\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tName:  \"go-test\",\n\t\tImage: \"meltwater/drone-cache\",\n\t\tSecretMapping: map[string]string{\n\t\t\t\"VERY_SECRET\": \"secret_value\",\n\t\t},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t})\n\tassert.NoError(t, err)\n\n\tsecretJSON, err := json.Marshal(secret)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(secretJSON), expected)\n}\n\nfunc TestPodAffinity(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"affinity\": {\n\t\t\t\t\"podAffinity\": {\n\t\t\t\t\t\"requiredDuringSchedulingIgnoredDuringExecution\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"labelSelector\": {},\n\t\t\t\t\t\t\t\"matchLabelKeys\": [\"woodpecker-ci.org/task-uuid\"],\n\t\t\t\t\t\t\t\"topologyKey\": \"kubernetes.io/hostname\"\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"affinity-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tagentAffinity := &kube_core_v1.Affinity{\n\t\tPodAffinity: &kube_core_v1.PodAffinity{\n\t\t\tRequiredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.PodAffinityTerm{\n\t\t\t\t{\n\t\t\t\t\tLabelSelector:  &kube_meta_v1.LabelSelector{},\n\t\t\t\t\tMatchLabelKeys: []string{\"woodpecker-ci.org/task-uuid\"},\n\t\t\t\t\tTopologyKey:    \"kubernetes.io/hostname\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:  \"affinity-test\",\n\t\tImage: \"alpine\",\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t}, &config{\n\t\tNamespace:                \"woodpecker\",\n\t\tPodAffinity:              agentAffinity,\n\t\tPodAffinityAllowFromStep: false,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestPodAffinityAllowFromStep(t *testing.T) {\n\tconst expectedDisallow = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"affinity-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\tconst expectedAllow = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"affinity\": {\n\t\t\t\t\"podAntiAffinity\": {\n\t\t\t\t\t\"preferredDuringSchedulingIgnoredDuringExecution\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"weight\": 100,\n\t\t\t\t\t\t\t\"podAffinityTerm\": {\n\t\t\t\t\t\t\t\t\"labelSelector\": {\n\t\t\t\t\t\t\t\t\t\"matchLabels\": {\n\t\t\t\t\t\t\t\t\t\t\"app\": \"woodpecker\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"topologyKey\": \"kubernetes.io/hostname\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"affinity-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tstepAffinity := &kube_core_v1.Affinity{\n\t\tPodAntiAffinity: &kube_core_v1.PodAntiAffinity{\n\t\t\tPreferredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.WeightedPodAffinityTerm{\n\t\t\t\t{\n\t\t\t\t\tWeight: 100,\n\t\t\t\t\tPodAffinityTerm: kube_core_v1.PodAffinityTerm{\n\t\t\t\t\t\tLabelSelector: &kube_meta_v1.LabelSelector{\n\t\t\t\t\t\t\tMatchLabels: map[string]string{\n\t\t\t\t\t\t\t\t\"app\": \"woodpecker\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tTopologyKey: \"kubernetes.io/hostname\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tstep := &types.Step{\n\t\tName:  \"affinity-test\",\n\t\tImage: \"alpine\",\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t}\n\n\tpod, err := mkPod(step, &config{\n\t\tNamespace:                \"woodpecker\",\n\t\tPodAffinity:              nil,\n\t\tPodAffinityAllowFromStep: false,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tAffinity: stepAffinity,\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expectedDisallow)\n\n\tpod, err = mkPod(step, &config{\n\t\tNamespace:                \"woodpecker\",\n\t\tPodAffinity:              nil,\n\t\tPodAffinityAllowFromStep: true,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tAffinity: stepAffinity,\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err = json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja = jsonassert.New(t)\n\tja.Assertf(string(podJSON), expectedAllow)\n}\n\nfunc TestPodAffinityStepOverridesAgent(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"affinity-test\",\n\t\t\t\t\"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t\t}\n\t\t},\n\t\t\"spec\": {\n\t\t\t\"containers\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\t\t\"image\": \"alpine\",\n\t\t\t\t\t\"resources\": {}\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"restartPolicy\": \"Never\",\n\t\t\t\"affinity\": {\n\t\t\t\t\"nodeAffinity\": {\n\t\t\t\t\t\"requiredDuringSchedulingIgnoredDuringExecution\": {\n\t\t\t\t\t\t\"nodeSelectorTerms\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"matchExpressions\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"disk-type\",\n\t\t\t\t\t\t\t\t\t\t\"operator\": \"In\",\n\t\t\t\t\t\t\t\t\t\t\"values\": [\"ssd\"]\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"dnsConfig\": {\n\t\t\t\t\"searches\": [\"wp-hsvc-11301.woodpecker.svc.cluster.local\"]\n\t\t\t},\n\t\t\t\"subdomain\": \"wp-hsvc-11301\",\n\t\t\t\"hostname\": \"affinity-test\"\n\t\t},\n\t\t\"status\": {}\n\t}`\n\n\tagentAffinity := &kube_core_v1.Affinity{\n\t\tNodeAffinity: &kube_core_v1.NodeAffinity{\n\t\t\tRequiredDuringSchedulingIgnoredDuringExecution: &kube_core_v1.NodeSelector{\n\t\t\t\tNodeSelectorTerms: []kube_core_v1.NodeSelectorTerm{\n\t\t\t\t\t{\n\t\t\t\t\t\tMatchExpressions: []kube_core_v1.NodeSelectorRequirement{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tKey:      \"topology.kubernetes.io/zone\",\n\t\t\t\t\t\t\t\tOperator: kube_core_v1.NodeSelectorOpIn,\n\t\t\t\t\t\t\t\tValues:   []string{\"eu-central-1a\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tstepAffinity := &kube_core_v1.Affinity{\n\t\tNodeAffinity: &kube_core_v1.NodeAffinity{\n\t\t\tRequiredDuringSchedulingIgnoredDuringExecution: &kube_core_v1.NodeSelector{\n\t\t\t\tNodeSelectorTerms: []kube_core_v1.NodeSelectorTerm{\n\t\t\t\t\t{\n\t\t\t\t\t\tMatchExpressions: []kube_core_v1.NodeSelectorRequirement{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tKey:      \"disk-type\",\n\t\t\t\t\t\t\t\tOperator: kube_core_v1.NodeSelectorOpIn,\n\t\t\t\t\t\t\t\tValues:   []string{\"ssd\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:  \"affinity-test\",\n\t\tImage: \"alpine\",\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t}, &config{\n\t\tNamespace:                \"woodpecker\",\n\t\tPodAffinity:              agentAffinity,\n\t\tPodAffinityAllowFromStep: true,\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tAffinity: stepAffinity,\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tpodJSON, err := json.Marshal(pod)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestInitContainer(t *testing.T) {\n\tconst expected = `\n\t{\n\t\t\"name\": \"init-wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\"image\": \"busybox:stable-musl\",\n\t\t\"imagePullPolicy\": \"Always\",\n\t\t\"args\": [\n\t\t\t\"mkdir\",\n\t\t\t\"-p\",\n\t\t\t\"/woodpecker/src/github.com/woodpecker-ci/woodpecker\"\n\t\t],\n\t\t\"resources\": {\n\t\t\t\"requests\": {\n\t\t\t\t\"cpu\": \"5m\",\n\t\t\t\t\"memory\": \"5Mi\"\n\t\t\t},\n\t\t\t\"limits\": {\n\t\t\t\t\"cpu\": \"5m\",\n\t\t\t\t\"memory\": \"5Mi\"\n\t\t\t}\n\t\t},\n\t\t\"securityContext\": {\n\t\t\t\"allowPrivilegeEscalation\": false,\n\t\t\t\"capabilities\": {\"drop\": [\"ALL\"]}\n\t\t},\n\t\t\"volumeMounts\": [\n\t\t\t{\n\t\t\t\t\"name\": \"workspace\",\n\t\t\t\t\"mountPath\": \"/woodpecker/src\"\n\t\t\t}\n\t\t]\n\t}`\n\n\tpod, err := mkPod(&types.Step{\n\t\tName:       \"clone\",\n\t\tImage:      \"docker.io/woodpeckerci/plugin-git\",\n\t\tUUID:       \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tWorkingDir: \"/woodpecker/src/github.com/woodpecker-ci/woodpecker\",\n\t\tVolumes:    []string{\"workspace:/woodpecker/src\", \"other:/other\"},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", BackendOptions{\n\t\tSecurityContext: &SecurityContext{\n\t\t\tRunAsNonRoot: newBool(true),\n\t\t\tRunAsUser:    newInt64(1000),\n\t\t},\n\t}, taskUUID)\n\tassert.NoError(t, err)\n\n\tassert.NotNil(t, pod.Spec.InitContainers)\n\tassert.NotEmpty(t, pod.Spec.InitContainers)\n\tassert.Len(t, pod.Spec.InitContainers, 1)\n\tpodJSON, err := json.Marshal(pod.Spec.InitContainers[0])\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(podJSON), expected)\n}\n\nfunc TestUnrequiredInitContainer(t *testing.T) {\n\tcreateTestPod := func(workingDir string, backendOpts BackendOptions) (*kube_core_v1.Pod, error) {\n\t\treturn mkPod(&types.Step{\n\t\t\tName:       \"init-container-test\",\n\t\t\tImage:      \"docker.io/woodpeckerci/plugin-git\",\n\t\t\tUUID:       \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\tWorkingDir: workingDir,\n\t\t\tVolumes:    []string{\"workspace:/woodpecker/src\"},\n\t\t}, &config{\n\t\t\tNamespace: \"woodpecker\",\n\t\t}, \"wp-01he8bebctabr3kgk0qj36d2me-0\", \"linux/amd64\", backendOpts, taskUUID)\n\t}\n\t// no security context (pod running as root), does not need init container\n\tpod, err := createTestPod(\"/woodpecker/src/github.com/woodpecker-ci/woodpecker\", BackendOptions{})\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.InitContainers)\n\n\t// explicit security context requesting root, does not need init container\n\tpod, err = createTestPod(\"/woodpecker/src/github.com/woodpecker-ci/woodpecker\", BackendOptions{\n\t\tSecurityContext: &SecurityContext{\n\t\t\tRunAsNonRoot: newBool(false),\n\t\t\tRunAsUser:    newInt64(0),\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.InitContainers)\n\n\t// working dir is outside of the workspace volume, does not need init container\n\tpod, err = createTestPod(\"/tmp\", BackendOptions{\n\t\tSecurityContext: &SecurityContext{\n\t\t\tRunAsNonRoot: newBool(true),\n\t\t\tRunAsUser:    newInt64(1000),\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.InitContainers)\n\n\t// workingDir is exactly the same as the workspace volume mount path, does not need init container\n\tpod, err = createTestPod(\"/woodpecker/src\", BackendOptions{\n\t\tSecurityContext: &SecurityContext{\n\t\t\tRunAsNonRoot: newBool(true),\n\t\t\tRunAsUser:    newInt64(1000),\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\tassert.Nil(t, pod.Spec.InitContainers)\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/secrets.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/distribution/reference\"\n\t\"github.com/docker/cli/cli/config/configfile\"\n\tdocker_config_types \"github.com/docker/cli/cli/config/types\"\n\t\"github.com/rs/zerolog/log\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils\"\n)\n\ntype nativeSecretsProcessor struct {\n\tconfig         *config\n\tsecrets        []SecretRef\n\tenvFromSources []kube_core_v1.EnvFromSource\n\tenvVars        []kube_core_v1.EnvVar\n\tvolumes        []kube_core_v1.Volume\n\tmounts         []kube_core_v1.VolumeMount\n}\n\nfunc newNativeSecretsProcessor(config *config, secrets []SecretRef) nativeSecretsProcessor {\n\treturn nativeSecretsProcessor{\n\t\tconfig:  config,\n\t\tsecrets: secrets,\n\t}\n}\n\nfunc (nsp *nativeSecretsProcessor) isEnabled() bool {\n\treturn nsp.config.NativeSecretsAllowFromStep\n}\n\nfunc (nsp *nativeSecretsProcessor) process() error {\n\tif len(nsp.secrets) > 0 {\n\t\tif !nsp.isEnabled() {\n\t\t\tlog.Debug().Msg(\"Secret names were defined in backend options, but secret access is disallowed by instance configuration.\")\n\t\t\treturn nil\n\t\t}\n\t} else {\n\t\treturn nil\n\t}\n\n\tfor _, secret := range nsp.secrets {\n\t\tswitch {\n\t\tcase secret.isSimple():\n\t\t\tsimpleSecret, err := secret.toEnvFromSource()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnsp.envFromSources = append(nsp.envFromSources, simpleSecret)\n\t\tcase secret.isAdvanced():\n\t\t\tadvancedSecret, err := secret.toEnvVar()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnsp.envVars = append(nsp.envVars, advancedSecret)\n\t\tcase secret.isFile():\n\t\t\tvolume, err := secret.toVolume()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnsp.volumes = append(nsp.volumes, volume)\n\n\t\t\tmount, err := secret.toVolumeMount()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnsp.mounts = append(nsp.mounts, mount)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (sr SecretRef) isSimple() bool {\n\treturn len(sr.Key) == 0 && len(sr.Target.Env) == 0 && !sr.isFile()\n}\n\nfunc (sr SecretRef) isAdvanced() bool {\n\treturn (len(sr.Key) > 0 || len(sr.Target.Env) > 0) && !sr.isFile()\n}\n\nfunc (sr SecretRef) isFile() bool {\n\treturn len(sr.Target.File) > 0\n}\n\nfunc (sr SecretRef) toEnvFromSource() (kube_core_v1.EnvFromSource, error) {\n\tenv := kube_core_v1.EnvFromSource{}\n\n\tif !sr.isSimple() {\n\t\treturn env, fmt.Errorf(\"secret '%s' is not simple reference\", sr.Name)\n\t}\n\n\tenv = kube_core_v1.EnvFromSource{\n\t\tSecretRef: &kube_core_v1.SecretEnvSource{\n\t\t\tLocalObjectReference: secretReference(sr.Name),\n\t\t},\n\t}\n\n\treturn env, nil\n}\n\nfunc (sr SecretRef) toEnvVar() (kube_core_v1.EnvVar, error) {\n\tenvVar := kube_core_v1.EnvVar{}\n\n\tif !sr.isAdvanced() {\n\t\treturn envVar, fmt.Errorf(\"secret '%s' is not advanced reference\", sr.Name)\n\t}\n\n\tenvVar.ValueFrom = &kube_core_v1.EnvVarSource{\n\t\tSecretKeyRef: &kube_core_v1.SecretKeySelector{\n\t\t\tLocalObjectReference: secretReference(sr.Name),\n\t\t\tKey:                  sr.Key,\n\t\t},\n\t}\n\n\tif len(sr.Target.Env) > 0 {\n\t\tenvVar.Name = sr.Target.Env\n\t} else {\n\t\tenvVar.Name = strings.ToUpper(sr.Key)\n\t}\n\n\treturn envVar, nil\n}\n\nfunc (sr SecretRef) toVolume() (kube_core_v1.Volume, error) {\n\tvar err error\n\tvolume := kube_core_v1.Volume{}\n\n\tif !sr.isFile() {\n\t\treturn volume, fmt.Errorf(\"secret '%s' is not file reference\", sr.Name)\n\t}\n\n\tvolume.Name, err = volumeName(sr.Name)\n\tif err != nil {\n\t\treturn volume, err\n\t}\n\n\tvolume.Secret = &kube_core_v1.SecretVolumeSource{\n\t\tSecretName: sr.Name,\n\t}\n\n\treturn volume, nil\n}\n\nfunc (sr SecretRef) toVolumeMount() (kube_core_v1.VolumeMount, error) {\n\tvar err error\n\tmount := kube_core_v1.VolumeMount{\n\t\tReadOnly: true,\n\t}\n\n\tif !sr.isFile() {\n\t\treturn mount, fmt.Errorf(\"secret '%s' is not file reference\", sr.Name)\n\t}\n\n\tmount.Name, err = volumeName(sr.Name)\n\tif err != nil {\n\t\treturn mount, err\n\t}\n\n\tmount.MountPath = sr.Target.File\n\tmount.SubPath = sr.Key\n\n\treturn mount, nil\n}\n\nfunc secretsReferences(names []string) []kube_core_v1.LocalObjectReference {\n\tsecretReferences := make([]kube_core_v1.LocalObjectReference, len(names))\n\tfor i, imagePullSecretName := range names {\n\t\tsecretReferences[i] = secretReference(imagePullSecretName)\n\t}\n\treturn secretReferences\n}\n\nfunc secretReference(name string) kube_core_v1.LocalObjectReference {\n\treturn kube_core_v1.LocalObjectReference{\n\t\tName: name,\n\t}\n}\n\nfunc needsRegistrySecret(step *types.Step) bool {\n\treturn step.AuthConfig.Username != \"\" && step.AuthConfig.Password != \"\"\n}\n\nfunc mkRegistrySecret(step *types.Step, config *config) (*kube_core_v1.Secret, error) {\n\tname, err := registrySecretName(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlabels, err := registrySecretLabels(step, config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnamed, err := utils.ParseNamed(step.Image)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tauthConfig := configfile.ConfigFile{\n\t\tAuthConfigs: map[string]docker_config_types.AuthConfig{\n\t\t\treference.Domain(named): {\n\t\t\t\tUsername: step.AuthConfig.Username,\n\t\t\t\tPassword: step.AuthConfig.Password,\n\t\t\t},\n\t\t},\n\t}\n\n\tconfigFileJSON, err := json.Marshal(authConfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &kube_core_v1.Secret{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{\n\t\t\tNamespace: config.GetNamespace(step.OrgID),\n\t\t\tName:      name,\n\t\t\tLabels:    labels,\n\t\t},\n\t\tType: kube_core_v1.SecretTypeDockerConfigJson,\n\t\tData: map[string][]byte{\n\t\t\tkube_core_v1.DockerConfigJsonKey: configFileJSON,\n\t\t},\n\t}, nil\n}\n\nfunc registrySecretName(step *types.Step) (string, error) {\n\treturn podName(step)\n}\n\nfunc registrySecretLabels(step *types.Step, config *config) (map[string]string, error) {\n\tvar err error\n\tlabels := make(map[string]string)\n\n\tfor k, v := range step.WorkflowLabels {\n\t\t// Only copy user labels if allowed by agent config.\n\t\t// Internal labels are filtered on the server-side.\n\t\tif config.PodLabelsAllowFromStep || strings.HasPrefix(k, pipeline.InternalLabelPrefix) {\n\t\t\tlabels[k], err = toDNSName(v)\n\t\t\tif err != nil {\n\t\t\t\treturn labels, err\n\t\t\t}\n\t\t}\n\t}\n\n\tif step.Type == types.StepTypeService {\n\t\tlabels[ServiceLabel], _ = serviceName(step)\n\t}\n\tlabels[StepLabelLegacy], err = stepLabel(step)\n\tif err != nil {\n\t\treturn labels, err\n\t}\n\tlabels[StepLabel], err = stepLabel(step)\n\tif err != nil {\n\t\treturn labels, err\n\t}\n\n\treturn labels, nil\n}\n\nfunc startRegistrySecret(ctx context.Context, engine *kube, step *types.Step) error {\n\tsecret, err := mkRegistrySecret(step, engine.config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Trace().Msgf(\"creating secret: %s\", secret.Name)\n\t_, err = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Create(ctx, secret, kube_meta_v1.CreateOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc stopRegistrySecret(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error {\n\tname, err := registrySecretName(step)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Trace().Str(\"name\", name).Msg(\"deleting secret\")\n\n\terr = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Delete(ctx, name, deleteOpts)\n\tif errors.IsNotFound(err) {\n\t\treturn nil\n\t}\n\treturn err\n}\n\nfunc needsStepSecret(step *types.Step) bool {\n\treturn len(step.SecretMapping) > 0\n}\n\nfunc startStepSecret(ctx context.Context, e *kube, step *types.Step) error {\n\tsecret, err := mkStepSecret(step, e.config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Trace().Msgf(\"creating secret: %s\", secret.Name)\n\t_, err = e.client.CoreV1().Secrets(e.config.GetNamespace(step.OrgID)).Create(ctx, secret, kube_meta_v1.CreateOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc mkStepSecret(step *types.Step, config *config) (*kube_core_v1.Secret, error) {\n\tname, err := stepSecretName(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &kube_core_v1.Secret{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{\n\t\t\tNamespace: config.GetNamespace(step.OrgID),\n\t\t\tName:      name,\n\t\t},\n\t\tType:       kube_core_v1.SecretTypeOpaque,\n\t\tStringData: step.SecretMapping,\n\t}, nil\n}\n\nfunc stepSecretName(step *types.Step) (string, error) {\n\tname, err := stepToPodName(step)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn fmt.Sprintf(\"%s-step-secret\", name), nil\n}\n\nfunc stopStepSecret(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error {\n\tname, err := stepSecretName(step)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Trace().Str(\"name\", name).Msg(\"deleting secret\")\n\n\terr = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Delete(ctx, name, deleteOpts)\n\tif errors.IsNotFound(err) {\n\t\treturn nil\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/secrets_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/kinbiko/jsonassert\"\n\t\"github.com/stretchr/testify/assert\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestNativeSecretsEnabled(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: true,\n\t}, nil)\n\tassert.True(t, nsp.isEnabled())\n}\n\nfunc TestNativeSecretsDisabled(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: false,\n\t}, []SecretRef{\n\t\t{\n\t\t\tName: \"env-simple\",\n\t\t},\n\t\t{\n\t\t\tName: \"env-advanced\",\n\t\t\tKey:  \"key\",\n\t\t\tTarget: SecretTarget{\n\t\t\t\tEnv: \"ENV_VAR\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"env-file\",\n\t\t\tKey:  \"cert\",\n\t\t\tTarget: SecretTarget{\n\t\t\t\tFile: \"/etc/ca/x3.cert\",\n\t\t\t},\n\t\t},\n\t})\n\tassert.False(t, nsp.isEnabled())\n\n\terr := nsp.process()\n\tassert.NoError(t, err)\n\tassert.Empty(t, nsp.envFromSources)\n\tassert.Empty(t, nsp.envVars)\n\tassert.Empty(t, nsp.volumes)\n\tassert.Empty(t, nsp.mounts)\n}\n\nfunc TestSimpleSecret(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: true,\n\t}, []SecretRef{\n\t\t{\n\t\t\tName: \"test-secret\",\n\t\t},\n\t})\n\n\terr := nsp.process()\n\tassert.NoError(t, err)\n\tassert.Empty(t, nsp.envVars)\n\tassert.Empty(t, nsp.volumes)\n\tassert.Empty(t, nsp.mounts)\n\tassert.Equal(t, []kube_core_v1.EnvFromSource{\n\t\t{\n\t\t\tSecretRef: &kube_core_v1.SecretEnvSource{\n\t\t\t\tLocalObjectReference: kube_core_v1.LocalObjectReference{Name: \"test-secret\"},\n\t\t\t},\n\t\t},\n\t}, nsp.envFromSources)\n}\n\nfunc TestSecretWithKey(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: true,\n\t}, []SecretRef{\n\t\t{\n\t\t\tName: \"test-secret\",\n\t\t\tKey:  \"access_key\",\n\t\t},\n\t})\n\n\terr := nsp.process()\n\tassert.NoError(t, err)\n\tassert.Empty(t, nsp.envFromSources)\n\tassert.Empty(t, nsp.volumes)\n\tassert.Empty(t, nsp.mounts)\n\tassert.Equal(t, []kube_core_v1.EnvVar{\n\t\t{\n\t\t\tName: \"ACCESS_KEY\",\n\t\t\tValueFrom: &kube_core_v1.EnvVarSource{\n\t\t\t\tSecretKeyRef: &kube_core_v1.SecretKeySelector{\n\t\t\t\t\tLocalObjectReference: kube_core_v1.LocalObjectReference{Name: \"test-secret\"},\n\t\t\t\t\tKey:                  \"access_key\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}, nsp.envVars)\n}\n\nfunc TestSecretWithKeyMapping(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: true,\n\t}, []SecretRef{\n\t\t{\n\t\t\tName: \"test-secret\",\n\t\t\tKey:  \"aws-secret\",\n\t\t\tTarget: SecretTarget{\n\t\t\t\tEnv: \"AWS_SECRET_ACCESS_KEY\",\n\t\t\t},\n\t\t},\n\t})\n\n\terr := nsp.process()\n\tassert.NoError(t, err)\n\tassert.Empty(t, nsp.envFromSources)\n\tassert.Empty(t, nsp.volumes)\n\tassert.Empty(t, nsp.mounts)\n\tassert.Equal(t, []kube_core_v1.EnvVar{\n\t\t{\n\t\t\tName: \"AWS_SECRET_ACCESS_KEY\",\n\t\t\tValueFrom: &kube_core_v1.EnvVarSource{\n\t\t\t\tSecretKeyRef: &kube_core_v1.SecretKeySelector{\n\t\t\t\t\tLocalObjectReference: kube_core_v1.LocalObjectReference{Name: \"test-secret\"},\n\t\t\t\t\tKey:                  \"aws-secret\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}, nsp.envVars)\n}\n\nfunc TestFileSecret(t *testing.T) {\n\tnsp := newNativeSecretsProcessor(&config{\n\t\tNativeSecretsAllowFromStep: true,\n\t}, []SecretRef{\n\t\t{\n\t\t\tName: \"reg-cred\",\n\t\t\tKey:  \".dockerconfigjson\",\n\t\t\tTarget: SecretTarget{\n\t\t\t\tFile: \"~/.docker/config.json\",\n\t\t\t},\n\t\t},\n\t})\n\n\terr := nsp.process()\n\tassert.NoError(t, err)\n\tassert.Empty(t, nsp.envFromSources)\n\tassert.Empty(t, nsp.envVars)\n\tassert.Equal(t, []kube_core_v1.Volume{\n\t\t{\n\t\t\tName: \"reg-cred\",\n\t\t\tVolumeSource: kube_core_v1.VolumeSource{\n\t\t\t\tSecret: &kube_core_v1.SecretVolumeSource{\n\t\t\t\t\tSecretName: \"reg-cred\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}, nsp.volumes)\n\tassert.Equal(t, []kube_core_v1.VolumeMount{\n\t\t{\n\t\t\tName:      \"reg-cred\",\n\t\t\tReadOnly:  true,\n\t\t\tMountPath: \"~/.docker/config.json\",\n\t\t\tSubPath:   \".dockerconfigjson\",\n\t\t},\n\t}, nsp.mounts)\n}\n\nfunc TestNoAuthNoSecret(t *testing.T) {\n\tassert.False(t, needsRegistrySecret(&types.Step{}))\n}\n\nfunc TestNoPasswordNoSecret(t *testing.T) {\n\tassert.False(t, needsRegistrySecret(&types.Step{\n\t\tAuthConfig: types.Auth{Username: \"foo\"},\n\t}))\n}\n\nfunc TestNoUsernameNoSecret(t *testing.T) {\n\tassert.False(t, needsRegistrySecret(&types.Step{\n\t\tAuthConfig: types.Auth{Password: \"foo\"},\n\t}))\n}\n\nfunc TestUsernameAndPasswordNeedsSecret(t *testing.T) {\n\tassert.True(t, needsRegistrySecret(&types.Step{\n\t\tAuthConfig: types.Auth{Username: \"foo\", Password: \"bar\"},\n\t}))\n}\n\nfunc TestRegistrySecret(t *testing.T) {\n\tconst expected = `{\n\t\t\"metadata\": {\n\t\t\t\"name\": \"wp-01he8bebctabr3kgk0qj36d2me-0\",\n\t\t\t\"namespace\": \"woodpecker\",\n\t\t\t\"labels\": {\n\t\t\t\t\"step\": \"go-test\",\n\t\t\t\t\"woodpecker-ci.org/step\": \"go-test\"\n\t\t\t}\n\t\t},\n\t\t\"type\": \"kubernetes.io/dockerconfigjson\",\n\t\t\"data\": {\n\t\t\t\".dockerconfigjson\": \"eyJhdXRocyI6eyJkb2NrZXIuaW8iOnsidXNlcm5hbWUiOiJmb28iLCJwYXNzd29yZCI6ImJhciJ9fX0=\"\n\t\t}\n\t}`\n\n\tsecret, err := mkRegistrySecret(&types.Step{\n\t\tUUID:  \"01he8bebctabr3kgk0qj36d2me-0\",\n\t\tName:  \"go-test\",\n\t\tImage: \"meltwater/drone-cache\",\n\t\tAuthConfig: types.Auth{\n\t\t\tUsername: \"foo\",\n\t\t\tPassword: \"bar\",\n\t\t},\n\t}, &config{\n\t\tNamespace: \"woodpecker\",\n\t})\n\tassert.NoError(t, err)\n\n\tsecretJSON, err := json.Marshal(secret)\n\tassert.NoError(t, err)\n\n\tja := jsonassert.New(t)\n\tja.Assertf(string(secretJSON), expected)\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/service.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nconst (\n\tServiceLabel          = \"service\"\n\tHeadlessServicePrefix = \"wp-hsvc-\"\n\tServicePrefix         = \"wp-svc-\"\n)\n\nfunc mkHeadlessService(namespace, taskUUID string) (*kube_core_v1.Service, error) {\n\tselector := map[string]string{\n\t\tTaskUUIDLabel: taskUUID,\n\t}\n\n\tname, err := subdomain(taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Str(\"name\", name).Interface(\"selector\", selector).Msg(\"creating headless service\")\n\treturn &kube_core_v1.Service{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{\n\t\t\tName:      name,\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: kube_core_v1.ServiceSpec{\n\t\t\tType:      kube_core_v1.ServiceTypeClusterIP,\n\t\t\tClusterIP: \"None\",\n\t\t\tSelector:  selector,\n\t\t},\n\t}, nil\n}\n\nfunc serviceName(step *types.Step) (string, error) {\n\treturn dnsName(ServicePrefix + step.UUID + \"-\" + step.Name)\n}\n\nfunc isService(step *types.Step) bool {\n\treturn step.Type == types.StepTypeService\n}\n\nfunc subdomain(taskUUID string) (string, error) {\n\treturn dnsName(HeadlessServicePrefix + taskUUID)\n}\n\nfunc startHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) (*kube_core_v1.Service, error) {\n\tsvc, err := mkHeadlessService(namespace, taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Str(\"name\", svc.Name).Interface(\"selector\", svc.Spec.Selector).Msg(\"creating headless service\")\n\treturn engine.client.CoreV1().Services(namespace).Create(ctx, svc, kube_meta_v1.CreateOptions{})\n}\n\nfunc (e *kube) stopHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) error {\n\tname, err := subdomain(taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Trace().Str(\"name\", name).Msg(\"deleting headless service\")\n\n\terr = engine.client.CoreV1().Services(namespace).Delete(ctx, name, e.config.newDefaultDeleteOptions())\n\tif errors.IsNotFound(err) {\n\t\t// Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps.\n\t\tlog.Trace().Err(err).Msgf(\"unable to delete headless service %s\", name)\n\t\treturn nil\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/service_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestServiceName(t *testing.T) {\n\tname, err := serviceName(&types.Step{Name: \"database\", UUID: \"01he8bebctabr3kgk0qj36d2me\"})\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-svc-01he8bebctabr3kgk0qj36d2me-database\", name)\n\n\tname, err = serviceName(&types.Step{Name: \"wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local\", UUID: \"01he8bebctabr3kgk0qj36d2me\"})\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-svc-01he8bebctabr3kgk0qj36d2me-wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local\", name)\n\n\tname, err = serviceName(&types.Step{Name: \"awesome_service\", UUID: \"01he8bebctabr3kgk0qj36d2me\"})\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-svc-01he8bebctabr3kgk0qj36d2me-awesome-service\", name)\n}\n\nfunc TestHeadlessService(t *testing.T) {\n\texpected := `\n\t{\n\t  \"metadata\": {\n\t\t\"name\": \"wp-hsvc-11301\",\n\t\t\"namespace\": \"foo\"\n\t  },\n\t  \"spec\": {\n\t\t\"selector\": {\n\t\t  \"woodpecker-ci.org/task-uuid\": \"11301\"\n\t\t},\n\t\t\"clusterIP\": \"None\",\n\t\t\"type\": \"ClusterIP\"\n\t  },\n\t  \"status\": {\n\t\t\"loadBalancer\": {}\n\t  }\n\t}`\n\n\ts, err := mkHeadlessService(\"foo\", \"11301\")\n\tassert.NoError(t, err, \"expected no error when creating headless service\")\n\tj, err := json.Marshal(s)\n\tassert.NoError(t, err, \"expected no error when marshaling headless service to JSON\")\n\tassert.JSONEq(t, expected, string(j), \"expected headless service JSON to match\")\n}\n\nfunc TestInvalidHeadlessService(t *testing.T) {\n\t_, err := mkHeadlessService(\"foo\", \"invalid_task_uuid!\")\n\tassert.Error(t, err, \"expected error due to invalid task UUID\")\n}\n\nfunc TestStartHeadlessService(t *testing.T) {\n\tt.Run(\"successfully creates headless service\", func(t *testing.T) {\n\t\tengine := &kube{\n\t\t\tclient: fake.NewClientset(),\n\t\t\tconfig: &config{Namespace: \"test-namespace\"},\n\t\t}\n\n\t\tsvc, err := startHeadlessService(t.Context(), engine, \"foo\", \"11301\")\n\t\tassert.NoError(t, err, \"expected no error when starting headless service\")\n\n\t\tassert.NotNil(t, svc, \"expected headless service to be created\")\n\t\tassert.Equal(t, \"wp-hsvc-11301\", svc.Name, \"expected headless service name to match\")\n\t\tassert.Equal(t, \"foo\", svc.Namespace, \"expected headless service namespace to match\")\n\t\tassert.Equal(t, kube_core_v1.ServiceTypeClusterIP, svc.Spec.Type, \"expected headless service type to be ClusterIP\")\n\t\tassert.Equal(t, \"None\", svc.Spec.ClusterIP, \"expected headless service ClusterIP to be 'None'\")\n\t\tassert.Equal(t, map[string]string{TaskUUIDLabel: \"11301\"}, svc.Spec.Selector)\n\n\t\tcreatedSvc, err := engine.client.CoreV1().Services(\"foo\").Get(t.Context(), \"wp-hsvc-11301\", kube_meta_v1.GetOptions{})\n\t\tassert.NoError(t, err, \"expected no error when getting the created service\")\n\t\tassert.Equal(t, svc.Name, createdSvc.Name, \"expected created service name to match\")\n\t})\n\n\tt.Run(\"error on invalid task UUID resulting in invalid domain-name\", func(t *testing.T) {\n\t\tengine := &kube{\n\t\t\tclient: fake.NewClientset(),\n\t\t\tconfig: &config{Namespace: \"test-namespace\"},\n\t\t}\n\n\t\t_, err := startHeadlessService(t.Context(), engine, \"test-namespace\", \"invalid_task_uuid!\")\n\t\tassert.Error(t, err, \"expected error due to invalid task UUID\")\n\t})\n}\n\nfunc TestStopHeadlessService(t *testing.T) {\n\tt.Run(\"successfully deletes headless service\", func(t *testing.T) {\n\t\tengine := &kube{\n\t\t\tclient: fake.NewClientset(),\n\t\t\tconfig: &config{Namespace: \"test-namespace\"},\n\t\t}\n\n\t\t// arrage\n\t\t_, err := startHeadlessService(t.Context(), engine, \"foo\", \"11301\")\n\t\tassert.NoError(t, err, \"expected no error when starting headless service\")\n\n\t\t_, err = engine.client.CoreV1().Services(\"foo\").Get(t.Context(), \"wp-hsvc-11301\", kube_meta_v1.GetOptions{})\n\t\tassert.NoError(t, err, \"expected no error when getting the created service\")\n\n\t\t// act\n\t\terr = engine.stopHeadlessService(t.Context(), engine, \"foo\", \"11301\")\n\t\tassert.NoError(t, err, \"expected no error when deleting headless service\")\n\n\t\t// assert\n\t\t_, err = engine.client.CoreV1().Services(\"foo\").Get(t.Context(), \"wp-hsvc-11301\", kube_meta_v1.GetOptions{})\n\t\tassert.Error(t, err, \"expected error when getting a deleted service\")\n\t\tassert.True(t, err != nil, \"expected error to be non-nil\")\n\t})\n\n\tt.Run(\"handles non-existent service gracefully\", func(t *testing.T) {\n\t\tengine := &kube{\n\t\t\tclient: fake.NewClientset(),\n\t\t\tconfig: &config{Namespace: \"test-namespace\"},\n\t\t}\n\n\t\terr := engine.stopHeadlessService(t.Context(), engine, \"foo\", \"nonexistent\")\n\t\tassert.NoError(t, err, \"expected no error when deleting a non-existent service\")\n\t})\n\n\tt.Run(\"error on invalid task UUID resulting in invalid domain-name\", func(t *testing.T) {\n\t\tengine := &kube{\n\t\t\tclient: fake.NewClientset(),\n\t\t\tconfig: &config{Namespace: \"test-namespace\"},\n\t\t}\n\n\t\terr := engine.stopHeadlessService(t.Context(), engine, \"test-namespace\", \"invalid_task_uuid!\")\n\t\tassert.Error(t, err, \"expected error due to invalid task UUID\")\n\t})\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/utils.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/rest\"\n\tkube_client_cmd \"k8s.io/client-go/tools/clientcmd\"\n)\n\nconst maxDNSLabelLen = 63\n\nvar (\n\tdnsPattern = regexp.MustCompile(`^[a-z0-9]` + // must start with\n\t\t`([-a-z0-9]*[a-z0-9])?` + // inside can als contain -\n\t\t`(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`, // allow the same pattern as before with dots in between but only one dot\n\t)\n\tdnsLabelPattern         = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)\n\tdnsDisallowedCharacters = regexp.MustCompile(`[^-^.a-z0-9]+`)\n\tErrDNSPatternInvalid    = errors.New(\"name is not a valid kubernetes DNS name\")\n)\n\nfunc getHostnameOrEmpty(name string) string {\n\tclean, _ := toDNSName(name)\n\tif clean == \"\" {\n\t\tclean = strings.ToLower(name)\n\t}\n\tclean = strings.ReplaceAll(clean, \".\", \"-\")\n\n\tif len(clean) > maxDNSLabelLen {\n\t\tclean = clean[:maxDNSLabelLen]\n\t}\n\n\tclean = strings.Trim(clean, \"-\")\n\n\tif dnsLabelPattern.MatchString(clean) {\n\t\treturn clean\n\t}\n\treturn \"\"\n}\n\nfunc dnsName(i string) (string, error) {\n\tres := strings.ToLower(strings.ReplaceAll(i, \"_\", \"-\"))\n\n\tif found := dnsPattern.FindStringIndex(res); found == nil {\n\t\treturn \"\", ErrDNSPatternInvalid\n\t}\n\n\treturn res, nil\n}\n\nfunc toDNSName(in string) (string, error) {\n\tlower := strings.ToLower(in)\n\twithoutUnderscores := strings.ReplaceAll(lower, \"_\", \"-\")\n\twithoutSpaces := strings.ReplaceAll(withoutUnderscores, \" \", \"-\")\n\talmostDNS := dnsDisallowedCharacters.ReplaceAllString(withoutSpaces, \"\")\n\treturn dnsName(almostDNS)\n}\n\nfunc isImagePullBackOffState(pod *kube_core_v1.Pod) bool {\n\tfor _, containerState := range pod.Status.ContainerStatuses {\n\t\tif containerState.State.Waiting != nil {\n\t\t\tif containerState.State.Waiting.Reason == \"ImagePullBackOff\" {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc isInvalidImageName(pod *kube_core_v1.Pod) bool {\n\tfor _, containerState := range pod.Status.ContainerStatuses {\n\t\tif containerState.State.Waiting != nil {\n\t\t\tif containerState.State.Waiting.Reason == \"InvalidImageName\" {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\n// getClientOutOfCluster returns a k8s client set to the request from outside of cluster.\nfunc getClientOutOfCluster() (kubernetes.Interface, error) {\n\tkubeConfigPath := os.Getenv(\"KUBECONFIG\") // cspell:words KUBECONFIG\n\tif kubeConfigPath == \"\" {\n\t\tkubeConfigPath = os.Getenv(\"HOME\") + \"/.kube/config\"\n\t}\n\n\t// use the current context in kube config\n\tconfig, err := kube_client_cmd.BuildConfigFromFlags(\"\", kubeConfigPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn kubernetes.NewForConfig(config)\n}\n\n// getClientInsideOfCluster returns a k8s client set to the request from inside of cluster.\nfunc getClientInsideOfCluster() (kubernetes.Interface, error) {\n\tconfig, err := rest.InClusterConfig()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn kubernetes.NewForConfig(config)\n}\n\nfunc newBool(val bool) *bool {\n\tptr := new(bool)\n\t*ptr = val\n\treturn ptr\n}\n\nfunc newInt64(val int64) *int64 {\n\tptr := new(int64)\n\t*ptr = val\n\treturn ptr\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/utils_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestDNSName(t *testing.T) {\n\tname, err := dnsName(\"wp_01he8bebctabr3kgk0qj36d2me_0_services_0\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-01he8bebctabr3kgk0qj36d2me-0-services-0\", name)\n\n\tname, err = dnsName(\"a.0-AA\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"a.0-aa\", name)\n\n\tname, err = dnsName(\"wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local\", name)\n\n\t_, err = dnsName(\".0-a\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"ABC..DEF\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"0.-a\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"test-\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"-test\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"0-a.\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = dnsName(\"abc\\\\def\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n}\n\nfunc TestToDnsName(t *testing.T) {\n\tname, err := toDNSName(\"BUILD_AND_DEPLOY_0\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"build-and-deploy-0\", name)\n\n\tname, err = toDNSName(\"build and deploy\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"build-and-deploy\", name)\n\n\tname, err = toDNSName(\"build & deploy\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"build--deploy\", name)\n\n\t_, err = toDNSName(\"-build-and-deploy\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n}\n\nfunc TestGetHostnameOrEmpty(t *testing.T) {\n\ttests := []struct {\n\t\tin   string\n\t\twant string\n\t}{\n\t\t{\"Update repos\", \"update-repos\"},\n\t\t{\"MY_STEP\", \"my-step\"},\n\t\t{\"Build 🚀\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := getHostnameOrEmpty(tt.in)\n\t\tassert.Equal(t, tt.want, got, \"input: %q\", tt.in)\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/volume.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\tkube_core_v1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\t\"k8s.io/apimachinery/pkg/api/resource\"\n\tkube_meta_v1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\nfunc mkPersistentVolumeClaim(config *config, name, namespace string) (*kube_core_v1.PersistentVolumeClaim, error) {\n\t_storageClass := &config.StorageClass\n\tif config.StorageClass == \"\" {\n\t\t_storageClass = nil\n\t}\n\n\tvar accessMode kube_core_v1.PersistentVolumeAccessMode\n\n\tif config.StorageRwx {\n\t\taccessMode = kube_core_v1.ReadWriteMany\n\t} else {\n\t\taccessMode = kube_core_v1.ReadWriteOnce\n\t}\n\n\tvolumeName, err := volumeName(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpvc := &kube_core_v1.PersistentVolumeClaim{\n\t\tObjectMeta: kube_meta_v1.ObjectMeta{\n\t\t\tName:      volumeName,\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: kube_core_v1.PersistentVolumeClaimSpec{\n\t\t\tAccessModes:      []kube_core_v1.PersistentVolumeAccessMode{accessMode},\n\t\t\tStorageClassName: _storageClass,\n\t\t\tResources: kube_core_v1.VolumeResourceRequirements{\n\t\t\t\tRequests: kube_core_v1.ResourceList{\n\t\t\t\t\tkube_core_v1.ResourceStorage: resource.MustParse(config.VolumeSize),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\treturn pvc, nil\n}\n\nfunc volumeName(name string) (string, error) {\n\treturn dnsName(strings.Split(name, \":\")[0])\n}\n\nfunc volumeMountPath(name string) string {\n\ts := strings.Split(name, \":\")\n\tif len(s) > 1 {\n\t\treturn s[1]\n\t}\n\treturn s[0]\n}\n\nfunc startVolume(ctx context.Context, engine *kube, name, namespace string) (*kube_core_v1.PersistentVolumeClaim, error) {\n\tengineConfig := engine.getConfig()\n\tpvc, err := mkPersistentVolumeClaim(engineConfig, name, namespace)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Trace().Msgf(\"creating volume: %s\", pvc.Name)\n\treturn engine.client.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, kube_meta_v1.CreateOptions{})\n}\n\nfunc stopVolume(ctx context.Context, engine *kube, name, namespace string, deleteOpts kube_meta_v1.DeleteOptions) error {\n\tpvcName, err := volumeName(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Trace().Str(\"name\", pvcName).Msg(\"deleting volume\")\n\n\terr = engine.client.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, pvcName, deleteOpts)\n\tif errors.IsNotFound(err) {\n\t\t// Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps.\n\t\tlog.Trace().Err(err).Msgf(\"unable to delete service %s\", pvcName)\n\t\treturn nil\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/backend/kubernetes/volume_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kubernetes\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestPvcName(t *testing.T) {\n\tname, err := volumeName(\"woodpecker_cache:/woodpecker/src/cache\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"woodpecker-cache\", name)\n\n\t_, err = volumeName(\"woodpecker\\\\cache\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n\n\t_, err = volumeName(\"-woodpecker.cache:/woodpecker/src/cache\")\n\tassert.ErrorIs(t, err, ErrDNSPatternInvalid)\n}\n\nfunc TestPvcMount(t *testing.T) {\n\tmount := volumeMountPath(\"woodpecker-cache:/woodpecker/src/cache\")\n\tassert.Equal(t, \"/woodpecker/src/cache\", mount)\n\n\tmount = volumeMountPath(\"/woodpecker/src/cache\")\n\tassert.Equal(t, \"/woodpecker/src/cache\", mount)\n}\n\nfunc TestPersistentVolumeClaim(t *testing.T) {\n\tnamespace := \"someNamespace\"\n\texpectedRwx := `\n\t{\n\t  \"metadata\": {\n\t    \"name\": \"somename\",\n\t    \"namespace\": \"someNamespace\"\n\t  },\n\t  \"spec\": {\n\t    \"accessModes\": [\n\t      \"ReadWriteMany\"\n\t    ],\n\t    \"resources\": {\n\t      \"requests\": {\n\t        \"storage\": \"1Gi\"\n\t      }\n\t    },\n\t    \"storageClassName\": \"local-storage\"\n\t  },\n\t  \"status\": {}\n\t}`\n\n\texpectedRwo := `\n\t{\n\t  \"metadata\": {\n\t    \"name\": \"somename\",\n\t    \"namespace\": \"someNamespace\"\n\t  },\n\t  \"spec\": {\n\t    \"accessModes\": [\n\t      \"ReadWriteOnce\"\n\t    ],\n\t    \"resources\": {\n\t      \"requests\": {\n\t        \"storage\": \"1Gi\"\n\t      }\n\t    },\n\t    \"storageClassName\": \"local-storage\"\n\t  },\n\t  \"status\": {}\n\t}`\n\n\tpvc, err := mkPersistentVolumeClaim(&config{\n\t\tNamespace:    namespace,\n\t\tStorageClass: \"local-storage\",\n\t\tVolumeSize:   \"1Gi\",\n\t\tStorageRwx:   true,\n\t}, \"somename\", namespace)\n\tassert.NoError(t, err)\n\n\tj, err := json.Marshal(pvc)\n\tassert.NoError(t, err)\n\tassert.JSONEq(t, expectedRwx, string(j))\n\n\tpvc, err = mkPersistentVolumeClaim(&config{\n\t\tNamespace:    namespace,\n\t\tStorageClass: \"local-storage\",\n\t\tVolumeSize:   \"1Gi\",\n\t\tStorageRwx:   false,\n\t}, \"somename\", namespace)\n\tassert.NoError(t, err)\n\n\tj, err = json.Marshal(pvc)\n\tassert.NoError(t, err)\n\tassert.JSONEq(t, expectedRwo, string(j))\n\n\t_, err = mkPersistentVolumeClaim(&config{\n\t\tNamespace:    namespace,\n\t\tStorageClass: \"local-storage\",\n\t\tVolumeSize:   \"1Gi\",\n\t\tStorageRwx:   false,\n\t}, \"some0..INVALID3name\", namespace)\n\tassert.Error(t, err)\n}\n"
  },
  {
    "path": "pipeline/backend/local/clone.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// checkGitCloneCap check if we have the git binary on hand.\nfunc checkGitCloneCap() error {\n\t_, err := exec.LookPath(\"git\")\n\treturn err\n}\n\n// loadClone on backend start determine if there is a global plugin-git binary.\nfunc (e *local) loadClone() {\n\tbinary, err := exec.LookPath(\"plugin-git\")\n\tif err != nil || binary == \"\" {\n\t\t// could not found global git plugin, just ignore it\n\t\treturn\n\t}\n\te.pluginGitBinary = binary\n}\n\n// setupClone prepare the clone environment before exec.\nfunc (e *local) setupClone(ctx context.Context, state *workflowState) error {\n\tif e.pluginGitBinary != \"\" {\n\t\tstate.pluginGitBinary = e.pluginGitBinary\n\t\treturn nil\n\t}\n\n\tlog.Info().Msg(\"no global 'plugin-git' installed, try to download for current workflow\")\n\tstate.pluginGitBinary = filepath.Join(state.homeDir, \"plugin-git\")\n\tif e.os == \"windows\" {\n\t\tstate.pluginGitBinary += \".exe\"\n\t}\n\treturn e.downloadLatestGitPluginBinary(ctx, state.pluginGitBinary)\n}\n\n// execClone executes a clone-step locally.\nfunc (e *local) execClone(ctx context.Context, step *types.Step, state *workflowState, env []string) error {\n\tif err := checkGitCloneCap(); err != nil {\n\t\treturn fmt.Errorf(\"check for git clone capabilities failed: %w\", err)\n\t}\n\n\tif err := e.setupClone(ctx, state); err != nil {\n\t\treturn fmt.Errorf(\"setup clone step failed: %w\", err)\n\t}\n\n\tif !strings.Contains(step.Image, \"plugin-git\") {\n\t\tlog.Warn().Msgf(\"clone step image '%s' does not match default git clone image. We ignore it and use our plugin-git anyway.\", step.Image)\n\t}\n\n\trmCmd, err := e.writeNetRC(step, state)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Prepare command\n\tvar cmd *exec.Cmd\n\tif rmCmd != \"\" {\n\t\t// if we have a netrc injected we have to make sure it's deleted in any case after clone was attempted\n\t\tif e.os == \"windows\" {\n\t\t\tpwsh, err := exec.LookPath(\"powershell.exe\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcmd = exec.CommandContext(ctx, pwsh, \"-Command\", fmt.Sprintf(\"%s ; $code=$? ; %s ; if (!$code) {[Environment]::Exit(1)}\", state.pluginGitBinary, rmCmd))\n\t\t} else {\n\t\t\tcmd = exec.CommandContext(ctx, \"/bin/sh\", \"-c\", fmt.Sprintf(\"%s ; export code=$? ; %s ; exit $code\", state.pluginGitBinary, rmCmd))\n\t\t}\n\t} else {\n\t\t// if we have NO netrc, we can just exec the clone directly\n\t\tcmd = exec.CommandContext(ctx, state.pluginGitBinary)\n\t}\n\tcmd.Env = env\n\tcmd.Dir = state.workspaceDir\n\n\treader, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Save state\n\tstate.stepState.Store(step.UUID, &stepState{\n\t\tcmd:    cmd,\n\t\toutput: reader,\n\t})\n\n\t// Get output and redirect Stderr to Stdout\n\tcmd.Stderr = cmd.Stdout\n\n\treturn cmd.Start()\n}\n\n// writeNetRC write a netrc file into the home dir of a given workflow state.\nfunc (e *local) writeNetRC(step *types.Step, state *workflowState) (string, error) {\n\tif step.Environment[\"CI_NETRC_MACHINE\"] == \"\" {\n\t\tlog.Trace().Msg(\"no netrc to write\")\n\t\treturn \"\", nil\n\t}\n\n\tif !e.isolatedHome {\n\t\tlog.Trace().Msg(\"writing .netrc skipped due to disabled isolated home\")\n\t\treturn \"\", nil\n\t}\n\n\tfile := filepath.Join(state.homeDir, \".netrc\")\n\trmCmd := fmt.Sprintf(\"rm \\\"%s\\\"\", file)\n\tif e.os == \"windows\" {\n\t\tfile = filepath.Join(state.homeDir, \"_netrc\")\n\t\trmCmd = fmt.Sprintf(\"del \\\"%s\\\"\", file)\n\t}\n\n\tlog.Trace().Msgf(\"try to write netrc to '%s'\", file)\n\treturn rmCmd, os.WriteFile(file, []byte(genNetRC(step.Environment)), 0o600)\n}\n\n// downloadLatestGitPluginBinary download the latest plugin-git binary based on runtime OS and Arch\n// and saves it to dest.\nfunc (e *local) downloadLatestGitPluginBinary(ctx context.Context, dest string) error {\n\ttype asset struct {\n\t\tName               string\n\t\tBrowserDownloadURL string `json:\"browser_download_url\"`\n\t}\n\n\ttype release struct {\n\t\tAssets []asset\n\t}\n\n\t// get latest release\n\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"https://api.github.com/repos/woodpecker-ci/plugin-git/releases/latest\", nil)\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Set(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not get latest release: %w\", err)\n\t}\n\traw, _ := io.ReadAll(resp.Body)\n\t_ = resp.Body.Close()\n\tvar rel release\n\tif err := json.Unmarshal(raw, &rel); err != nil {\n\t\treturn fmt.Errorf(\"could not unmarshal github response: %w\", err)\n\t}\n\n\tfor _, at := range rel.Assets {\n\t\tif strings.Contains(at.Name, e.os) && strings.Contains(at.Name, e.arch) {\n\t\t\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, at.BrowserDownloadURL, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tassetResp, err := http.DefaultClient.Do(req)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"could not download plugin-git: %w\", err)\n\t\t\t}\n\t\t\tdefer assetResp.Body.Close()\n\n\t\t\tfile, err := os.Create(dest)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"could not create plugin-git: %w\", err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tif _, err := io.Copy(file, assetResp.Body); err != nil {\n\t\t\t\treturn fmt.Errorf(\"could not download plugin-git: %w\", err)\n\t\t\t}\n\t\t\tif err := os.Chmod(dest, 0o755); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// download successful\n\t\t\tlog.Trace().Msgf(\"download of 'plugin-git' to '%s' successful\", dest)\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"could not download plugin-git, binary for this os/arch not found\")\n}\n"
  },
  {
    "path": "pipeline/backend/local/command.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// cSpell:ignore ERRORLEVEL\n\npackage local\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"al.essio.dev/pkg/shellescape\"\n\t\"golang.org/x/text/encoding/unicode\"\n\t\"golang.org/x/text/transform\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// execCommands use step.Image as shell and run the commands in it.\nfunc (e *local) execCommands(ctx context.Context, step *types.Step, state *workflowState, env []string) error {\n\tif err := checkShellExistence(step.Image); err != nil {\n\t\treturn err\n\t}\n\n\t// Prepare commands\n\t// TODO: support `entrypoint` from pipeline config\n\targs, err := e.genCmdByShell(step.Image, step.Commands, state.baseDir)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not convert commands into args: %w\", err)\n\t}\n\n\t// Use \"image name\" as run command (indicate shell)\n\tcmd := exec.CommandContext(ctx, step.Image, args...)\n\tcmd.Env = env\n\tcmd.Dir = state.workspaceDir\n\n\treader, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif e.os == \"windows\" {\n\t\t// we get non utf8 output from windows so just sanitize it\n\t\t// TODO: remove hack\n\t\treader = io.NopCloser(transform.NewReader(reader, unicode.UTF8.NewDecoder().Transformer))\n\t}\n\n\t// Get output and redirect Stderr to Stdout\n\tcmd.Stderr = cmd.Stdout\n\n\t// Save state\n\tstate.stepState.Store(step.UUID, &stepState{\n\t\tcmd:    cmd,\n\t\toutput: reader,\n\t})\n\n\treturn cmd.Start()\n}\n\nfunc checkShellExistence(shell string) error {\n\t_, err := exec.LookPath(shell)\n\treturn err\n}\n\nfunc (e *local) genCmdByShell(shell string, cmdList []string, baseDir string) (args []string, err error) {\n\tif len(cmdList) == 0 {\n\t\treturn nil, ErrNoCmdSet\n\t}\n\n\tscript := \"\"\n\tfor _, cmd := range cmdList {\n\t\tscript += fmt.Sprintf(\"echo %s\\n%s\\n\", strings.TrimSpace(shellescape.Quote(\"+ \"+cmd)), cmd)\n\t}\n\tscript = strings.TrimSpace(script)\n\n\tshell = strings.TrimSuffix(strings.ToLower(shell), \".exe\")\n\tswitch shell {\n\tdefault:\n\t\t// assume posix shell\n\t\tif err := probeShellIsPosix(shell); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfallthrough\n\t\t// normal posix shells\n\tcase \"sh\", \"bash\", \"zsh\":\n\t\treturn []string{\"-e\", \"-c\", script}, nil\n\tcase \"\":\n\t\treturn nil, ErrNoShellSet\n\tcase \"cmd\":\n\t\tscript := \"@SET PROMPT=$\\n\"\n\t\tfor _, cmd := range cmdList {\n\t\t\tquotedCmd := strings.TrimSpace(shellescape.Quote(cmd))\n\t\t\t// As cmd echo does not allow strings with newlines we need to replace them ...\n\t\t\tquotedCmd = strings.ReplaceAll(quotedCmd, \"\\n\", \"\\\\n\")\n\t\t\t// Also the shellescape.Quote fail with any | or & char and wrapping them in quotes again can be bypassed\n\t\t\t// by just leaving an string halve quoted we just replace them with symbolic representations\n\t\t\tquotedCmd = strings.ReplaceAll(quotedCmd, \"&\", \"\\\\AND\")\n\t\t\tquotedCmd = strings.ReplaceAll(quotedCmd, \"|\", \"\\\\OR\")\n\n\t\t\tscript += fmt.Sprintf(\"@echo + %s\\n\", quotedCmd)\n\t\t\tscript += fmt.Sprintf(\"@%s\\n\", cmd)\n\t\t\tscript += \"@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\\n\"\n\t\t}\n\t\tcmd, err := os.CreateTemp(baseDir, \"*.cmd\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tdefer cmd.Close()\n\t\tif _, err := cmd.WriteString(script); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn []string{\"/c\", cmd.Name()}, nil\n\tcase \"fish\":\n\t\tscript := \"\"\n\t\tfor _, cmd := range cmdList {\n\t\t\tscript += fmt.Sprintf(\"echo %s\\n%s || exit $status\\n\", strings.TrimSpace(shellescape.Quote(\"+ \"+cmd)), cmd)\n\t\t}\n\t\treturn []string{\"-c\", script}, nil\n\tcase \"nu\":\n\t\treturn []string{\"--commands\", script}, nil\n\tcase \"powershell\", \"pwsh\":\n\t\t// cspell:disable-next-line\n\t\treturn []string{\"-noprofile\", \"-noninteractive\", \"-c\", \"$ErrorActionPreference = \\\"Stop\\\"; \" + script}, nil\n\t}\n}\n\n// before we generate a generic posix shell we test.\nfunc probeShellIsPosix(shell string) error {\n\tscript := `x=1 && [ \"$x\" = \"1\" ] && command -v test >/dev/null && printf ok`\n\n\tcmd := exec.Command(shell, \"-c\", script)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn &ErrNoPosixShell{Shell: shell, Err: err}\n\t}\n\n\tif strings.TrimSpace(string(output)) != \"ok\" {\n\t\treturn &ErrNoPosixShell{Shell: shell, Err: fmt.Errorf(\"unexpected output returned: %q\", string(output))}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pipeline/backend/local/command_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGenCmdByShell(t *testing.T) {\n\ttmpDir := t.TempDir()\n\te := local{tempDir: tmpDir}\n\n\tt.Run(\"error cases\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"\", []string{\"echo hi\"}, t.TempDir())\n\t\tassert.Nil(t, args)\n\t\tassert.ErrorIs(t, err, ErrNoShellSet)\n\n\t\targs, err = e.genCmdByShell(\"sh\", []string{}, t.TempDir())\n\t\tassert.Nil(t, args)\n\t\tassert.ErrorIs(t, err, ErrNoCmdSet)\n\t})\n\n\tt.Run(\"windows shells\", func(t *testing.T) {\n\t\tt.Run(\"cmd\", func(t *testing.T) {\n\t\t\targs, err := e.genCmdByShell(\"cmd.exe\", []string{\"echo hi\", \"call build.bat\"}, t.TempDir())\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, args, 2)\n\t\t\tassert.Equal(t, \"/c\", args[0])\n\t\t\tassert.True(t, strings.HasSuffix(args[1], \".cmd\"))\n\n\t\t\t// Verify the temp file was created and contains expected content\n\t\t\tcontent, err := os.ReadFile(args[1])\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.EqualValues(t, `@SET PROMPT=$\n@echo + 'echo hi'\n@echo hi\n@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n@echo + 'call build.bat'\n@call build.bat\n@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n`, string(content))\n\t\t})\n\n\t\tt.Run(\"powershell\", func(t *testing.T) {\n\t\t\targs, err := e.genCmdByShell(\"powershell\", []string{\"Write-Host 'test'\", \"echo test\"}, t.TempDir())\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, args, 4)\n\t\t\tassert.EqualValues(t, []string{\"-noprofile\", \"-noninteractive\", \"-c\"}, []string{args[0], args[1], args[2]})\n\t\t\tassert.EqualValues(t, `$ErrorActionPreference = \"Stop\"; echo '+ Write-Host '\"'\"'test'\"'\"''\nWrite-Host 'test'\necho '+ echo test'\necho test`, args[3])\n\n\t\t\targs, err = e.genCmdByShell(\"pwsh\", []string{\"Get-Process\"}, t.TempDir())\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Len(t, args, 4)\n\t\t\tassert.Equal(t, \"-noprofile\", args[0])\n\t\t})\n\t})\n\n\tt.Run(\"unix shells\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"sh\", []string{\"echo hello\", \"pwd\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 3)\n\t\tassert.Equal(t, \"-e\", args[0])\n\t\tassert.Equal(t, \"-c\", args[1])\n\t\tassert.Contains(t, args[2], \"echo hello\")\n\t\tassert.Contains(t, args[2], \"pwd\")\n\n\t\targs, err = e.genCmdByShell(\"bash\", []string{\"ls -la\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 3)\n\t\tassert.Equal(t, \"-e\", args[0])\n\t\tassert.Equal(t, \"-c\", args[1])\n\n\t\targs, err = e.genCmdByShell(\"zsh\", []string{\"echo test\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 3)\n\t\tassert.Equal(t, \"-e\", args[0])\n\t})\n\n\tt.Run(\"fish shell\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"fish\", []string{\"echo test\", \"ls\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 2)\n\t\tassert.Equal(t, \"-c\", args[0])\n\t\tassert.Contains(t, args[1], \"echo test\")\n\t\tassert.Contains(t, args[1], \"|| exit $status\")\n\t})\n\n\tt.Run(\"nu shell\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"nu\", []string{\"echo test\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 2)\n\t\tassert.Equal(t, \"--commands\", args[0])\n\t\tassert.Contains(t, args[1], \"echo test\")\n\t})\n\n\tt.Run(\"command escaping\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"cmd\", []string{\"echo 'test with | pipe'\", \"echo 'test & ampersand'\\n\\necho new line\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tcontent, err := os.ReadFile(args[1])\n\t\trequire.NoError(t, err)\n\t\tassert.EqualValues(t, `@SET PROMPT=$\n@echo + 'echo '\"'\"'test with \\OR pipe'\"'\"''\n@echo 'test with | pipe'\n@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n@echo + 'echo '\"'\"'test \\AND ampersand'\"'\"'\\n\\necho new line'\n@echo 'test & ampersand'\n\necho new line\n@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n`, string(content))\n\t})\n\n\tt.Run(\"shell with .exe suffix\", func(t *testing.T) {\n\t\targs, err := e.genCmdByShell(\"bash.exe\", []string{\"echo test\"}, t.TempDir())\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, args, 3)\n\t\tassert.Equal(t, \"-e\", args[0])\n\t})\n}\n\nfunc TestProbeShellIsPosix(t *testing.T) {\n\tif runtime.GOOS != \"linux\" {\n\t\tt.Skip(\"skipping posix shell tests on non-linux system\")\n\t}\n\n\tt.Run(\"valid posix shells\", func(t *testing.T) {\n\t\terr := probeShellIsPosix(\"sh\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"invalid shell\", func(t *testing.T) {\n\t\terr := probeShellIsPosix(\"nonexistentshell12345\")\n\t\tif assert.ErrorIs(t, err, &ErrNoPosixShell{}) {\n\t\t\tassert.Equal(t,\n\t\t\t\t`Shell \"nonexistentshell12345\" was assumed to be a Posix shell, but test failed: exec: \"nonexistentshell12345\": executable file not found in $PATH\n(if you want support for it, please open an issue)`,\n\t\t\t\terr.Error())\n\t\t}\n\t})\n\n\tt.Run(\"non-posix shell\", func(t *testing.T) {\n\t\t// nologin won't understand posix syntax\n\t\terr := probeShellIsPosix(\"true\")\n\t\tif assert.ErrorIs(t, err, &ErrNoPosixShell{}) {\n\t\t\tassert.Equal(t,\n\t\t\t\t`Shell \"true\" was assumed to be a Posix shell, but test failed: unexpected output returned: \"\"\n(if you want support for it, please open an issue)`,\n\t\t\t\terr.Error())\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pipeline/backend/local/const.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"fmt\"\n)\n\n// notAllowedEnvVarOverwrites are all env vars that cannot be overwritten by step config.\nvar notAllowedEnvVarOverwrites = []string{\n\t\"CI_NETRC_MACHINE\",\n\t\"CI_NETRC_USERNAME\",\n\t\"CI_NETRC_PASSWORD\",\n\t\"CI_SCRIPT\",\n\t\"HOME\",\n\t\"SHELL\",\n\t\"CI_WORKSPACE\",\n}\n\nconst netrcFile = `\nmachine %s\nlogin %s\npassword %s\n`\n\nfunc genNetRC(env map[string]string) string {\n\treturn fmt.Sprintf(\n\t\tnetrcFile,\n\t\tenv[\"CI_NETRC_MACHINE\"],\n\t\tenv[\"CI_NETRC_USERNAME\"],\n\t\tenv[\"CI_NETRC_PASSWORD\"],\n\t)\n}\n"
  },
  {
    "path": "pipeline/backend/local/const_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGenNetRC(t *testing.T) {\n\tassert.Equal(t, `\nmachine machine\nlogin user\npassword pass\n`, genNetRC(map[string]string{\n\t\t\"CI_NETRC_MACHINE\":  \"machine\",\n\t\t\"CI_NETRC_USERNAME\": \"user\",\n\t\t\"CI_NETRC_PASSWORD\": \"pass\",\n\t}))\n}\n"
  },
  {
    "path": "pipeline/backend/local/errors.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// cSpell:ignore ERRORLEVEL\n\npackage local\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\nvar (\n\tErrUnsupportedStepType   = errors.New(\"unsupported step type\")\n\tErrStepReaderNotFound    = errors.New(\"could not found pipe reader for step\")\n\tErrWorkflowStateNotFound = errors.New(\"workflow state not found\")\n\tErrStepStateNotFound     = errors.New(\"step state not found\")\n\tErrNoShellSet            = errors.New(\"no shell was set\")\n\tErrNoCmdSet              = errors.New(\"no commands where set\")\n)\n\n// ErrNoPosixShell indicates that a shell was assumed to be POSIX-compatible but failed the test.\ntype ErrNoPosixShell struct {\n\tShell string\n\tErr   error\n}\n\nfunc (e *ErrNoPosixShell) Error() string {\n\treturn fmt.Sprintf(\"Shell %q was assumed to be a Posix shell, but test failed: %v\\n(if you want support for it, please open an issue)\", e.Shell, e.Err)\n}\n\n// Unwrap returns the underlying error for errors.Is and errors.As support.\nfunc (e *ErrNoPosixShell) Unwrap() error {\n\treturn e.Err\n}\n\n// Is enables errors.Is comparison.\nfunc (e *ErrNoPosixShell) Is(target error) bool {\n\t_, ok := target.(*ErrNoPosixShell)\n\treturn ok\n}\n"
  },
  {
    "path": "pipeline/backend/local/flags.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"os\"\n\n\t\"github.com/urfave/cli/v3\"\n)\n\nvar Flags = []cli.Flag{\n\t&cli.StringFlag{\n\t\tName:        \"backend-local-temp-dir\",\n\t\tSources:     cli.EnvVars(\"WOODPECKER_BACKEND_LOCAL_TEMP_DIR\"),\n\t\tUsage:       \"set a different temp dir to clone workflows into\",\n\t\tDefaultText: \"system temporary directory\",\n\t\tValue:       os.TempDir(),\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_BACKEND_LOCAL_ISOLATED_HOME\"),\n\t\tName:    \"backend-local-isolated-home\",\n\t\tUsage:   \"set HOME, USERPROFILE and other variables to an isolated directory, if false we ignore netrc\",\n\t\tValue:   true,\n\t},\n}\n"
  },
  {
    "path": "pipeline/backend/local/local.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"sync\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\ntype workflowState struct {\n\tstepState       sync.Map // map of *stepState\n\tbaseDir         string\n\thomeDir         string\n\tworkspaceDir    string\n\tpluginGitBinary string\n}\n\ntype stepState struct {\n\tcmd    *exec.Cmd\n\toutput io.ReadCloser\n}\n\ntype local struct {\n\ttempDir         string\n\tisolatedHome    bool\n\tworkflows       sync.Map\n\tpluginGitBinary string\n\tos, arch        string\n}\n\nvar CLIWorkaroundExecAtDir string // To handle edge case for running local backend via cli exec\n\n// New returns a new local Backend.\nfunc New() types.Backend {\n\treturn &local{\n\t\tos:   runtime.GOOS,\n\t\tarch: runtime.GOARCH,\n\t}\n}\n\nfunc (e *local) Name() string {\n\treturn \"local\"\n}\n\nfunc (e *local) IsAvailable(ctx context.Context) bool {\n\tif c, ok := ctx.Value(types.CliCommand).(*cli.Command); ok {\n\t\tif c.String(\"backend-engine\") == e.Name() {\n\t\t\treturn true\n\t\t}\n\t}\n\t_, inContainer := os.LookupEnv(\"WOODPECKER_IN_CONTAINER\")\n\treturn !inContainer\n}\n\nfunc (e *local) Flags() []cli.Flag {\n\treturn Flags\n}\n\nfunc (e *local) Load(ctx context.Context) (*types.BackendInfo, error) {\n\tc, ok := ctx.Value(types.CliCommand).(*cli.Command)\n\tif ok {\n\t\te.tempDir = c.String(\"backend-local-temp-dir\")\n\t\te.isolatedHome = c.Bool(\"backend-local-isolated-home\")\n\t}\n\n\te.loadClone()\n\n\treturn &types.BackendInfo{\n\t\tPlatform: e.os + \"/\" + e.arch,\n\t}, nil\n}\n\nfunc (e *local) SetupWorkflow(_ context.Context, _ *types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msg(\"create workflow environment\")\n\n\tbaseDir, err := os.MkdirTemp(e.tempDir, \"woodpecker-local-*\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstate := &workflowState{\n\t\tbaseDir: baseDir,\n\t\thomeDir: filepath.Join(baseDir, \"home\"),\n\t}\n\te.workflows.Store(taskUUID, state)\n\n\tif err := os.Mkdir(state.homeDir, 0o700); err != nil {\n\t\treturn err\n\t}\n\n\t// normal workspace setup case\n\tif CLIWorkaroundExecAtDir == \"\" {\n\t\tstate.workspaceDir = filepath.Join(baseDir, \"workspace\")\n\t\tif err := os.Mkdir(state.workspaceDir, 0o700); err != nil {\n\t\t\treturn err\n\t\t}\n\t} else\n\t// setup workspace via internal flag signaled from cli exec to a specific dir\n\t{\n\t\tstate.workspaceDir = CLIWorkaroundExecAtDir\n\t\tif stat, err := os.Stat(CLIWorkaroundExecAtDir); os.IsNotExist(err) {\n\t\t\tlog.Debug().Msgf(\"create workspace directory '%s' set by internal flag\", CLIWorkaroundExecAtDir)\n\t\t\tif err := os.Mkdir(state.workspaceDir, 0o700); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else if !stat.IsDir() {\n\t\t\t//nolint:forbidigo\n\t\t\tlog.Fatal().Msg(\"This should never happen! internalExecDir was set to an non directory path!\")\n\t\t}\n\t}\n\n\te.workflows.Store(taskUUID, state)\n\n\treturn nil\n}\n\nfunc (e *local) StartStep(ctx context.Context, step *types.Step, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"start step %s\", step.Name)\n\n\tstate, err := e.getWorkflowState(taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Get environment variables\n\tenv := os.Environ()\n\tfor a, b := range step.Environment {\n\t\t// append allowed env vars to command env\n\t\tif !slices.Contains(notAllowedEnvVarOverwrites, a) {\n\t\t\tenv = append(env, a+\"=\"+b)\n\t\t}\n\t}\n\n\tif e.isolatedHome {\n\t\tenv = append(env, \"HOME=\"+state.homeDir)\n\t\tenv = append(env, \"USERPROFILE=\"+state.homeDir)\n\t}\n\n\tenv = append(env, \"CI_WORKSPACE=\"+state.workspaceDir)\n\n\tswitch step.Type {\n\tcase types.StepTypeClone:\n\t\treturn e.execClone(ctx, step, state, env)\n\tcase types.StepTypeCommands:\n\t\treturn e.execCommands(ctx, step, state, env)\n\tcase types.StepTypePlugin:\n\t\treturn e.execPlugin(ctx, step, state, env)\n\tdefault:\n\t\treturn ErrUnsupportedStepType\n\t}\n}\n\nfunc (e *local) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msgf(\"wait for step %s\", step.Name)\n\n\tstepState := &types.State{\n\t\tExited: true,\n\t}\n\n\tif err := ctx.Err(); err != nil {\n\t\tstepState.Error = err\n\t\treturn stepState, nil\n\t}\n\n\tstate, err := e.getStepState(taskUUID, step.UUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif state.cmd == nil {\n\t\treturn nil, errors.New(\"exec: step command not set up\")\n\t}\n\n\t// normally we use cmd.Wait() to wait for *exec.Cmd, but cmd.StdoutPipe() tells us not\n\t// as Wait() would close the io pipe even if not all logs where read and send back\n\t// so we have to do use the underlying functions\n\tif state.cmd.Process == nil {\n\t\treturn nil, errors.New(\"exec: not started\")\n\t}\n\tif state.cmd.ProcessState == nil {\n\t\tcmdState, err := state.cmd.Process.Wait()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif cmdState == nil {\n\t\t\treturn nil, errors.New(\"exec: cmd state after Wait() can not be nil but is\")\n\t\t}\n\t\tstepState.ExitCode = cmdState.ExitCode()\n\t\t// can be nil if step got canceled\n\t\tif state.cmd != nil {\n\t\t\tstate.cmd.ProcessState = cmdState\n\t\t}\n\t} else {\n\t\tstepState.ExitCode = state.cmd.ProcessState.ExitCode()\n\t}\n\n\treturn stepState, err\n}\n\nfunc (e *local) TailStep(_ context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) {\n\tstate, err := e.getStepState(taskUUID, step.UUID)\n\tif err != nil {\n\t\treturn nil, err\n\t} else if state.output == nil {\n\t\treturn nil, ErrStepReaderNotFound\n\t}\n\treturn state.output, nil\n}\n\nfunc (e *local) DestroyStep(_ context.Context, step *types.Step, taskUUID string) error {\n\tstate, err := e.getStepState(taskUUID, step.UUID)\n\tif err != nil {\n\t\tif errors.Is(err, ErrStepStateNotFound) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\t// As WaitStep can not use cmd.Wait() witch ensures the process already finished and\n\t// the io pipe is closed on process end, we make sure it is done.\n\tif state.output != nil {\n\t\t_ = state.output.Close()\n\t\tstate.output = nil\n\t}\n\tif state.cmd != nil {\n\t\t_ = state.cmd.Cancel()\n\t\tstate.cmd = nil\n\t}\n\tworkflowState, err := e.getWorkflowState(taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tworkflowState.stepState.Delete(step.UUID)\n\treturn nil\n}\n\nfunc (e *local) DestroyWorkflow(_ context.Context, _ *types.Config, taskUUID string) error {\n\tlog.Trace().Str(\"taskUUID\", taskUUID).Msg(\"delete workflow environment\")\n\n\tstate, err := e.getWorkflowState(taskUUID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// clean up steps not cleaned up because of context cancel or detached function\n\tstate.stepState.Range(func(_, value any) bool {\n\t\tif state, ok := value.(*stepState); ok && state != nil {\n\t\t\tif state.output != nil {\n\t\t\t\t_ = state.output.Close()\n\t\t\t\tstate.output = nil\n\t\t\t}\n\t\t\tif state.cmd != nil {\n\t\t\t\t_ = state.cmd.Cancel()\n\t\t\t\tstate.cmd = nil\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\terr = os.RemoveAll(state.baseDir)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// hint for the gc to clean stuff\n\tstate.stepState.Clear()\n\te.workflows.Delete(taskUUID)\n\n\treturn err\n}\n\nfunc (e *local) getWorkflowState(taskUUID string) (*workflowState, error) {\n\tstate, ok := e.workflows.Load(taskUUID)\n\tif !ok {\n\t\treturn nil, ErrWorkflowStateNotFound\n\t}\n\n\ts, ok := state.(*workflowState)\n\tif !ok || s == nil {\n\t\treturn nil, fmt.Errorf(\"could not parse state: %v\", state)\n\t}\n\n\treturn s, nil\n}\n\nfunc (e *local) getStepState(taskUUID, stepUUID string) (*stepState, error) {\n\twState, err := e.getWorkflowState(taskUUID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstate, ok := wState.stepState.Load(stepUUID)\n\tif !ok {\n\t\treturn nil, ErrStepStateNotFound\n\t}\n\n\ts, ok := state.(*stepState)\n\tif !ok || s == nil {\n\t\treturn nil, fmt.Errorf(\"could not parse state: %v\", state)\n\t}\n\n\treturn s, nil\n}\n"
  },
  {
    "path": "pipeline/backend/local/local_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build linux\n\npackage local\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestIsAvailable(t *testing.T) {\n\tt.Run(\"not available in container\", func(t *testing.T) {\n\t\tbackend := New()\n\n\t\tt.Setenv(\"WOODPECKER_IN_CONTAINER\", \"true\")\n\n\t\tavailable := backend.IsAvailable(context.Background())\n\t\tassert.False(t, available)\n\t})\n\n\tt.Run(\"available without container env and no cli context\", func(t *testing.T) {\n\t\tbackend := New()\n\n\t\tos.Unsetenv(\"WOODPECKER_IN_CONTAINER\")\n\t\tavailable := backend.IsAvailable(context.Background())\n\t\tassert.True(t, available)\n\t})\n}\n\nfunc TestLoad(t *testing.T) {\n\tbackend, _ := New().(*local)\n\n\tt.Run(\"load without cli context\", func(t *testing.T) {\n\t\tctx := context.Background()\n\t\tinfo, err := backend.Load(ctx)\n\n\t\trequire.NoError(t, err)\n\t\tassert.NotNil(t, info)\n\t\tassert.Equal(t, runtime.GOOS+\"/\"+runtime.GOARCH, info.Platform)\n\t})\n\n\tt.Run(\"load with cli context and temp dir\", func(t *testing.T) {\n\t\ttmpDir := t.TempDir()\n\t\tcmd := &cli.Command{}\n\t\tcmd.Flags = []cli.Flag{\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"backend-local-temp-dir\",\n\t\t\t\tValue: tmpDir,\n\t\t\t},\n\t\t}\n\t\tctx := context.WithValue(context.Background(), types.CliCommand, cmd)\n\n\t\tinfo, err := backend.Load(ctx)\n\n\t\trequire.NoError(t, err)\n\t\tassert.NotNil(t, info)\n\t\tassert.Equal(t, tmpDir, backend.tempDir)\n\t\tassert.Equal(t, runtime.GOOS+\"/\"+runtime.GOARCH, info.Platform)\n\t})\n}\n\nfunc TestSetupWorkflow(t *testing.T) {\n\tbackend, _ := New().(*local)\n\tbackend.tempDir = t.TempDir()\n\n\tctx := context.Background()\n\ttaskUUID := \"test-task-uuid-123\"\n\tconfig := &types.Config{}\n\n\terr := backend.SetupWorkflow(ctx, config, taskUUID)\n\trequire.NoError(t, err)\n\n\t// Verify state was saved\n\tstate, err := backend.getWorkflowState(taskUUID)\n\trequire.NoError(t, err)\n\tassert.NotNil(t, state)\n\tassert.NotEmpty(t, state.baseDir)\n\tassert.NotEmpty(t, state.workspaceDir)\n\tassert.NotEmpty(t, state.homeDir)\n\n\t// Verify directories were created\n\tassert.DirExists(t, state.baseDir)\n\tassert.DirExists(t, state.workspaceDir)\n\tassert.DirExists(t, state.homeDir)\n\n\t// Verify directory structure\n\tassert.Equal(t, filepath.Join(state.baseDir, \"workspace\"), state.workspaceDir)\n\tassert.Equal(t, filepath.Join(state.baseDir, \"home\"), state.homeDir)\n\n\t// Cleanup\n\tassert.NoError(t, os.RemoveAll(state.baseDir))\n}\n\nfunc TestDestroyWorkflow(t *testing.T) {\n\tbackend, _ := New().(*local)\n\tbackend.tempDir = t.TempDir()\n\n\tctx := context.Background()\n\ttaskUUID := \"test-destroy-task\"\n\tconfig := &types.Config{}\n\n\t// Setup workflow first\n\terr := backend.SetupWorkflow(ctx, config, taskUUID)\n\trequire.NoError(t, err)\n\n\tstate, err := backend.getWorkflowState(taskUUID)\n\trequire.NoError(t, err)\n\tbaseDir := state.baseDir\n\n\t// Verify directory exists\n\tassert.DirExists(t, baseDir)\n\n\t// Destroy workflow\n\terr = backend.DestroyWorkflow(ctx, config, taskUUID)\n\trequire.NoError(t, err)\n\n\t// Verify directory was removed\n\tassert.NoDirExists(t, baseDir)\n\n\t// Verify state was deleted\n\t_, err = backend.getWorkflowState(taskUUID)\n\tassert.ErrorIs(t, err, ErrWorkflowStateNotFound)\n}\n\nfunc prepairEnv(t *testing.T) {\n\tprevEnv := os.Environ()\n\tos.Clearenv()\n\tt.Cleanup(func() {\n\t\tfor i := range prevEnv {\n\t\t\tenv := strings.SplitN(prevEnv[i], \"=\", 2)\n\t\t\t//nolint:usetesting // reason: the suggested t.Setenv will be undone on t.Run() end witch we explizite dont want here\n\t\t\t_ = os.Setenv(env[0], env[1])\n\t\t}\n\t})\n}\n\nfunc TestRunStep(t *testing.T) {\n\tif runtime.GOOS != \"linux\" {\n\t\tt.Skip(\"skipping on non linux due to shell availability and symlink capability\")\n\t}\n\n\t// we lookup shell tools we use first and create the PATH var based on that\n\tshBinary, err := exec.LookPath(\"sh\")\n\trequire.NoError(t, err)\n\tpath := []string{filepath.Dir(shBinary)}\n\techoBinary, err := exec.LookPath(\"echo\")\n\trequire.NoError(t, err)\n\tif echoPath := filepath.Dir(echoBinary); !slices.Contains(path, echoPath) {\n\t\tpath = append(path, echoPath)\n\t}\n\t// we make a symlinc to have a posix but non default shell\n\taltShellDir := t.TempDir()\n\taltShellPath := filepath.Join(altShellDir, \"altsh\")\n\trequire.NoError(t, os.Symlink(shBinary, altShellPath))\n\tpath = append(path, altShellDir)\n\n\tprepairEnv(t)\n\t//nolint:usetesting // reason: we use prepairEnv()\n\tos.Setenv(\"PATH\", strings.Join(path, \":\"))\n\n\tbackend, _ := New().(*local)\n\tbackend.tempDir = t.TempDir()\n\tbackend.isolatedHome = true\n\tctx := t.Context()\n\ttaskUUID := \"test-run-tasks\"\n\n\t// Setup workflow\n\trequire.NoError(t, backend.SetupWorkflow(ctx, &types.Config{}, taskUUID))\n\n\tt.Run(\"type commands\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tUUID:     \"step-1\",\n\t\t\tName:     \"test-step\",\n\t\t\tType:     types.StepTypeCommands,\n\t\t\tImage:    \"sh\",\n\t\t\tCommands: []string{\"echo hello\", \"env\"},\n\t\t\tEnvironment: map[string]string{\n\t\t\t\t\"TEST_VAR\": \"test_value\",\n\t\t\t},\n\t\t}\n\n\t\tt.Run(\"start successful\", func(t *testing.T) {\n\t\t\terr = backend.StartStep(ctx, step, taskUUID)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Verify command was started\n\t\t\tstate, err := backend.getWorkflowState(taskUUID)\n\t\t\trequire.NoError(t, err)\n\t\t\tstepStateWraped, contains := state.stepState.Load(step.UUID)\n\t\t\tassert.True(t, contains)\n\t\t\tstepState, _ := stepStateWraped.(*stepState)\n\t\t\tassert.NotNil(t, stepState.cmd)\n\n\t\t\tvar outputData []byte\n\t\t\toutputDataMutex := sync.Mutex{}\n\t\t\tgo t.Run(\"TailStep\", func(t *testing.T) {\n\t\t\t\toutputDataMutex.Lock()\n\t\t\t\tgo outputDataMutex.Unlock()\n\t\t\t\toutput, err := backend.TailStep(ctx, step, taskUUID)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.NotNil(t, output)\n\n\t\t\t\t// Read output\n\t\t\t\toutputData, err = io.ReadAll(output)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t})\n\n\t\t\t// Wait for step to finish\n\t\t\tt.Run(\"TestWaitStep\", func(t *testing.T) {\n\t\t\t\ttime.Sleep(time.Second / 5) // needed to prevent race condition on outputData\n\t\t\t\tstate, err := backend.WaitStep(ctx, step, taskUUID)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.True(t, state.Exited)\n\t\t\t\tassert.Equal(t, 0, state.ExitCode)\n\t\t\t})\n\n\t\t\t// Verify output\n\t\t\toutputDataMutex.Lock()\n\t\t\tgo outputDataMutex.Unlock()\n\t\t\toutputLines := strings.Split(strings.TrimSpace(string(outputData)), \"\\n\")\n\t\t\trequire.Truef(t, len(outputLines) > 3, \"output of lines must be bigger than 3 at least but we got: %#v\", outputLines)\n\t\t\t// we first test output without environments\n\t\t\twantBeforeEnvs := []string{\n\t\t\t\t\"+ echo hello\",\n\t\t\t\t\"hello\",\n\t\t\t\t\"+ env\",\n\t\t\t}\n\t\t\tgotBeforeEnvs := outputLines[:len(wantBeforeEnvs)]\n\t\t\tassert.Equal(t, wantBeforeEnvs, gotBeforeEnvs)\n\t\t\t// we filter out nixos specific stuff catched up in env output\n\t\t\tgotEnvs := slices.DeleteFunc(outputLines[len(wantBeforeEnvs):], func(s string) bool {\n\t\t\t\treturn strings.HasPrefix(s, \"_=\") || strings.HasPrefix(s, \"SHLVL=\")\n\t\t\t})\n\t\t\tassert.ElementsMatch(t, []string{\n\t\t\t\t\"PWD=\" + state.baseDir + \"/workspace\",\n\t\t\t\t\"USERPROFILE=\" + state.baseDir + \"/home\",\n\t\t\t\t\"TEST_VAR=test_value\",\n\t\t\t\t\"HOME=\" + state.baseDir + \"/home\",\n\t\t\t\t\"CI_WORKSPACE=\" + state.baseDir + \"/workspace\",\n\t\t\t\t\"PATH=\" + strings.Join(path, \":\"),\n\t\t\t}, gotEnvs)\n\n\t\t\tt.Run(\"TestDestroyStep\", func(t *testing.T) {\n\t\t\t\terr := backend.DestroyStep(ctx, step, taskUUID)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t})\n\t\t})\n\t})\n\n\tt.Run(\"run command in alternate unix shell\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tUUID:     \"step-altshell\",\n\t\t\tName:     \"altshell\",\n\t\t\tType:     types.StepTypeCommands,\n\t\t\tImage:    \"altsh\",\n\t\t\tCommands: []string{\"echo success\"},\n\t\t}\n\n\t\terr = backend.StartStep(ctx, step, taskUUID)\n\t\trequire.NoError(t, err)\n\n\t\tstate, err := backend.WaitStep(ctx, step, taskUUID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, state.Exited)\n\t\tassert.Equal(t, 0, state.ExitCode)\n\t})\n\n\tt.Run(\"command should fail\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tUUID:     \"step-fail\",\n\t\t\tName:     \"fail-step\",\n\t\t\tType:     types.StepTypeCommands,\n\t\t\tImage:    \"sh\",\n\t\t\tCommands: []string{\"exit 1\"},\n\t\t}\n\n\t\terr = backend.StartStep(ctx, step, taskUUID)\n\t\trequire.NoError(t, err)\n\n\t\tstate, err := backend.WaitStep(ctx, step, taskUUID)\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, state.Exited)\n\t\tassert.Equal(t, 1, state.ExitCode)\n\t})\n\n\tt.Run(\"WaitStep\", func(t *testing.T) {\n\t\tt.Run(\"step not found\", func(t *testing.T) {\n\t\t\tstep := &types.Step{\n\t\t\t\tUUID: \"nonexistent-step\",\n\t\t\t\tName: \"missing\",\n\t\t\t}\n\n\t\t\t_, err = backend.WaitStep(ctx, step, taskUUID)\n\t\t\tassert.Error(t, err)\n\t\t\tassert.Contains(t, err.Error(), \"not found\")\n\t\t})\n\t})\n\n\tt.Run(\"type plugin\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tUUID:        \"step-plugin-1\",\n\t\t\tName:        \"test-plugin\",\n\t\t\tType:        types.StepTypePlugin,\n\t\t\tImage:       \"echo\", // Use a binary that exists\n\t\t\tEnvironment: map[string]string{},\n\t\t}\n\n\t\tt.Run(\"start\", func(t *testing.T) {\n\t\t\terr = backend.StartStep(ctx, step, taskUUID)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Verify command was started\n\t\t\tstate, err := backend.getStepState(taskUUID, step.UUID)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NotEqualf(t, 0, state.cmd.Process.Pid, \"expect an pid of the process\")\n\t\t})\n\t})\n\n\tt.Run(\"type unsupported\", func(t *testing.T) {\n\t\tstep := &types.Step{\n\t\t\tUUID: \"step-unsupported\",\n\t\t\tName: \"test-unsupported\",\n\t\t\tType: \"unsupported-type\",\n\t\t}\n\n\t\tt.Run(\"start\", func(t *testing.T) {\n\t\t\terr = backend.StartStep(ctx, step, taskUUID)\n\t\t\tassert.ErrorIs(t, err, ErrUnsupportedStepType)\n\t\t})\n\t})\n\n\t// Cleanup\n\tassert.NoError(t, backend.DestroyWorkflow(ctx, &types.Config{}, taskUUID))\n}\n\nfunc TestStateManagement(t *testing.T) {\n\tbackend, _ := New().(*local)\n\n\tt.Run(\"save and get state\", func(t *testing.T) {\n\t\ttaskUUID := \"test-state-uuid\"\n\t\tstate := &workflowState{\n\t\t\tbaseDir:      \"/tmp/test\",\n\t\t\thomeDir:      \"/tmp/test/2home\",\n\t\t\tworkspaceDir: \"/tmp/test/2workspace\",\n\t\t}\n\n\t\tbackend.workflows.Store(taskUUID, state)\n\n\t\tretrieved, err := backend.getWorkflowState(taskUUID)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, state.baseDir, retrieved.baseDir)\n\t\tassert.Equal(t, state.homeDir, retrieved.homeDir)\n\t\tassert.Equal(t, state.workspaceDir, retrieved.workspaceDir)\n\t})\n\n\tt.Run(\"get nonexistent state\", func(t *testing.T) {\n\t\t_, err := backend.getWorkflowState(\"nonexistent-uuid\")\n\t\tassert.ErrorIs(t, err, ErrWorkflowStateNotFound)\n\t})\n\n\tt.Run(\"delete state\", func(t *testing.T) {\n\t\ttaskUUID := \"test-delete-uuid\"\n\t\tstate := &workflowState{}\n\n\t\tbackend.workflows.Store(taskUUID, state)\n\n\t\t// Verify state exists\n\t\t_, err := backend.getWorkflowState(taskUUID)\n\t\trequire.NoError(t, err)\n\n\t\t// Delete state\n\t\tbackend.workflows.Delete(taskUUID)\n\n\t\t// Verify state is gone\n\t\t_, err = backend.getWorkflowState(taskUUID)\n\t\tassert.ErrorIs(t, err, ErrWorkflowStateNotFound)\n\t})\n}\n\nfunc TestConcurrentWorkflows(t *testing.T) {\n\tbackend, _ := New().(*local)\n\tbackend.tempDir = t.TempDir()\n\n\tctx := context.Background()\n\n\t// Create multiple workflows concurrently\n\ttaskUUIDs := []string{\"task-1\", \"task-2\", \"task-3\"}\n\n\tfor _, uuid := range taskUUIDs {\n\t\terr := backend.SetupWorkflow(ctx, &types.Config{}, uuid)\n\t\trequire.NoError(t, err)\n\t}\n\n\tcounter := atomic.Int32{}\n\tcounter.Store(0)\n\tfor _, uuid := range taskUUIDs {\n\t\tgo t.Run(\"start step in \"+uuid, func(t *testing.T) {\n\t\t\tfor i := 0; i < 3; i++ {\n\t\t\t\tcounter.Store(counter.Load() + 1)\n\t\t\t\tstep := &types.Step{\n\t\t\t\t\tUUID:        fmt.Sprintf(\"step-%s-%d\", uuid, i),\n\t\t\t\t\tName:        fmt.Sprintf(\"step-name-%s-%d\", uuid, i),\n\t\t\t\t\tType:        types.StepTypePlugin,\n\t\t\t\t\tImage:       \"sh\",\n\t\t\t\t\tCommands:    []string{fmt.Sprintf(\"echo %s %d\", uuid, i)},\n\t\t\t\t\tEnvironment: map[string]string{},\n\t\t\t\t}\n\t\t\t\trequire.NoError(t, backend.StartStep(ctx, step, uuid))\n\t\t\t\t_, err := backend.WaitStep(ctx, step, uuid)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tcounter.Store(counter.Load() - 1)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Verify all states exist\n\tfor _, uuid := range taskUUIDs {\n\t\tstate, err := backend.getWorkflowState(uuid)\n\t\trequire.NoError(t, err)\n\t\tassert.NotNil(t, state)\n\t}\n\n\tfailSave := 0\nloop:\n\tfor {\n\t\tif failSave == 1000 { // wait max 1s\n\t\t\tt.Log(\"failSave was hit\")\n\t\t\tt.FailNow()\n\t\t}\n\t\tfailSave++\n\t\tselect {\n\t\tcase <-time.After(time.Millisecond):\n\t\t\tif count := counter.Load(); count == 0 {\n\t\t\t\tbreak loop\n\t\t\t} else {\n\t\t\t\tt.Logf(\"count at: %d\", count)\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Cleanup all workflows\n\tfor _, uuid := range taskUUIDs {\n\t\t// Cleanup all steps\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tstepUUID := fmt.Sprintf(\"step-%s-%d\", uuid, i)\n\t\t\tassert.NoError(t, backend.DestroyStep(ctx, &types.Step{UUID: stepUUID}, uuid))\n\t\t}\n\n\t\t// finish with workflow cleanup\n\t\terr := backend.DestroyWorkflow(ctx, &types.Config{}, uuid)\n\t\trequire.NoError(t, err)\n\t}\n\n\t// Verify all states are deleted\n\tfor _, uuid := range taskUUIDs {\n\t\t_, err := backend.getWorkflowState(uuid)\n\t\tassert.ErrorIs(t, err, ErrWorkflowStateNotFound)\n\t}\n}\n"
  },
  {
    "path": "pipeline/backend/local/plugin.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage local\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// execPlugin use step.Image as exec binary.\nfunc (e *local) execPlugin(ctx context.Context, step *types.Step, state *workflowState, env []string) error {\n\tbinary, err := exec.LookPath(step.Image)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"lookup plugin binary: %w\", err)\n\t}\n\n\tcmd := exec.CommandContext(ctx, binary)\n\tcmd.Env = env\n\tcmd.Dir = state.workspaceDir\n\n\treader, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Get output and redirect Stderr to Stdout\n\tcmd.Stderr = cmd.Stdout\n\n\t// Save state\n\tstate.stepState.Store(step.UUID, &stepState{\n\t\tcmd:    cmd,\n\t\toutput: reader,\n\t})\n\n\treturn cmd.Start()\n}\n"
  },
  {
    "path": "pipeline/backend/types/auth.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Auth defines registry authentication credentials.\ntype Auth struct {\n\tUsername string `json:\"username,omitempty\"`\n\tPassword string `json:\"password,omitempty\"`\n}\n"
  },
  {
    "path": "pipeline/backend/types/backend.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package types defines the Backend interface and related types for\n// executing Woodpecker CI workflows across different runtime environments.\npackage types\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"github.com/urfave/cli/v3\"\n)\n\n// Backend defines the mechanism for orchestrating workflows and their steps.\n//\n// A Backend instance is created once per agent and must handle multiple\n// workflows concurrently, depending on the configured parallel workflow\n// capacity. Each workflow may have multiple steps executing concurrently.\n//\n// Thread Safety and Isolation:\n//\n//   - Each workflow must have a unique taskUUID\n//   - Backend implementations must use taskUUID to isolate workflow resources\n//   - A single Backend instance must safely handle multiple concurrent workflows\n//   - Workflow functions may be called concurrently for different workflows\n//   - Step functions must be safe to call concurrently for different steps,\n//     even across different workflows\n//\n// Intended execution flow:\n//\n//  1. Initialization (once per backend instance):\n//     - Name() returns backend identifier\n//     - IsAvailable() checks environment compatibility\n//     - Flags() registers configuration options\n//     - Load() initializes the backend instance\n//\n//  2. Workflow setup (once per workflow, may be called concurrently):\n//     - SetupWorkflow() creates isolated environment for the workflow\n//\n//  3. Step execution (once per step, may run concurrently):\n//     - StartStep() launches the step\n//     - TailStep() streams logs (async, in background)\n//     - WaitStep() blocks until completion\n//     - DestroyStep() cleans up step resources\n//\n//  4. Workflow cleanup (once per workflow, may be called concurrently):\n//     - DestroyWorkflow() removes workflow environment\ntype Backend interface {\n\t// Name returns the unique identifier of the backend implementation.\n\t// Examples: \"docker\", \"kubernetes\", \"local\", \"dummy\"\n\tName() string\n\n\t// IsAvailable checks if the backend is available and can be used in the\n\t// current environment. For example, a Docker backend would check if the\n\t// Docker daemon is accessible.\n\tIsAvailable(ctx context.Context) bool\n\n\t// Flags returns the configuration flags specific to this backend.\n\t// Are used to configure backend-specific behavior\n\t// (e.g., Docker socket path, Kubernetes namespace).\n\tFlags() []cli.Flag\n\n\t// Load initializes the backend engine and returns metadata about its\n\t// capabilities and configuration.\n\t// This is called once after flags are parsed.\n\t// The backend must be ready to handle multiple concurrent workflows\n\t// after Load completes successfully.\n\tLoad(ctx context.Context) (*BackendInfo, error)\n\n\t// SetupWorkflow prepares the execution environment for a new workflow.\n\t// This is called exactly once per workflow, before any steps are started.\n\t// The taskUUID uniquely identifies this workflow and must be used to\n\t// isolate this workflow's resources from other concurrent workflows.\n\t//\n\t// Implementations should:\n\t// - Create isolated workspaces, networks, or namespaces\n\t// - Initialize shared volumes or storage\n\t// - Ensure the setup doesn't interfere with other running workflows\n\t//\n\t// This function may be called concurrently for different workflows.\n\t// Implementations must be thread-safe and handle concurrent workflow setup.\n\tSetupWorkflow(ctx context.Context, conf *Config, taskUUID string) error\n\n\t// StartStep set up and begins execution of a workflow step.\n\t// This may be called concurrently for multiple steps within the same\n\t// workflow, depending on the dependency graph.\n\t//\n\t// Implementations should:\n\t// - Start the step's container/process/pod\n\t// - Use taskUUID to associate the step with its workflow\n\t// - Ensure steps can run independently without blocking each other\n\t// - Handle different step types (commands, plugins, services, cache, clone)\n\t//\n\t// The step's UUID uniquely identifies it within the workflow.\n\t// This function must be thread-safe for concurrent calls.\n\tStartStep(ctx context.Context, step *Step, taskUUID string) error\n\n\t// TailStep streams the step's logs back to the caller.\n\t// This is started in a background goroutine immediately after\n\t// StartStep, before WaitStep is called.\n\t//\n\t// The returned io.ReadCloser should:\n\t// - Stream logs as they are produced by the step\n\t// - Remain open until the step completes or is destroyed\n\t//\n\t// The reader will be closed by the caller when no longer needed, which\n\t// may be after WaitStep returns or during DestroyStep.\n\t// This function must be thread-safe for concurrent calls.\n\tTailStep(ctx context.Context, step *Step, taskUUID string) (io.ReadCloser, error)\n\n\t// WaitStep blocks until the step completes and returns its final state.\n\t// This is called after StartStep and TailStep while TailStep is\n\t// streaming logs in the background.\n\t//\n\t// Returns:\n\t// - State.ExitCode: The step's exit code (0 for success, non-zero for failure)\n\t// - State.Error: Any error that occurred during step execution\n\t// - State.Exited: Timestamp when the step completed\n\t//\n\t// The TailStep reader may be closed either when WaitStep completes or\n\t// during DestroyStep - implementations should handle both cases.\n\t// This function must be thread-safe for concurrent calls.\n\tWaitStep(ctx context.Context, step *Step, taskUUID string) (*State, error)\n\n\t// DestroyStep cleans up resources associated with a step.\n\t// This is called after WaitStep completes, or if the workflow is canceled.\n\t//\n\t// Implementations should:\n\t// - Stop the step if still running\n\t// - Clean up step-specific resources (containers, processes)\n\t// - Close any open log streams\n\t// - Not affect other steps in the same or other workflows\n\t// - Must not fail if already invoked once\n\t//\n\t// Must be safe to call even if StartStep failed or the step was never started.\n\t// This function must be thread-safe for concurrent calls.\n\tDestroyStep(ctx context.Context, step *Step, taskUUID string) error\n\n\t// DestroyWorkflow cleans up all workflow-level resources.\n\t//\n\t// Implementations should:\n\t// - Destroy steps still running in the background (detached steps and services)\n\t// - Remove workflow-specific workspaces, networks, or namespaces\n\t// - Clean up shared volumes or storage\n\t// - Ensure complete cleanup so the taskUUID can be reused later\n\t// - Not affect other workflows that may be running concurrently\n\t//\n\t// Must be safe to call even if SetupWorkflow failed.\n\t// This function may be called concurrently for different workflows\n\t// and must be thread-safe.\n\tDestroyWorkflow(ctx context.Context, conf *Config, taskUUID string) error\n}\n\n// BackendInfo represents the reported information of a loaded backend.\ntype BackendInfo struct {\n\tPlatform string\n}\n"
  },
  {
    "path": "pipeline/backend/types/config.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Config defines the runtime configuration of a workflow.\ntype Config struct {\n\tStages  []*Stage  `json:\"pipeline\"` // workflow stages\n\tNetwork string    `json:\"network\"`  // network definition\n\tVolume  string    `json:\"volume\"`   // volume definition\n\tSecrets []*Secret `json:\"secrets\"`  // secret definitions\n}\n\n// CliCommand is the context key to pass cli context to backends if needed.\nvar CliCommand contextKey\n\n// contextKey is just an empty struct. It exists so CliCommand can be\n// an immutable public variable with a unique type. It's immutable\n// because nobody else can create a ContextKey, being unexported.\ntype contextKey struct{}\n"
  },
  {
    "path": "pipeline/backend/types/conn.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Conn defines a container network connection.\ntype Conn struct {\n\tName    string   `json:\"name\"`\n\tAliases []string `json:\"aliases\"`\n}\n"
  },
  {
    "path": "pipeline/backend/types/errors.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport \"errors\"\n\nvar ErrNoCliContextFound = errors.New(\"no CliContext in context found\")\n"
  },
  {
    "path": "pipeline/backend/types/mocks/mock_Backend.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"github.com/urfave/cli/v3\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// NewMockBackend creates a new instance of MockBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockBackend(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockBackend {\n\tmock := &MockBackend{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockBackend is an autogenerated mock type for the Backend type\ntype MockBackend struct {\n\tmock.Mock\n}\n\ntype MockBackend_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockBackend) EXPECT() *MockBackend_Expecter {\n\treturn &MockBackend_Expecter{mock: &_m.Mock}\n}\n\n// DestroyStep provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error {\n\tret := _mock.Called(ctx, step, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for DestroyStep\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) error); ok {\n\t\tr0 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockBackend_DestroyStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DestroyStep'\ntype MockBackend_DestroyStep_Call struct {\n\t*mock.Call\n}\n\n// DestroyStep is a helper method to define mock.On call\n//   - ctx context.Context\n//   - step *types.Step\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) DestroyStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_DestroyStep_Call {\n\treturn &MockBackend_DestroyStep_Call{Call: _e.mock.On(\"DestroyStep\", ctx, step, taskUUID)}\n}\n\nfunc (_c *MockBackend_DestroyStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_DestroyStep_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Step\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Step)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_DestroyStep_Call) Return(err error) *MockBackend_DestroyStep_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_DestroyStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) error) *MockBackend_DestroyStep_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// DestroyWorkflow provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) DestroyWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error {\n\tret := _mock.Called(ctx, conf, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for DestroyWorkflow\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Config, string) error); ok {\n\t\tr0 = returnFunc(ctx, conf, taskUUID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockBackend_DestroyWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DestroyWorkflow'\ntype MockBackend_DestroyWorkflow_Call struct {\n\t*mock.Call\n}\n\n// DestroyWorkflow is a helper method to define mock.On call\n//   - ctx context.Context\n//   - conf *types.Config\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) DestroyWorkflow(ctx interface{}, conf interface{}, taskUUID interface{}) *MockBackend_DestroyWorkflow_Call {\n\treturn &MockBackend_DestroyWorkflow_Call{Call: _e.mock.On(\"DestroyWorkflow\", ctx, conf, taskUUID)}\n}\n\nfunc (_c *MockBackend_DestroyWorkflow_Call) Run(run func(ctx context.Context, conf *types.Config, taskUUID string)) *MockBackend_DestroyWorkflow_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Config\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Config)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_DestroyWorkflow_Call) Return(err error) *MockBackend_DestroyWorkflow_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_DestroyWorkflow_Call) RunAndReturn(run func(ctx context.Context, conf *types.Config, taskUUID string) error) *MockBackend_DestroyWorkflow_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Flags provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) Flags() []cli.Flag {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Flags\")\n\t}\n\n\tvar r0 []cli.Flag\n\tif returnFunc, ok := ret.Get(0).(func() []cli.Flag); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]cli.Flag)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockBackend_Flags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Flags'\ntype MockBackend_Flags_Call struct {\n\t*mock.Call\n}\n\n// Flags is a helper method to define mock.On call\nfunc (_e *MockBackend_Expecter) Flags() *MockBackend_Flags_Call {\n\treturn &MockBackend_Flags_Call{Call: _e.mock.On(\"Flags\")}\n}\n\nfunc (_c *MockBackend_Flags_Call) Run(run func()) *MockBackend_Flags_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_Flags_Call) Return(flags []cli.Flag) *MockBackend_Flags_Call {\n\t_c.Call.Return(flags)\n\treturn _c\n}\n\nfunc (_c *MockBackend_Flags_Call) RunAndReturn(run func() []cli.Flag) *MockBackend_Flags_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// IsAvailable provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) IsAvailable(ctx context.Context) bool {\n\tret := _mock.Called(ctx)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for IsAvailable\")\n\t}\n\n\tvar r0 bool\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {\n\t\tr0 = returnFunc(ctx)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\treturn r0\n}\n\n// MockBackend_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'\ntype MockBackend_IsAvailable_Call struct {\n\t*mock.Call\n}\n\n// IsAvailable is a helper method to define mock.On call\n//   - ctx context.Context\nfunc (_e *MockBackend_Expecter) IsAvailable(ctx interface{}) *MockBackend_IsAvailable_Call {\n\treturn &MockBackend_IsAvailable_Call{Call: _e.mock.On(\"IsAvailable\", ctx)}\n}\n\nfunc (_c *MockBackend_IsAvailable_Call) Run(run func(ctx context.Context)) *MockBackend_IsAvailable_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_IsAvailable_Call) Return(b bool) *MockBackend_IsAvailable_Call {\n\t_c.Call.Return(b)\n\treturn _c\n}\n\nfunc (_c *MockBackend_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockBackend_IsAvailable_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Load provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) Load(ctx context.Context) (*types.BackendInfo, error) {\n\tret := _mock.Called(ctx)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Load\")\n\t}\n\n\tvar r0 *types.BackendInfo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) (*types.BackendInfo, error)); ok {\n\t\treturn returnFunc(ctx)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) *types.BackendInfo); ok {\n\t\tr0 = returnFunc(ctx)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*types.BackendInfo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = returnFunc(ctx)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockBackend_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'\ntype MockBackend_Load_Call struct {\n\t*mock.Call\n}\n\n// Load is a helper method to define mock.On call\n//   - ctx context.Context\nfunc (_e *MockBackend_Expecter) Load(ctx interface{}) *MockBackend_Load_Call {\n\treturn &MockBackend_Load_Call{Call: _e.mock.On(\"Load\", ctx)}\n}\n\nfunc (_c *MockBackend_Load_Call) Run(run func(ctx context.Context)) *MockBackend_Load_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_Load_Call) Return(backendInfo *types.BackendInfo, err error) *MockBackend_Load_Call {\n\t_c.Call.Return(backendInfo, err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_Load_Call) RunAndReturn(run func(ctx context.Context) (*types.BackendInfo, error)) *MockBackend_Load_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Name provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) Name() string {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Name\")\n\t}\n\n\tvar r0 string\n\tif returnFunc, ok := ret.Get(0).(func() string); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(string)\n\t}\n\treturn r0\n}\n\n// MockBackend_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'\ntype MockBackend_Name_Call struct {\n\t*mock.Call\n}\n\n// Name is a helper method to define mock.On call\nfunc (_e *MockBackend_Expecter) Name() *MockBackend_Name_Call {\n\treturn &MockBackend_Name_Call{Call: _e.mock.On(\"Name\")}\n}\n\nfunc (_c *MockBackend_Name_Call) Run(run func()) *MockBackend_Name_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_Name_Call) Return(s string) *MockBackend_Name_Call {\n\t_c.Call.Return(s)\n\treturn _c\n}\n\nfunc (_c *MockBackend_Name_Call) RunAndReturn(run func() string) *MockBackend_Name_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SetupWorkflow provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) SetupWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error {\n\tret := _mock.Called(ctx, conf, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SetupWorkflow\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Config, string) error); ok {\n\t\tr0 = returnFunc(ctx, conf, taskUUID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockBackend_SetupWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetupWorkflow'\ntype MockBackend_SetupWorkflow_Call struct {\n\t*mock.Call\n}\n\n// SetupWorkflow is a helper method to define mock.On call\n//   - ctx context.Context\n//   - conf *types.Config\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) SetupWorkflow(ctx interface{}, conf interface{}, taskUUID interface{}) *MockBackend_SetupWorkflow_Call {\n\treturn &MockBackend_SetupWorkflow_Call{Call: _e.mock.On(\"SetupWorkflow\", ctx, conf, taskUUID)}\n}\n\nfunc (_c *MockBackend_SetupWorkflow_Call) Run(run func(ctx context.Context, conf *types.Config, taskUUID string)) *MockBackend_SetupWorkflow_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Config\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Config)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_SetupWorkflow_Call) Return(err error) *MockBackend_SetupWorkflow_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_SetupWorkflow_Call) RunAndReturn(run func(ctx context.Context, conf *types.Config, taskUUID string) error) *MockBackend_SetupWorkflow_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StartStep provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) StartStep(ctx context.Context, step *types.Step, taskUUID string) error {\n\tret := _mock.Called(ctx, step, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StartStep\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) error); ok {\n\t\tr0 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockBackend_StartStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartStep'\ntype MockBackend_StartStep_Call struct {\n\t*mock.Call\n}\n\n// StartStep is a helper method to define mock.On call\n//   - ctx context.Context\n//   - step *types.Step\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) StartStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_StartStep_Call {\n\treturn &MockBackend_StartStep_Call{Call: _e.mock.On(\"StartStep\", ctx, step, taskUUID)}\n}\n\nfunc (_c *MockBackend_StartStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_StartStep_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Step\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Step)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_StartStep_Call) Return(err error) *MockBackend_StartStep_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_StartStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) error) *MockBackend_StartStep_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// TailStep provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) TailStep(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) {\n\tret := _mock.Called(ctx, step, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for TailStep\")\n\t}\n\n\tvar r0 io.ReadCloser\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) (io.ReadCloser, error)); ok {\n\t\treturn returnFunc(ctx, step, taskUUID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) io.ReadCloser); ok {\n\t\tr0 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(io.ReadCloser)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *types.Step, string) error); ok {\n\t\tr1 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockBackend_TailStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TailStep'\ntype MockBackend_TailStep_Call struct {\n\t*mock.Call\n}\n\n// TailStep is a helper method to define mock.On call\n//   - ctx context.Context\n//   - step *types.Step\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) TailStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_TailStep_Call {\n\treturn &MockBackend_TailStep_Call{Call: _e.mock.On(\"TailStep\", ctx, step, taskUUID)}\n}\n\nfunc (_c *MockBackend_TailStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_TailStep_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Step\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Step)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_TailStep_Call) Return(readCloser io.ReadCloser, err error) *MockBackend_TailStep_Call {\n\t_c.Call.Return(readCloser, err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_TailStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error)) *MockBackend_TailStep_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WaitStep provides a mock function for the type MockBackend\nfunc (_mock *MockBackend) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) {\n\tret := _mock.Called(ctx, step, taskUUID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WaitStep\")\n\t}\n\n\tvar r0 *types.State\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) (*types.State, error)); ok {\n\t\treturn returnFunc(ctx, step, taskUUID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) *types.State); ok {\n\t\tr0 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*types.State)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *types.Step, string) error); ok {\n\t\tr1 = returnFunc(ctx, step, taskUUID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockBackend_WaitStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitStep'\ntype MockBackend_WaitStep_Call struct {\n\t*mock.Call\n}\n\n// WaitStep is a helper method to define mock.On call\n//   - ctx context.Context\n//   - step *types.Step\n//   - taskUUID string\nfunc (_e *MockBackend_Expecter) WaitStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_WaitStep_Call {\n\treturn &MockBackend_WaitStep_Call{Call: _e.mock.On(\"WaitStep\", ctx, step, taskUUID)}\n}\n\nfunc (_c *MockBackend_WaitStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_WaitStep_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.Step\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.Step)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockBackend_WaitStep_Call) Return(state *types.State, err error) *MockBackend_WaitStep_Call {\n\t_c.Call.Return(state, err)\n\treturn _c\n}\n\nfunc (_c *MockBackend_WaitStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error)) *MockBackend_WaitStep_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "pipeline/backend/types/network.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\ntype Port struct {\n\tNumber   uint16 `json:\"number,omitempty\"`\n\tProtocol string `json:\"protocol,omitempty\"`\n}\n\ntype HostAlias struct {\n\tName string `json:\"name,omitempty\"`\n\tIP   string `json:\"ip,omitempty\"`\n}\n"
  },
  {
    "path": "pipeline/backend/types/secret.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Secret defines a runtime secret.\ntype Secret struct {\n\tName  string `json:\"name,omitempty\"`\n\tValue string `json:\"value,omitempty\"`\n}\n"
  },
  {
    "path": "pipeline/backend/types/stage.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Stage denotes a collection of one or more steps.\ntype Stage struct {\n\tSteps []*Step `json:\"steps,omitempty\"`\n}\n"
  },
  {
    "path": "pipeline/backend/types/state.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// State defines a container state.\ntype State struct {\n\t// Unix start time\n\tStarted int64 `json:\"started\"`\n\t// Container exit code\n\tExitCode int `json:\"exit_code\"`\n\t// Container exited, true or false\n\tExited bool `json:\"exited\"`\n\t// Step was skipped by the runtime (OnSuccess/OnFailure filter)\n\tSkipped bool `json:\"skipped\"`\n\t// Container is oom killed, true or false\n\t// TODO (6024): well known errors as string enum into ./errors.go\n\tOOMKilled bool `json:\"oom_killed\"`\n\t// Container error\n\tError error\n}\n"
  },
  {
    "path": "pipeline/backend/types/step.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// Step defines a container process.\ntype Step struct {\n\tName           string            `json:\"name\"`\n\tOrgID          int64             `json:\"org_id,omitempty\"`\n\tUUID           string            `json:\"uuid\"`\n\tType           StepType          `json:\"type,omitempty\"`\n\tImage          string            `json:\"image,omitempty\"`\n\tPull           bool              `json:\"pull,omitempty\"`\n\tDetached       bool              `json:\"detach,omitempty\"`\n\tPrivileged     bool              `json:\"privileged,omitempty\"`\n\tWorkingDir     string            `json:\"working_dir,omitempty\"`\n\tWorkspaceBase  string            `json:\"workspace_base,omitempty\"`\n\tEnvironment    map[string]string `json:\"environment,omitempty\"`\n\tSecretMapping  map[string]string `json:\"secret_mapping,omitempty\"`\n\tEntrypoint     []string          `json:\"entrypoint,omitempty\"`\n\tCommands       []string          `json:\"commands,omitempty\"`\n\tExtraHosts     []HostAlias       `json:\"extra_hosts,omitempty\"`\n\tVolumes        []string          `json:\"volumes,omitempty\"`\n\tTmpfs          []string          `json:\"tmpfs,omitempty\"`\n\tDevices        []string          `json:\"devices,omitempty\"`\n\tNetworks       []Conn            `json:\"networks,omitempty\"`\n\tDNS            []string          `json:\"dns,omitempty\"`\n\tDNSSearch      []string          `json:\"dns_search,omitempty\"`\n\tOnFailure      bool              `json:\"on_failure,omitempty\"`\n\tOnSuccess      bool              `json:\"on_success,omitempty\"`\n\tFailure        string            `json:\"failure,omitempty\"`\n\tAuthConfig     Auth              `json:\"auth_config\"`\n\tNetworkMode    string            `json:\"network_mode,omitempty\"`\n\tPorts          []Port            `json:\"ports,omitempty\"`\n\tBackendOptions map[string]any    `json:\"backend_options,omitempty\"`\n\tWorkflowLabels map[string]string `json:\"workflow_labels,omitempty\"`\n}\n\n// StepType identifies the type of step.\ntype StepType string\n\nconst (\n\tStepTypeClone    StepType = \"clone\"\n\tStepTypeService  StepType = \"service\"\n\tStepTypePlugin   StepType = \"plugin\"\n\tStepTypeCommands StepType = \"commands\"\n\tStepTypeCache    StepType = \"cache\"\n)\n"
  },
  {
    "path": "pipeline/const.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nconst (\n\tExitCodeKilled int = 137\n\n\t// Store no more than 1mb in a log-line as 4mb is the limit of a grpc message\n\t// and log-lines needs to be parsed by the browsers later on.\n\tMaxLogLineLength int = 1 * 1024 * 1024 // 1mb\n\n\tInternalLabelPrefix string = \"woodpecker-ci.org\"\n\tLabelForgeRemoteID  string = InternalLabelPrefix + \"/forge-id\"\n\tLabelRepoForgeID    string = InternalLabelPrefix + \"/repo-forge-id\"\n\tLabelRepoID         string = InternalLabelPrefix + \"/repo-id\"\n\tLabelRepoName       string = InternalLabelPrefix + \"/repo-name\"\n\tLabelRepoFullName   string = InternalLabelPrefix + \"/repo-full-name\"\n\tLabelBranch         string = InternalLabelPrefix + \"/branch\"\n\tLabelOrgID          string = InternalLabelPrefix + \"/org-id\"\n\tLabelFilterOrg      string = \"org-id\"\n\tLabelFilterRepo     string = \"repo\"\n\tLabelFilterPlatform string = \"platform\"\n\tLabelFilterHostname string = \"hostname\"\n\tLabelFilterBackend  string = \"backend\"\n)\n"
  },
  {
    "path": "pipeline/errors/linter.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage errors\n\nimport (\n\t\"errors\"\n\n\t\"go.uber.org/multierr\"\n)\n\ntype LinterErrorData struct {\n\tFile  string `json:\"file\"`\n\tField string `json:\"field\"`\n}\n\ntype DeprecationErrorData struct {\n\tFile  string `json:\"file\"`\n\tField string `json:\"field\"`\n\tDocs  string `json:\"docs\"`\n}\n\ntype BadHabitErrorData struct {\n\tFile  string `json:\"file\"`\n\tField string `json:\"field\"`\n\tDocs  string `json:\"docs\"`\n}\n\nfunc GetLinterData(e *PipelineError) *LinterErrorData {\n\tif e.Type != PipelineErrorTypeLinter {\n\t\treturn nil\n\t}\n\n\tif data, ok := e.Data.(*LinterErrorData); ok {\n\t\treturn data\n\t}\n\n\treturn nil\n}\n\nfunc GetPipelineErrors(err error) []*PipelineError {\n\tvar pipelineErrors []*PipelineError\n\tfor _, _err := range multierr.Errors(err) {\n\t\tvar err *PipelineError\n\t\tif errors.As(_err, &err) {\n\t\t\tpipelineErrors = append(pipelineErrors, err)\n\t\t} else {\n\t\t\tpipelineErrors = append(pipelineErrors, &PipelineError{\n\t\t\t\tMessage: _err.Error(),\n\t\t\t\tType:    PipelineErrorTypeGeneric,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn pipelineErrors\n}\n\nfunc HasBlockingErrors(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\terrs := GetPipelineErrors(err)\n\n\tfor _, err := range errs {\n\t\tif !err.IsWarning {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pipeline/errors/linter_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage errors_test\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"go.uber.org/multierr\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n)\n\nfunc TestGetPipelineErrors(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\ttitle    string\n\t\terr      error\n\t\texpected []*pipeline_errors.PipelineError\n\t}{\n\t\t{\n\t\t\ttitle:    \"nil error\",\n\t\t\terr:      nil,\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\ttitle: \"warning\",\n\t\t\terr: &pipeline_errors.PipelineError{\n\t\t\t\tIsWarning: true,\n\t\t\t},\n\t\t\texpected: []*pipeline_errors.PipelineError{\n\t\t\t\t{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"pipeline error\",\n\t\t\terr: &pipeline_errors.PipelineError{\n\t\t\t\tIsWarning: false,\n\t\t\t},\n\t\t\texpected: []*pipeline_errors.PipelineError{\n\t\t\t\t{\n\t\t\t\t\tIsWarning: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple warnings\",\n\t\t\terr: multierr.Combine(\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t),\n\t\t\texpected: []*pipeline_errors.PipelineError{\n\t\t\t\t{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple errors and warnings\",\n\t\t\terr: multierr.Combine(\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: false,\n\t\t\t\t},\n\t\t\t\terrors.New(\"some error\"),\n\t\t\t),\n\t\t\texpected: []*pipeline_errors.PipelineError{\n\t\t\t\t{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIsWarning: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:      pipeline_errors.PipelineErrorTypeGeneric,\n\t\t\t\t\tIsWarning: false,\n\t\t\t\t\tMessage:   \"some error\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tassert.Equalf(t, pipeline_errors.GetPipelineErrors(test.err), test.expected, test.title)\n\t}\n}\n\nfunc TestHasBlockingErrors(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\ttitle    string\n\t\terr      error\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\ttitle:    \"nil error\",\n\t\t\terr:      nil,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"warning\",\n\t\t\terr: &pipeline_errors.PipelineError{\n\t\t\t\tIsWarning: true,\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"pipeline error\",\n\t\t\terr: &pipeline_errors.PipelineError{\n\t\t\t\tIsWarning: false,\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple warnings\",\n\t\t\terr: multierr.Combine(\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t),\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple errors and warnings\",\n\t\t\terr: multierr.Combine(\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t},\n\t\t\t\t&pipeline_errors.PipelineError{\n\t\t\t\t\tIsWarning: false,\n\t\t\t\t},\n\t\t\t\terrors.New(\"some error\"),\n\t\t\t),\n\t\t\texpected: true,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tassert.Equal(t, test.expected, pipeline_errors.HasBlockingErrors(test.err))\n\t}\n}\n"
  },
  {
    "path": "pipeline/errors/pipeline.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage errors\n\nimport (\n\t\"fmt\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\ntype PipelineErrorType string\n\nconst (\n\tPipelineErrorTypeLinter      PipelineErrorType = \"linter\"      // some error with the config syntax\n\tPipelineErrorTypeDeprecation PipelineErrorType = \"deprecation\" // using some deprecated feature\n\tPipelineErrorTypeCompiler    PipelineErrorType = \"compiler\"    // some error with the config semantics\n\tPipelineErrorTypeGeneric     PipelineErrorType = \"generic\"     // some generic error\n\tPipelineErrorTypeBadHabit    PipelineErrorType = \"bad_habit\"   // some bad-habit error\n)\n\ntype PipelineError struct {\n\tType      PipelineErrorType `json:\"type\"`\n\tMessage   string            `json:\"message\"`\n\tIsWarning bool              `json:\"is_warning\"`\n\tData      any               `json:\"data\"`\n}\n\nfunc (e *PipelineError) Error() string {\n\treturn fmt.Sprintf(\"[%s] %s\", e.Type, e.Message)\n}\n\ntype ErrInvalidWorkflowSetup struct {\n\tErr  error\n\tStep *backend_types.Step\n}\n\nfunc (e *ErrInvalidWorkflowSetup) Error() string {\n\tif e.Step != nil {\n\t\treturn fmt.Sprintf(\"error in workflow setup step '%s': %v\", e.Step.Name, e.Err)\n\t}\n\treturn fmt.Sprintf(\"error in workflow setup: %v\", e.Err)\n}\n"
  },
  {
    "path": "pipeline/errors/runtime.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage errors\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\nvar (\n\t// ErrSkip is used as a return value when container execution should be\n\t// skipped at runtime. It is not returned as an error by any function.\n\tErrSkip = errors.New(\"Skipped\")\n\n\t// ErrCancel is used as a return value when the container execution receives\n\t// a cancellation signal from the context.\n\tErrCancel = errors.New(\"Canceled\")\n)\n\n// An ExitError reports an unsuccessful exit.\ntype ExitError struct {\n\tUUID string\n\tCode int\n}\n\n// Error returns the error message in string format.\nfunc (e *ExitError) Error() string {\n\treturn fmt.Sprintf(\"uuid=%s: exit code %d\", e.UUID, e.Code)\n}\n\n// An OomError reports the process received an OOMKill from the kernel.\ntype OomError struct {\n\tUUID string\n\tCode int\n}\n\n// Error returns the error message in string format.\nfunc (e *OomError) Error() string {\n\treturn fmt.Sprintf(\"uuid=%s: received oom kill\", e.UUID)\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/const.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\ntype Event string\n\n// Event types corresponding to forge hooks.\nconst (\n\tEventPush         Event = \"push\"\n\tEventPull         Event = \"pull_request\"\n\tEventPullClosed   Event = \"pull_request_closed\"\n\tEventPullMetadata Event = \"pull_request_metadata\"\n\tEventTag          Event = \"tag\"\n\tEventRelease      Event = \"release\"\n\tEventDeploy       Event = \"deployment\"\n\tEventCron         Event = \"cron\"\n\tEventManual       Event = \"manual\"\n)\n\nfunc (event Event) IsPull() bool {\n\tswitch event {\n\tcase EventPull,\n\t\tEventPullClosed,\n\t\tEventPullMetadata:\n\t\treturn true\n\t}\n\treturn false\n}\n\ntype Failure string\n\n// Different ways to handle failure states.\nconst (\n\tFailureIgnore Failure = \"ignore\"\n\tFailureFail   Failure = \"fail\"\n\tFailureCancel Failure = \"cancel\"\n)\n"
  },
  {
    "path": "pipeline/frontend/metadata/drone_compatibility.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\n// SetDroneEnviron set dedicated to DroneCI environment vars as compatibility\n// layer. Main purpose is to be compatible with drone plugins.\nfunc SetDroneEnviron(env map[string]string) {\n\t// webhook\n\tcopyEnv(\"CI_COMMIT_BRANCH\", \"DRONE_BRANCH\", env)\n\tcopyEnv(\"CI_COMMIT_PULL_REQUEST\", \"DRONE_PULL_REQUEST\", env)\n\tcopyEnv(\"CI_COMMIT_PULL_REQUEST\", \"PULLREQUEST_DRONE_PULL_REQUEST\", env)\n\tcopyEnv(\"CI_COMMIT_TAG\", \"DRONE_TAG\", env)\n\tcopyEnv(\"CI_COMMIT_SOURCE_BRANCH\", \"DRONE_SOURCE_BRANCH\", env)\n\tcopyEnv(\"CI_COMMIT_TARGET_BRANCH\", \"DRONE_TARGET_BRANCH\", env)\n\t// pipeline\n\tcopyEnv(\"CI_PIPELINE_NUMBER\", \"DRONE_BUILD_NUMBER\", env)\n\tcopyEnv(\"CI_PIPELINE_PARENT\", \"DRONE_BUILD_PARENT\", env)\n\tcopyEnv(\"CI_PIPELINE_EVENT\", \"DRONE_BUILD_EVENT\", env)\n\tcopyEnv(\"CI_PIPELINE_URL\", \"DRONE_BUILD_LINK\", env)\n\tcopyEnv(\"CI_PIPELINE_CREATED\", \"DRONE_BUILD_CREATED\", env)\n\tcopyEnv(\"CI_PIPELINE_STARTED\", \"DRONE_BUILD_STARTED\", env)\n\t// commit\n\tcopyEnv(\"CI_COMMIT_SHA\", \"DRONE_COMMIT\", env)\n\tcopyEnv(\"CI_COMMIT_SHA\", \"DRONE_COMMIT_SHA\", env)\n\tcopyEnv(\"CI_PREV_COMMIT_SHA\", \"DRONE_COMMIT_BEFORE\", env)\n\tcopyEnv(\"CI_COMMIT_REF\", \"DRONE_COMMIT_REF\", env)\n\tcopyEnv(\"CI_COMMIT_BRANCH\", \"DRONE_COMMIT_BRANCH\", env)\n\tcopyEnv(\"CI_PIPELINE_FORGE_URL\", \"DRONE_COMMIT_LINK\", env)\n\tcopyEnv(\"CI_COMMIT_MESSAGE\", \"DRONE_COMMIT_MESSAGE\", env)\n\tcopyEnv(\"CI_COMMIT_AUTHOR\", \"DRONE_COMMIT_AUTHOR\", env)\n\tcopyEnv(\"CI_COMMIT_AUTHOR\", \"DRONE_COMMIT_AUTHOR_NAME\", env)\n\tcopyEnv(\"CI_COMMIT_AUTHOR_EMAIL\", \"DRONE_COMMIT_AUTHOR_EMAIL\", env)\n\tcopyEnv(\"CI_PIPELINE_AVATAR\", \"DRONE_COMMIT_AUTHOR_AVATAR\", env)\n\t// repo\n\tcopyEnv(\"CI_REPO\", \"DRONE_REPO\", env)\n\tcopyEnv(\"CI_REPO_OWNER\", \"DRONE_REPO_OWNER\", env)\n\tcopyEnv(\"CI_REPO_NAME\", \"DRONE_REPO_NAME\", env)\n\tcopyEnv(\"CI_REPO_URL\", \"DRONE_REPO_LINK\", env)\n\tcopyEnv(\"CI_REPO_DEFAULT_BRANCH\", \"DRONE_REPO_BRANCH\", env)\n\tcopyEnv(\"CI_REPO_PRIVATE\", \"DRONE_REPO_PRIVATE\", env)\n\t// clone\n\tcopyEnv(\"CI_REPO_CLONE_URL\", \"DRONE_REMOTE_URL\", env)\n\tcopyEnv(\"CI_REPO_CLONE_URL\", \"DRONE_GIT_HTTP_URL\", env)\n\t// misc\n\tcopyEnv(\"CI_SYSTEM_HOST\", \"DRONE_SYSTEM_HOST\", env)\n\tcopyEnv(\"CI_STEP_NUMBER\", \"DRONE_STEP_NUMBER\", env)\n\n\tenv[\"DRONE_BUILD_STATUS\"] = \"success\"\n\tenv[\"DRONE_REPO_SCM\"] = \"git\"\n\n\t// some quirks\n\n\t// Legacy env var to prevent the plugin from throwing an error\n\t// when converting an empty string to a number\n\t//\n\t// plugins affected: \"plugins/manifest\"\n\tif env[\"CI_COMMIT_PULL_REQUEST\"] == \"\" {\n\t\tenv[\"PULLREQUEST_DRONE_PULL_REQUEST\"] = \"0\"\n\t}\n}\n\nfunc copyEnv(woodpecker, drone string, env map[string]string) {\n\tvar present bool\n\tvar value string\n\n\tvalue, present = env[woodpecker]\n\tif present {\n\t\tenv[drone] = value\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/drone_compatibility_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata_test\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n)\n\nfunc TestSetDroneEnvironOnPull(t *testing.T) {\n\twoodpeckerVars := `CI=woodpecker\nCI_COMMIT_AUTHOR=6543\nCI_COMMIT_BRANCH=main\nCI_COMMIT_MESSAGE=fix testscript\nCI_COMMIT_PULL_REQUEST=9\nCI_COMMIT_PULL_REQUEST_LABELS=tests,bugfix\nCI_COMMIT_REF=refs/pull/9/head\nCI_COMMIT_REFSPEC=fix_fail-on-err:main\nCI_COMMIT_SHA=a778b069d9f5992786d2db9be493b43868cfce76\nCI_COMMIT_SOURCE_BRANCH=fix_fail-on-err\nCI_COMMIT_TARGET_BRANCH=main\nCI_MACHINE=7939910e431b\nCI_PIPELINE_CREATED=1685749339\nCI_PIPELINE_EVENT=pull_request\nCI_PIPELINE_NUMBER=41\nCI_PIPELINE_STARTED=1685749339\nCI_PREV_COMMIT_AUTHOR=6543\nCI_PREV_COMMIT_BRANCH=main\nCI_PREV_COMMIT_MESSAGE=Print filename and linenuber on fail\nCI_PREV_COMMIT_REF=refs/pull/13/head\nCI_PREV_COMMIT_REFSPEC=print_file_and_line:main\nCI_PREV_COMMIT_SHA=e246aff5a9466df2e522efc9007823a7496d9d41\nCI_PREV_PIPELINE_CREATED=1685748680\nCI_PREV_PIPELINE_EVENT=pull_request\nCI_PREV_PIPELINE_FINISHED=1685748704\nCI_PREV_PIPELINE_NUMBER=40\nCI_PREV_PIPELINE_STARTED=1685748680\nCI_PREV_PIPELINE_STATUS=success\nCI_REPO=Epsilon_02/todo-checker\nCI_REPO_CLONE_URL=https://codeberg.org/Epsilon_02/todo-checker.git\nCI_REPO_DEFAULT_BRANCH=main\nCI_REPO_NAME=todo-checker\nCI_REPO_OWNER=Epsilon_02\nCI_STEP_NAME=wp_01h1z7v5d1tskaqjexw0ng6w7d_0_step_3\nCI_STEP_STARTED=1685749339\nCI_SYSTEM_PLATFORM=linux/amd64\nCI_SYSTEM_HOST=ci.codeberg.org\nCI_SYSTEM_NAME=woodpecker\nCI_SYSTEM_VERSION=next-dd644da3\nCI_WORKFLOW_NAME=woodpecker\nCI_WORKFLOW_NUMBER=1\nCI_WORKSPACE=/woodpecker/src/codeberg.org/Epsilon_02/todo-checker`\n\n\tdroneVars := `DRONE_BRANCH=main\nDRONE_BUILD_CREATED=1685749339\nDRONE_BUILD_EVENT=pull_request\nDRONE_BUILD_NUMBER=41\nDRONE_BUILD_STARTED=1685749339\nDRONE_BUILD_STATUS=success\nDRONE_COMMIT=a778b069d9f5992786d2db9be493b43868cfce76\nDRONE_COMMIT_AUTHOR=6543\nDRONE_COMMIT_AUTHOR_NAME=6543\nDRONE_COMMIT_BEFORE=e246aff5a9466df2e522efc9007823a7496d9d41\nDRONE_COMMIT_BRANCH=main\nDRONE_COMMIT_MESSAGE=fix testscript\nDRONE_COMMIT_REF=refs/pull/9/head\nDRONE_COMMIT_SHA=a778b069d9f5992786d2db9be493b43868cfce76\nDRONE_GIT_HTTP_URL=https://codeberg.org/Epsilon_02/todo-checker.git\nDRONE_PULL_REQUEST=9\nDRONE_REMOTE_URL=https://codeberg.org/Epsilon_02/todo-checker.git\nDRONE_REPO=Epsilon_02/todo-checker\nDRONE_REPO_BRANCH=main\nDRONE_REPO_NAME=todo-checker\nDRONE_REPO_OWNER=Epsilon_02\nDRONE_REPO_SCM=git\nDRONE_SOURCE_BRANCH=fix_fail-on-err\nDRONE_SYSTEM_HOST=ci.codeberg.org\nDRONE_TARGET_BRANCH=main\nPULLREQUEST_DRONE_PULL_REQUEST=9`\n\n\tenv := convertListToEnvMap(t, woodpeckerVars)\n\tmetadata.SetDroneEnviron(env)\n\t// filter only new added env vars\n\tfor k := range convertListToEnvMap(t, woodpeckerVars) {\n\t\tdelete(env, k)\n\t}\n\tassert.EqualValues(t, convertListToEnvMap(t, droneVars), env)\n}\n\nfunc TestSetDroneEnvironOnPush(t *testing.T) {\n\twoodpeckerVars := `CI_COMMIT_AUTHOR=test\nCI_COMMIT_AUTHOR_EMAIL=test@noreply.localhost\nCI_COMMIT_BRANCH=main\nCI_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463\nCI_COMMIT_PULL_REQUEST=\nCI_COMMIT_PULL_REQUEST_LABELS=\nCI_COMMIT_REF=refs/heads/main\nCI_COMMIT_REFSPEC=\nCI_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523\nCI_COMMIT_SOURCE_BRANCH=\nCI_COMMIT_TAG=\nCI_COMMIT_TARGET_BRANCH=\nCI_FORGE_TYPE=gitea\nCI_FORGE_URL=http://1.2.3.4:3000\nCI_MACHINE=hagalaz\nCI_PIPELINE_CREATED=1721328737\nCI_PIPELINE_DEPLOY_TARGET=\nCI_PIPELINE_DEPLOY_TASK=\nCI_PIPELINE_EVENT=push\nCI_PIPELINE_FILES=[\".woodpecker.yaml\"]\nCI_PIPELINE_FORGE_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523\nCI_PIPELINE_NUMBER=24\nCI_PIPELINE_PARENT=23\nCI_PIPELINE_STARTED=1721328737\nCI_PIPELINE_URL=http://1.2.3.4:8000/repos/2/pipeline/24\nCI_PREV_COMMIT_AUTHOR=test\nCI_PREV_COMMIT_AUTHOR_EMAIL=test@noreply.localhost\nCI_PREV_COMMIT_BRANCH=main\nCI_PREV_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463\nCI_PREV_COMMIT_REF=refs/heads/main\nCI_PREV_COMMIT_REFSPEC=\nCI_PREV_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523\nCI_PREV_COMMIT_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523\nCI_PREV_COMMIT_SOURCE_BRANCH=\nCI_PREV_COMMIT_TARGET_BRANCH=\nCI_PREV_PIPELINE_CREATED=1721086039\nCI_PREV_PIPELINE_DEPLOY_TARGET=\nCI_PREV_PIPELINE_DEPLOY_TASK=\nCI_PREV_PIPELINE_EVENT=push\nCI_PREV_PIPELINE_FINISHED=1721086056\nCI_PREV_PIPELINE_FORGE_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523\nCI_PREV_PIPELINE_NUMBER=23\nCI_PREV_PIPELINE_PARENT=0\nCI_PREV_PIPELINE_STARTED=1721086039\nCI_PREV_PIPELINE_STATUS=failure\nCI_PREV_PIPELINE_URL=http://1.2.3.4:8000/repos/2/pipeline/23\nCI_REPO=test/woodpecker-test\nCI_REPO_CLONE_SSH_URL=user@1.2.3.4:test/woodpecker-test.git\nCI_REPO_CLONE_URL=http://1.2.3.4:3000/test/woodpecker-test.git\nCI_REPO_DEFAULT_BRANCH=main\nCI_REPO_NAME=woodpecker-test\nCI_REPO_OWNER=test\nCI_REPO_PRIVATE=false\nCI_REPO_REMOTE_ID=4\nCI_REPO_TRUSTED=false\nCI_REPO_TRUSTED_NETWORK=false\nCI_REPO_TRUSTED_VOLUMES=false\nCI_REPO_TRUSTED_SECURITY=false\nCI_REPO_URL=http://1.2.3.4:3000/test/woodpecker-test\nCI_STEP_NAME=\nCI_STEP_NUMBER=0\nCI_STEP_STARTED=1721328737\nCI_STEP_URL=http://1.2.3.4:8000/repos/2/pipeline/24\nCI_SYSTEM_HOST=1.2.3.4:8000\nCI_SYSTEM_NAME=woodpecker\nCI_SYSTEM_PLATFORM=linux/amd64\nCI_SYSTEM_URL=http://1.2.3.4:8000\nCI_SYSTEM_VERSION=2.7.0\nCI_WORKFLOW_NAME=woodpecker\nCI_WORKFLOW_NUMBER=1\nCI_WORKSPACE=/usr/local/src/1.2.3.4/test/woodpecker-test`\n\n\tdroneVars := `DRONE_BRANCH=main\nDRONE_BUILD_CREATED=1721328737\nDRONE_BUILD_EVENT=push\nDRONE_BUILD_LINK=http://1.2.3.4:8000/repos/2/pipeline/24\nDRONE_BUILD_NUMBER=24\nDRONE_BUILD_PARENT=23\nDRONE_BUILD_STARTED=1721328737\nDRONE_BUILD_STATUS=success\nDRONE_COMMIT=8826c98181353075bbeee8f99b400496488e3523\nDRONE_COMMIT_AUTHOR=test\nDRONE_COMMIT_AUTHOR_EMAIL=test@noreply.localhost\nDRONE_COMMIT_AUTHOR_NAME=test\nDRONE_COMMIT_BEFORE=8826c98181353075bbeee8f99b400496488e3523\nDRONE_COMMIT_BRANCH=main\nDRONE_COMMIT_LINK=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523\nDRONE_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463\nDRONE_COMMIT_REF=refs/heads/main\nDRONE_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523\nDRONE_GIT_HTTP_URL=http://1.2.3.4:3000/test/woodpecker-test.git\nDRONE_PULL_REQUEST=\nDRONE_REMOTE_URL=http://1.2.3.4:3000/test/woodpecker-test.git\nDRONE_REPO=test/woodpecker-test\nDRONE_REPO_BRANCH=main\nDRONE_REPO_LINK=http://1.2.3.4:3000/test/woodpecker-test\nDRONE_REPO_NAME=woodpecker-test\nDRONE_REPO_OWNER=test\nDRONE_REPO_PRIVATE=false\nDRONE_REPO_SCM=git\nDRONE_SOURCE_BRANCH=\nDRONE_STEP_NUMBER=0\nDRONE_SYSTEM_HOST=1.2.3.4:8000\nDRONE_TAG=\nDRONE_TARGET_BRANCH=\nPULLREQUEST_DRONE_PULL_REQUEST=0`\n\n\tenv := convertListToEnvMap(t, woodpeckerVars)\n\tmetadata.SetDroneEnviron(env)\n\t// filter only new added env vars\n\tfor k := range convertListToEnvMap(t, woodpeckerVars) {\n\t\tdelete(env, k)\n\t}\n\tassert.EqualValues(t, convertListToEnvMap(t, droneVars), env)\n}\n\nfunc convertListToEnvMap(t *testing.T, list string) map[string]string {\n\tresult := make(map[string]string)\n\tfor _, s := range strings.Split(list, \"\\n\") {\n\t\tbefore, after, _ := strings.Cut(strings.TrimSpace(s), \"=\")\n\t\tif before == \"\" {\n\t\t\tt.Fatal(\"helper function got invalid test data\")\n\t\t}\n\t\tresult[before] = after\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/environment.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n)\n\nconst (\n\tinitialEnvMapSize = 100\n\tmaxChangedFiles   = 500\n)\n\nvar pullRegexp = regexp.MustCompile(`\\d+`)\n\n// Environ returns the metadata as a map of environment variables.\nfunc (m *Metadata) Environ() map[string]string {\n\tparams := make(map[string]string, initialEnvMapSize)\n\n\tsystem := m.Sys\n\tsetNonEmptyEnvVar(params, \"CI\", system.Name)\n\tsetNonEmptyEnvVar(params, \"CI_SYSTEM_NAME\", system.Name)\n\tsetNonEmptyEnvVar(params, \"CI_SYSTEM_URL\", system.URL)\n\tsetNonEmptyEnvVar(params, \"CI_SYSTEM_HOST\", system.Host)\n\tsetNonEmptyEnvVar(params, \"CI_SYSTEM_PLATFORM\", system.Platform) // will be set by pipeline platform option or by agent\n\tsetNonEmptyEnvVar(params, \"CI_SYSTEM_VERSION\", system.Version)\n\n\tforge := m.Forge\n\tsetNonEmptyEnvVar(params, \"CI_FORGE_TYPE\", forge.Type)\n\tsetNonEmptyEnvVar(params, \"CI_FORGE_URL\", forge.URL)\n\n\trepo := m.Repo\n\tsetNonEmptyEnvVar(params, \"CI_REPO\", path.Join(repo.Owner, repo.Name))\n\tsetNonEmptyEnvVar(params, \"CI_REPO_NAME\", repo.Name)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_OWNER\", repo.Owner)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_REMOTE_ID\", repo.RemoteID)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_URL\", repo.ForgeURL)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_CLONE_URL\", repo.CloneURL)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_CLONE_SSH_URL\", repo.CloneSSHURL)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_DEFAULT_BRANCH\", repo.Branch)\n\tsetNonEmptyEnvVar(params, \"CI_REPO_PRIVATE\", strconv.FormatBool(repo.Private))\n\tsetNonEmptyEnvVar(params, \"CI_REPO_TRUSTED_NETWORK\", strconv.FormatBool(repo.Trusted.Network))\n\tsetNonEmptyEnvVar(params, \"CI_REPO_TRUSTED_VOLUMES\", strconv.FormatBool(repo.Trusted.Volumes))\n\tsetNonEmptyEnvVar(params, \"CI_REPO_TRUSTED_SECURITY\", strconv.FormatBool(repo.Trusted.Security))\n\t// Deprecated remove in 4.x\n\tsetNonEmptyEnvVar(params, \"CI_REPO_TRUSTED\", strconv.FormatBool(m.Repo.Trusted.Security && m.Repo.Trusted.Network && m.Repo.Trusted.Volumes))\n\n\tpipeline := m.Curr\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_NUMBER\", strconv.FormatInt(pipeline.Number, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_PARENT\", strconv.FormatInt(pipeline.Parent, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_EVENT\", string(pipeline.Event))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_EVENT_REASON\", strings.Join(pipeline.EventReason, \",\"))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_URL\", m.getPipelineWebURL(pipeline, 0))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_FORGE_URL\", pipeline.ForgeURL)\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_DEPLOY_TARGET\", pipeline.DeployTo)\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_DEPLOY_TASK\", pipeline.DeployTask)\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_CREATED\", strconv.FormatInt(pipeline.Created, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_STARTED\", strconv.FormatInt(pipeline.Started, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_AUTHOR\", pipeline.Author)\n\tsetNonEmptyEnvVar(params, \"CI_PIPELINE_AVATAR\", pipeline.Avatar)\n\n\tworkflow := m.Workflow\n\tsetNonEmptyEnvVar(params, \"CI_WORKFLOW_NAME\", workflow.Name)\n\tsetNonEmptyEnvVar(params, \"CI_WORKFLOW_NUMBER\", strconv.Itoa(workflow.Number))\n\n\tstep := m.Step\n\tsetNonEmptyEnvVar(params, \"CI_STEP_NAME\", step.Name)\n\tsetNonEmptyEnvVar(params, \"CI_STEP_NUMBER\", strconv.Itoa(step.Number))\n\tsetNonEmptyEnvVar(params, \"CI_STEP_URL\", m.getPipelineWebURL(pipeline, step.Number))\n\t// CI_STEP_STARTED will be set by agent\n\n\tcommit := pipeline.Commit\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_SHA\", commit.Sha)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_REF\", commit.Ref)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_REFSPEC\", commit.Refspec)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_MESSAGE\", commit.Message)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_BRANCH\", commit.Branch)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_AUTHOR\", commit.Author.Name)\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_AUTHOR_EMAIL\", commit.Author.Email)\n\tif p, f := strings.CutPrefix(pipeline.Commit.Ref, \"refs/tags/\"); f {\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_TAG\", p)\n\t}\n\tif pipeline.Event == EventRelease {\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_PRERELEASE\", strconv.FormatBool(pipeline.Commit.IsPrerelease))\n\t}\n\tif pipeline.Event.IsPull() {\n\t\tsourceBranch, targetBranch := getSourceTargetBranches(commit.Refspec)\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_SOURCE_BRANCH\", sourceBranch)\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_TARGET_BRANCH\", targetBranch)\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_PULL_REQUEST\", pullRegexp.FindString(pipeline.Commit.Ref))\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_PULL_REQUEST_LABELS\", strings.Join(pipeline.Commit.PullRequestLabels, \",\"))\n\t\tsetNonEmptyEnvVar(params, \"CI_COMMIT_PULL_REQUEST_MILESTONE\", pipeline.Commit.PullRequestMilestone)\n\t}\n\n\t// Only export changed files if maxChangedFiles is not exceeded\n\tchangedFiles := commit.ChangedFiles\n\tif len(changedFiles) == 0 {\n\t\tparams[\"CI_PIPELINE_FILES\"] = \"[]\"\n\t} else if len(changedFiles) <= maxChangedFiles {\n\t\t// we have to use json, as other separators like ;, or space are valid filename chars\n\t\tchangedFiles, err := json.Marshal(changedFiles)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"marshal changed files\")\n\t\t}\n\t\tparams[\"CI_PIPELINE_FILES\"] = string(changedFiles)\n\t}\n\n\tprevPipeline := m.Prev\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_NUMBER\", strconv.FormatInt(prevPipeline.Number, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_PARENT\", strconv.FormatInt(prevPipeline.Parent, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_EVENT\", string(prevPipeline.Event))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_EVENT_REASON\", strings.Join(prevPipeline.EventReason, \",\"))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_URL\", m.getPipelineWebURL(prevPipeline, 0))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_FORGE_URL\", prevPipeline.ForgeURL)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_URL\", prevPipeline.ForgeURL) // why commit url?\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_DEPLOY_TARGET\", prevPipeline.DeployTo)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_DEPLOY_TASK\", prevPipeline.DeployTask)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_STATUS\", prevPipeline.Status)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_CREATED\", strconv.FormatInt(prevPipeline.Created, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_STARTED\", strconv.FormatInt(prevPipeline.Started, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_FINISHED\", strconv.FormatInt(prevPipeline.Finished, 10))\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_AUTHOR\", prevPipeline.Author)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_PIPELINE_AVATAR\", prevPipeline.Avatar)\n\n\tprevCommit := prevPipeline.Commit\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_SHA\", prevCommit.Sha)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_REF\", prevCommit.Ref)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_REFSPEC\", prevCommit.Refspec)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_MESSAGE\", prevCommit.Message)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_BRANCH\", prevCommit.Branch)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_AUTHOR\", prevCommit.Author.Name)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_AUTHOR_EMAIL\", prevCommit.Author.Email)\n\tif prevPipeline.Event.IsPull() {\n\t\tprevSourceBranch, prevTargetBranch := getSourceTargetBranches(prevCommit.Refspec)\n\t\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_SOURCE_BRANCH\", prevSourceBranch)\n\t\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_TARGET_BRANCH\", prevTargetBranch)\n\t}\n\n\t// TODO Deprecated, remove in next major\n\tsetNonEmptyEnvVar(params, \"CI_COMMIT_AUTHOR_AVATAR\", pipeline.Avatar)\n\tsetNonEmptyEnvVar(params, \"CI_PREV_COMMIT_AUTHOR_AVATAR\", prevPipeline.Avatar)\n\n\treturn params\n}\n\nfunc (m *Metadata) getPipelineWebURL(pipeline Pipeline, stepNumber int) string {\n\tif stepNumber == 0 {\n\t\treturn fmt.Sprintf(\"%s/repos/%d/pipeline/%d\", m.Sys.URL, m.Repo.ID, pipeline.Number)\n\t}\n\n\treturn fmt.Sprintf(\"%s/repos/%d/pipeline/%d/%d\", m.Sys.URL, m.Repo.ID, pipeline.Number, stepNumber)\n}\n\nfunc getSourceTargetBranches(refspec string) (string, string) {\n\tvar (\n\t\tsourceBranch string\n\t\ttargetBranch string\n\t)\n\n\tbranchParts := strings.Split(refspec, \":\")\n\tif len(branchParts) == 2 { //nolint:mnd\n\t\tsourceBranch = branchParts[0]\n\t\ttargetBranch = branchParts[1]\n\t}\n\n\treturn sourceBranch, targetBranch\n}\n\nfunc setNonEmptyEnvVar(env map[string]string, key, value string) {\n\tif len(value) > 0 {\n\t\tenv[key] = value\n\t} else {\n\t\tlog.Trace().Str(\"variable\", key).Msg(\"env var is filtered as it's empty\")\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/environment_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestEnviron(t *testing.T) {\n\tm := Metadata{\n\t\tSys: System{Name: \"wp\"},\n\t\tCurr: Pipeline{\n\t\t\tEvent: EventRelease,\n\t\t\tCommit: Commit{\n\t\t\t\tRef:          \"refs/tags/v1.2.3\",\n\t\t\t\tIsPrerelease: true,\n\t\t\t},\n\t\t},\n\t\tPrev: Pipeline{\n\t\t\tEvent: EventPullMetadata,\n\t\t\tCommit: Commit{\n\t\t\t\tRefspec: \"branch-a:branch-b\",\n\t\t\t},\n\t\t},\n\t}\n\n\tenvs := m.Environ()\n\tassert.Equal(t, \"wp\", envs[\"CI\"])\n\tassert.Equal(t, \"release\", envs[\"CI_PIPELINE_EVENT\"])\n\tassert.Equal(t, \"pull_request_metadata\", envs[\"CI_PREV_PIPELINE_EVENT\"])\n\tassert.Equal(t, \"true\", envs[\"CI_COMMIT_PRERELEASE\"])\n\tassert.Equal(t, \"branch-a\", envs[\"CI_PREV_COMMIT_SOURCE_BRANCH\"])\n\tassert.Equal(t, \"branch-b\", envs[\"CI_PREV_COMMIT_TARGET_BRANCH\"])\n\tassert.Equal(t, \"[]\", envs[\"CI_PIPELINE_FILES\"])\n\tassert.Equal(t, \"v1.2.3\", envs[\"CI_COMMIT_TAG\"])\n\n\tm = Metadata{\n\t\tSys: System{Name: \"wp\"},\n\t\tCurr: Pipeline{\n\t\t\tEvent: EventPull,\n\t\t\tCommit: Commit{\n\t\t\t\tChangedFiles: []string{\"readme\", \"license\"},\n\t\t\t\tRefspec:      \"branch-a:branch-b\",\n\t\t\t},\n\t\t},\n\t\tPrev: Pipeline{\n\t\t\tEvent: EventPull,\n\t\t\tCommit: Commit{\n\t\t\t\tRefspec: \"branch-a:branch-b\",\n\t\t\t},\n\t\t},\n\t}\n\n\tenvs = m.Environ()\n\n\t_, ok := envs[\"CI_COMMIT_TAG\"]\n\tassert.False(t, ok)\n\n\tassert.Equal(t, `[\"readme\",\"license\"]`, envs[\"CI_PIPELINE_FILES\"])\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/substitution.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/drone/envsubst\"\n)\n\nfunc EnvVarSubst(yaml string, environ map[string]string) (string, error) {\n\treturn envsubst.Eval(yaml, func(name string) string {\n\t\tenv := environ[name]\n\t\tif strings.Contains(env, \"\\n\") {\n\t\t\tenv = fmt.Sprintf(\"%q\", env)\n\t\t}\n\t\treturn env\n\t})\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/substitution_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestEnvVarSubst(t *testing.T) {\n\tresult, err := EnvVarSubst(`steps:\n\t\tstep1:\n\t\t\timage: ${HELLO_IMAGE}\n\t\t\tcommand: echo ${NEWLINE}`, map[string]string{\"HELLO_IMAGE\": \"hello-world\", \"NEWLINE\": \"some env\\nwith newline\"})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, `steps:\n\t\tstep1:\n\t\t\timage: hello-world\n\t\t\tcommand: echo \"some env\\nwith newline\"`, result)\n}\n"
  },
  {
    "path": "pipeline/frontend/metadata/types.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metadata\n\ntype (\n\t// Metadata defines runtime m.\n\tMetadata struct {\n\t\tID       string   `json:\"id,omitempty\"`\n\t\tRepo     Repo     `json:\"repo,omitempty\"`\n\t\tCurr     Pipeline `json:\"curr,omitempty\"`\n\t\tPrev     Pipeline `json:\"prev,omitempty\"`\n\t\tWorkflow Workflow `json:\"workflow,omitempty\"`\n\t\tStep     Step     `json:\"step,omitempty\"`\n\t\tSys      System   `json:\"sys,omitempty\"`\n\t\tForge    Forge    `json:\"forge,omitempty\"`\n\t}\n\n\t// Repo defines runtime metadata for a repository.\n\tRepo struct {\n\t\tID          int64                `json:\"id,omitempty\"`\n\t\tName        string               `json:\"name,omitempty\"`\n\t\tOwner       string               `json:\"owner,omitempty\"`\n\t\tRemoteID    string               `json:\"remote_id,omitempty\"`\n\t\tForgeURL    string               `json:\"forge_url,omitempty\"`\n\t\tCloneURL    string               `json:\"clone_url,omitempty\"`\n\t\tCloneSSHURL string               `json:\"clone_url_ssh,omitempty\"`\n\t\tPrivate     bool                 `json:\"private,omitempty\"`\n\t\tBranch      string               `json:\"default_branch,omitempty\"`\n\t\tTrusted     TrustedConfiguration `json:\"trusted,omitempty\"`\n\t}\n\n\t// Pipeline defines runtime metadata for a pipeline.\n\tPipeline struct {\n\t\tNumber      int64    `json:\"number,omitempty\"`\n\t\tCreated     int64    `json:\"created,omitempty\"`\n\t\tStarted     int64    `json:\"started,omitempty\"`\n\t\tFinished    int64    `json:\"finished,omitempty\"`\n\t\tStatus      string   `json:\"status,omitempty\"`\n\t\tEvent       Event    `json:\"event,omitempty\"`\n\t\tEventReason []string `json:\"event_reason,omitempty\"`\n\t\tForgeURL    string   `json:\"forge_url,omitempty\"`\n\t\tDeployTo    string   `json:\"target,omitempty\"`\n\t\tDeployTask  string   `json:\"task,omitempty\"`\n\t\tCommit      Commit   `json:\"commit\"`\n\t\tParent      int64    `json:\"parent,omitempty\"`\n\t\tCron        string   `json:\"cron,omitempty\"`\n\t\tAuthor      string   `json:\"author,omitempty\"`\n\t\tAvatar      string   `json:\"avatar,omitempty\"`\n\t}\n\n\t// Commit defines runtime metadata for a commit.\n\tCommit struct {\n\t\tSha                  string   `json:\"sha,omitempty\"`\n\t\tRef                  string   `json:\"ref,omitempty\"`\n\t\tRefspec              string   `json:\"refspec,omitempty\"`\n\t\tBranch               string   `json:\"branch,omitempty\"`\n\t\tMessage              string   `json:\"message,omitempty\"`\n\t\tAuthor               Author   `json:\"author\"`\n\t\tChangedFiles         []string `json:\"changed_files,omitempty\"`\n\t\tPullRequestLabels    []string `json:\"labels,omitempty\"`\n\t\tPullRequestMilestone string   `json:\"milestone,omitempty\"`\n\t\tIsPrerelease         bool     `json:\"is_prerelease,omitempty\"`\n\t}\n\n\t// Author defines runtime metadata for a commit author.\n\tAuthor struct {\n\t\tName  string `json:\"name,omitempty\"`\n\t\tEmail string `json:\"email,omitempty\"`\n\t}\n\n\t// Workflow defines runtime metadata for a workflow.\n\tWorkflow struct {\n\t\tName   string            `json:\"name,omitempty\"`\n\t\tNumber int               `json:\"number,omitempty\"`\n\t\tMatrix map[string]string `json:\"matrix,omitempty\"`\n\t}\n\n\t// Step defines runtime metadata for a step.\n\tStep struct {\n\t\tName   string `json:\"name,omitempty\"`\n\t\tNumber int    `json:\"number,omitempty\"`\n\t}\n\n\t// System defines runtime metadata for a ci/cd system.\n\tSystem struct {\n\t\tName     string `json:\"name,omitempty\"`\n\t\tHost     string `json:\"host,omitempty\"`\n\t\tURL      string `json:\"url,omitempty\"`\n\t\tPlatform string `json:\"arch,omitempty\"`\n\t\tVersion  string `json:\"version,omitempty\"`\n\t}\n\n\t// Forge defines runtime metadata about the forge that host the repo.\n\tForge struct {\n\t\tType string `json:\"type,omitempty\"`\n\t\tURL  string `json:\"url,omitempty\"`\n\t}\n\n\t// ServerForge represent the needed func of a server forge to get its metadata.\n\tServerForge interface {\n\t\t// Name returns the string name of this driver\n\t\tName() string\n\t\t// URL returns the root url of a configured forge\n\t\tURL() string\n\t}\n\n\tTrustedConfiguration struct {\n\t\tNetwork  bool `json:\"network,omitempty\"`\n\t\tVolumes  bool `json:\"volumes,omitempty\"`\n\t\tSecurity bool `json:\"security,omitempty\"`\n\t}\n)\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/compiler.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"path\"\n\t\"slices\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\tyaml_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\nconst (\n\tdefaultCloneName = \"clone\"\n)\n\n// Registry represents registry credentials.\ntype Registry struct {\n\tHostname string\n\tUsername string\n\tPassword string\n}\n\ntype Secret struct {\n\tName           string\n\tValue          string\n\tAllowedPlugins []string\n\tEvents         []metadata.Event\n}\n\nfunc (s *Secret) Available(event metadata.Event, container *yaml_types.Container) error {\n\tonlyAllowSecretForPlugins := len(s.AllowedPlugins) > 0\n\tif onlyAllowSecretForPlugins && !container.IsPlugin() {\n\t\treturn fmt.Errorf(\"secret %q is only allowed to be used by plugins (a filter has been set on the secret). Note: Image filters do not work for normal steps\", s.Name)\n\t}\n\n\tif onlyAllowSecretForPlugins && !utils.MatchImageDynamic(container.Image, s.AllowedPlugins...) {\n\t\treturn fmt.Errorf(\"secret %q is not allowed to be used with image %q by step %q\", s.Name, container.Image, container.Name)\n\t}\n\n\tif !s.Match(event) {\n\t\treturn fmt.Errorf(\"secret %q is not allowed to be used with pipeline event %q\", s.Name, event)\n\t}\n\n\treturn nil\n}\n\n// Match returns true if an image and event match the restricted list.\n// Note that EventPullClosed are treated as EventPull.\nfunc (s *Secret) Match(event metadata.Event) bool {\n\t// if there is no filter set secret matches all webhook events\n\tif len(s.Events) == 0 {\n\t\treturn true\n\t}\n\t// treat all pull events the same way\n\tif event.IsPull() {\n\t\tevent = metadata.EventPull\n\t}\n\t// one match is enough\n\treturn slices.Contains(s.Events, event)\n}\n\n// Compiler compiles the yaml.\ntype Compiler struct {\n\tlocal                   bool\n\tescalated               []string\n\tprefix                  string\n\tvolumes                 []string\n\tnetworks                []string\n\tenv                     map[string]string\n\tcloneEnv                map[string]string\n\tworkspaceBase           string\n\tworkspacePath           string\n\tmetadata                metadata.Metadata\n\tregistries              []Registry\n\tsecrets                 map[string]Secret\n\tdefaultClonePlugin      string\n\ttrustedClonePlugins     []string\n\tsecurityTrustedPipeline bool\n\t// TODO: remove with version 4.x\n\tforceIgnoreServiceFailure bool\n}\n\n// New creates a new Compiler with options.\nfunc New(opts ...Option) *Compiler {\n\tcompiler := &Compiler{\n\t\tenv:                 map[string]string{},\n\t\tcloneEnv:            map[string]string{},\n\t\tsecrets:             map[string]Secret{},\n\t\tdefaultClonePlugin:  constant.DefaultClonePlugin,\n\t\ttrustedClonePlugins: constant.TrustedClonePlugins,\n\t}\n\tfor _, opt := range opts {\n\t\topt(compiler)\n\t}\n\treturn compiler\n}\n\n// Compile compiles the YAML configuration to the pipeline intermediate\n// representation configuration format.\nfunc (c *Compiler) Compile(conf *yaml_types.Workflow) (*backend_types.Config, error) {\n\tconfig := new(backend_types.Config)\n\n\tif match, err := conf.When.Match(c.metadata, true, c.env); !match && err == nil {\n\t\t// This pipeline does not match the configured filter so return an empty config and stop further compilation.\n\t\t// An empty pipeline will just be skipped completely.\n\t\treturn config, nil\n\t} else if err != nil {\n\t\treturn nil, err\n\t}\n\n\t// create a default volume\n\tconfig.Volume = fmt.Sprintf(\"%s_default\", c.prefix)\n\n\t// create a default network\n\tconfig.Network = fmt.Sprintf(\"%s_default\", c.prefix)\n\n\t// create secrets for mask\n\tfor _, sec := range c.secrets {\n\t\tconfig.Secrets = append(config.Secrets, &backend_types.Secret{\n\t\t\tName:  sec.Name,\n\t\t\tValue: sec.Value,\n\t\t})\n\t}\n\n\t// overrides the default workspace paths when specified\n\t// in the YAML file.\n\tif len(conf.Workspace.Base) != 0 {\n\t\tc.workspaceBase = path.Clean(conf.Workspace.Base)\n\t}\n\tif len(conf.Workspace.Path) != 0 {\n\t\tc.workspacePath = path.Clean(conf.Workspace.Path)\n\t}\n\n\t// add default clone step\n\tif !c.local && len(conf.Clone.ContainerList) == 0 && !conf.SkipClone && len(c.defaultClonePlugin) != 0 {\n\t\tcloneSettings := map[string]any{\"depth\": \"0\"}\n\t\tif c.metadata.Curr.Event == metadata.EventTag {\n\t\t\tcloneSettings[\"tags\"] = \"true\"\n\t\t}\n\t\tcontainer := &yaml_types.Container{\n\t\t\tName:        defaultCloneName,\n\t\t\tImage:       c.defaultClonePlugin,\n\t\t\tSettings:    cloneSettings,\n\t\t\tEnvironment: make(map[string]any),\n\t\t}\n\t\tfor k, v := range c.cloneEnv {\n\t\t\tcontainer.Environment[k] = v\n\t\t}\n\t\tstep, err := c.createProcess(container, conf, backend_types.StepTypeClone)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tstage := new(backend_types.Stage)\n\t\tstage.Steps = append(stage.Steps, step)\n\n\t\tconfig.Stages = append(config.Stages, stage)\n\t} else if !c.local && !conf.SkipClone {\n\t\tfor _, container := range conf.Clone.ContainerList {\n\t\t\tif match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil {\n\t\t\t\tcontinue\n\t\t\t} else if err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tstage := new(backend_types.Stage)\n\n\t\t\tstep, err := c.createProcess(container, conf, backend_types.StepTypeClone)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\t// only inject netrc if it's a trusted repo or a trusted plugin\n\t\t\tif c.securityTrustedPipeline || (container.IsPlugin() && container.IsTrustedCloneImage(c.trustedClonePlugins)) {\n\t\t\t\tmaps.Copy(step.Environment, c.cloneEnv)\n\t\t\t}\n\n\t\t\tstage.Steps = append(stage.Steps, step)\n\n\t\t\tconfig.Stages = append(config.Stages, stage)\n\t\t}\n\t}\n\n\t// add services steps\n\tif len(conf.Services.ContainerList) != 0 {\n\t\tstage := new(backend_types.Stage)\n\n\t\tfor _, container := range conf.Services.ContainerList {\n\t\t\tif match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil {\n\t\t\t\tcontinue\n\t\t\t} else if err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tstep, err := c.createProcess(container, conf, backend_types.StepTypeService)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tstage.Steps = append(stage.Steps, step)\n\t\t}\n\t\tconfig.Stages = append(config.Stages, stage)\n\t}\n\n\t// add pipeline steps\n\tsteps := make([]*dagCompilerStep, 0, len(conf.Steps.ContainerList))\n\tfor pos, container := range conf.Steps.ContainerList {\n\t\t// Skip if local and should not run local\n\t\tif c.local && !container.When.IsLocal() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil {\n\t\t\tcontinue\n\t\t} else if err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tstepType := backend_types.StepTypeCommands\n\t\tif container.IsPlugin() {\n\t\t\tstepType = backend_types.StepTypePlugin\n\t\t}\n\t\tstep, err := c.createProcess(container, conf, stepType)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// only inject netrc if it's a trusted repo or a trusted plugin\n\t\tif c.securityTrustedPipeline || (container.IsPlugin() && container.IsTrustedCloneImage(c.trustedClonePlugins)) {\n\t\t\tmaps.Copy(step.Environment, c.cloneEnv)\n\t\t}\n\n\t\tsteps = append(steps, &dagCompilerStep{\n\t\t\tstep:      step,\n\t\t\tposition:  pos,\n\t\t\tname:      container.Name,\n\t\t\tdependsOn: container.DependsOn,\n\t\t})\n\t}\n\n\t// generate stages out of steps\n\tstepStages, err := newDAGCompiler(steps).compile()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfig.Stages = append(config.Stages, stepStages...)\n\n\treturn config, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/compiler_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\tyaml_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n\tyaml_base_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\nfunc TestSecretAvailable(t *testing.T) {\n\tsecret := Secret{\n\t\tAllowedPlugins: []string{},\n\t\tEvents:         []metadata.Event{\"push\"},\n\t}\n\tassert.NoError(t, secret.Available(\"push\", &yaml_types.Container{\n\t\tImage:    \"golang\",\n\t\tCommands: yaml_base_types.StringOrSlice{\"echo 'this is not a plugin'\"},\n\t}))\n\n\t// secret only available for \"golang\" plugin\n\tsecret = Secret{\n\t\tName:           \"foo\",\n\t\tAllowedPlugins: []string{\"golang\"},\n\t\tEvents:         []metadata.Event{\"push\"},\n\t}\n\tassert.NoError(t, secret.Available(\"push\", &yaml_types.Container{\n\t\tName:     \"step\",\n\t\tImage:    \"golang\",\n\t\tCommands: yaml_base_types.StringOrSlice{},\n\t}))\n\tassert.ErrorContains(t, secret.Available(\"push\", &yaml_types.Container{\n\t\tImage:    \"golang\",\n\t\tCommands: yaml_base_types.StringOrSlice{\"echo 'this is not a plugin'\"},\n\t}), \"is only allowed to be used by plugins (a filter has been set on the secret). Note: Image filters do not work for normal steps\")\n\tassert.ErrorContains(t, secret.Available(\"push\", &yaml_types.Container{\n\t\tImage:    \"not-golang\",\n\t\tCommands: yaml_base_types.StringOrSlice{},\n\t}), \"not allowed to be used with image \")\n\tassert.ErrorContains(t, secret.Available(\"pull_request\", &yaml_types.Container{\n\t\tImage: \"golang\",\n\t}), \"not allowed to be used with pipeline event \")\n}\n\nfunc TestCompilerCompile(t *testing.T) {\n\trepoURL := \"https://github.com/octocat/hello-world\"\n\tcompiler := New(\n\t\tWithMetadata(metadata.Metadata{\n\t\t\tRepo: metadata.Repo{\n\t\t\t\tOwner:    \"octacat\",\n\t\t\t\tName:     \"hello-world\",\n\t\t\t\tPrivate:  true,\n\t\t\t\tForgeURL: repoURL,\n\t\t\t\tCloneURL: \"https://github.com/octocat/hello-world.git\",\n\t\t\t},\n\t\t}),\n\t\tWithEnviron(map[string]string{\n\t\t\t\"VERBOSE\": \"true\",\n\t\t\t\"COLORED\": \"true\",\n\t\t}),\n\t\tWithPrefix(\"test\"),\n\t\t// we use \"/test\" as custom workspace base to ensure the enforcement of the pluginWorkspaceBase is applied\n\t\tWithWorkspaceFromURL(\"/test\", repoURL),\n\t)\n\n\tdefaultNetwork := \"test_default\"\n\tdefaultVolume := \"test_default\"\n\n\tdefaultCloneStage := &backend_types.Stage{\n\t\tSteps: []*backend_types.Step{{\n\t\t\tName:          \"clone\",\n\t\t\tType:          backend_types.StepTypeClone,\n\t\t\tImage:         constant.DefaultClonePlugin,\n\t\t\tOnSuccess:     true,\n\t\t\tFailure:       \"fail\",\n\t\t\tVolumes:       []string{defaultVolume + \":/woodpecker\"},\n\t\t\tWorkingDir:    \"/woodpecker/src/github.com/octocat/hello-world\",\n\t\t\tWorkspaceBase: \"/woodpecker\",\n\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"clone\"}}},\n\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t}},\n\t}\n\n\ttests := []struct {\n\t\tname        string\n\t\tfronConf    *yaml_types.Workflow\n\t\tbackConf    *backend_types.Config\n\t\texpectedErr string\n\t}{\n\t\t{\n\t\t\tname:     \"empty workflow, no clone\",\n\t\t\tfronConf: &yaml_types.Workflow{SkipClone: true},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty workflow, default clone\",\n\t\t\tfronConf: &yaml_types.Workflow{},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t\tStages:  []*backend_types.Stage{defaultCloneStage},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"workflow with one dummy step\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:  \"dummy\",\n\t\t\t\tImage: \"dummy_img\",\n\t\t\t}}}},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t\tStages: []*backend_types.Stage{defaultCloneStage, {\n\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\tName:          \"dummy\",\n\t\t\t\t\t\tType:          backend_types.StepTypePlugin,\n\t\t\t\t\t\tImage:         \"dummy_img\",\n\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/woodpecker\"},\n\t\t\t\t\t\tWorkingDir:    \"/woodpecker/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\tWorkspaceBase: \"/woodpecker\",\n\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"dummy\"}}},\n\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t}},\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"workflow with three steps\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:     \"echo env\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"env\"},\n\t\t\t}, {\n\t\t\t\tName:     \"parallel echo 1\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"echo 1\"},\n\t\t\t}, {\n\t\t\t\tName:     \"parallel echo 2\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"echo 2\"},\n\t\t\t}}}},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t\tdefaultCloneStage, {\n\t\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\t\tName:          \"echo env\",\n\t\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\t\tCommands:      []string{\"env\"},\n\t\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"echo env\"}}},\n\t\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t\t}},\n\t\t\t\t\t}, {\n\t\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\t\tName:          \"parallel echo 1\",\n\t\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\t\tCommands:      []string{\"echo 1\"},\n\t\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"parallel echo 1\"}}},\n\t\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t\t}},\n\t\t\t\t\t}, {\n\t\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\t\tName:          \"parallel echo 2\",\n\t\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\t\tCommands:      []string{\"echo 2\"},\n\t\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"parallel echo 2\"}}},\n\t\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"workflow with three steps and depends_on\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:     \"echo env\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"env\"},\n\t\t\t}, {\n\t\t\t\tName:      \"echo 1\",\n\t\t\t\tImage:     \"bash\",\n\t\t\t\tCommands:  []string{\"echo 1\"},\n\t\t\t\tDependsOn: []string{\"echo env\", \"echo 2\"},\n\t\t\t}, {\n\t\t\t\tName:     \"echo 2\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"echo 2\"},\n\t\t\t}}}},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t\tStages: []*backend_types.Stage{defaultCloneStage, {\n\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\tName:          \"echo env\",\n\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\tCommands:      []string{\"env\"},\n\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"echo env\"}}},\n\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t}, {\n\t\t\t\t\t\tName:          \"echo 2\",\n\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\tCommands:      []string{\"echo 2\"},\n\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"echo 2\"}}},\n\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t}},\n\t\t\t\t}, {\n\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\tName:          \"echo 1\",\n\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\tCommands:      []string{\"echo 1\"},\n\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"echo 1\"}}},\n\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t}},\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"workflow with missing secret\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:     \"step\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"env\"},\n\t\t\t\tEnvironment: map[string]any{\n\t\t\t\t\t\"MISSING\": map[string]any{\"from_secret\": \"missing\"},\n\t\t\t\t},\n\t\t\t}}}},\n\t\t\tbackConf:    nil,\n\t\t\texpectedErr: \"secret \\\"missing\\\" not found\",\n\t\t},\n\t\t{\n\t\t\tname: \"workflow with broken step dependency\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:      \"dummy\",\n\t\t\t\tImage:     \"dummy_img\",\n\t\t\t\tDependsOn: []string{\"not exist\"},\n\t\t\t}}}},\n\t\t\tbackConf:    nil,\n\t\t\texpectedErr: \"step 'dummy' depends on unknown step 'not exist'\",\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tbackConf, err := compiler.Compile(test.fronConf)\n\t\t\tif test.expectedErr != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Equal(t, test.expectedErr, err.Error())\n\t\t\t} else {\n\t\t\t\t// we ignore uuids in steps and only check if global env got set ...\n\t\t\t\tfor _, st := range backConf.Stages {\n\t\t\t\t\tfor _, s := range st.Steps {\n\t\t\t\t\t\ts.UUID = \"\"\n\t\t\t\t\t\tassert.Truef(t, s.Environment[\"VERBOSE\"] == \"true\", \"expected to get value of global set environment\")\n\t\t\t\t\t\tassert.Truef(t, len(s.Environment) > 10, \"expected to have a lot of built-in variables\")\n\t\t\t\t\t\ts.Environment = nil\n\t\t\t\t\t\ts.SecretMapping = nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// check if we get an expected backend config based on a frontend config\n\t\t\t\tassert.EqualValues(t, *test.backConf, *backConf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCompilerCompileWithFromSecret(t *testing.T) {\n\trepoURL := \"https://github.com/octocat/hello-world\"\n\tcompiler := New(\n\t\tWithMetadata(metadata.Metadata{\n\t\t\tRepo: metadata.Repo{\n\t\t\t\tOwner:    \"octacat\",\n\t\t\t\tName:     \"hello-world\",\n\t\t\t\tPrivate:  true,\n\t\t\t\tForgeURL: repoURL,\n\t\t\t\tCloneURL: \"https://github.com/octocat/hello-world.git\",\n\t\t\t},\n\t\t}),\n\t\tWithEnviron(map[string]string{\n\t\t\t\"VERBOSE\": \"true\",\n\t\t\t\"COLORED\": \"true\",\n\t\t}),\n\t\tWithSecret(Secret{\n\t\t\tName:  \"secret_name\",\n\t\t\tValue: \"VERY_SECRET\",\n\t\t}),\n\t\tWithPrefix(\"test\"),\n\t\t// we use \"/test\" as custom workspace base to ensure the enforcement of the pluginWorkspaceBase is applied\n\t\tWithWorkspaceFromURL(\"/test\", repoURL),\n\t)\n\tdefaultNetwork := \"test_default\"\n\tdefaultVolume := \"test_default\"\n\tdefaultCloneStage := &backend_types.Stage{\n\t\tSteps: []*backend_types.Step{{\n\t\t\tName:          \"clone\",\n\t\t\tType:          backend_types.StepTypeClone,\n\t\t\tImage:         constant.DefaultClonePlugin,\n\t\t\tOnSuccess:     true,\n\t\t\tFailure:       \"fail\",\n\t\t\tWorkingDir:    \"/woodpecker/src/github.com/octocat/hello-world\",\n\t\t\tWorkspaceBase: \"/woodpecker\",\n\t\t\tVolumes:       []string{defaultVolume + \":/woodpecker\"},\n\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"clone\"}}},\n\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t}},\n\t}\n\ttests := []struct {\n\t\tname        string\n\t\tfronConf    *yaml_types.Workflow\n\t\tbackConf    *backend_types.Config\n\t\texpectedErr string\n\t}{\n\t\t{\n\t\t\tname: \"workflow with missing secret\",\n\t\t\tfronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{\n\t\t\t\tName:     \"step\",\n\t\t\t\tImage:    \"bash\",\n\t\t\t\tCommands: []string{\"env\"},\n\t\t\t\tEnvironment: map[string]any{\n\t\t\t\t\t\"SECRET\": map[string]any{\"from_secret\": \"secret_name\"},\n\t\t\t\t},\n\t\t\t}}}},\n\t\t\tbackConf: &backend_types.Config{\n\t\t\t\tStages: []*backend_types.Stage{defaultCloneStage, {\n\t\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\t\tName:          \"step\",\n\t\t\t\t\t\tType:          backend_types.StepTypeCommands,\n\t\t\t\t\t\tImage:         \"bash\",\n\t\t\t\t\t\tCommands:      []string{\"env\"},\n\t\t\t\t\t\tOnSuccess:     true,\n\t\t\t\t\t\tFailure:       \"fail\",\n\t\t\t\t\t\tWorkingDir:    \"/test/src/github.com/octocat/hello-world\",\n\t\t\t\t\t\tWorkspaceBase: \"/test\",\n\t\t\t\t\t\tVolumes:       []string{defaultVolume + \":/test\"},\n\t\t\t\t\t\tNetworks:      []backend_types.Conn{{Name: \"test_default\", Aliases: []string{\"step\"}}},\n\t\t\t\t\t\tExtraHosts:    []backend_types.HostAlias{},\n\t\t\t\t\t\tSecretMapping: map[string]string{\n\t\t\t\t\t\t\t\"SECRET\": \"VERY_SECRET\",\n\t\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t}},\n\t\t\t\tVolume:  defaultVolume,\n\t\t\t\tNetwork: defaultNetwork,\n\t\t\t\tSecrets: []*backend_types.Secret{{\n\t\t\t\t\tName:  \"secret_name\",\n\t\t\t\t\tValue: \"VERY_SECRET\",\n\t\t\t\t}},\n\t\t\t},\n\t\t\texpectedErr: \"\",\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tbackConf, err := compiler.Compile(test.fronConf)\n\t\t\tif test.expectedErr != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Equal(t, test.expectedErr, err.Error())\n\t\t\t} else {\n\t\t\t\t// we ignore uuids in steps and only check if global env got set ...\n\t\t\t\tfor _, st := range backConf.Stages {\n\t\t\t\t\tfor _, s := range st.Steps {\n\t\t\t\t\t\ts.UUID = \"\"\n\t\t\t\t\t\tassert.Truef(t, s.Environment[\"VERBOSE\"] == \"true\", \"expected to get value of global set environment\")\n\t\t\t\t\t\tassert.Truef(t, len(s.Environment) > 10, \"expected to have a lot of built-in variables\")\n\t\t\t\t\t\ts.Environment = nil\n\n\t\t\t\t\t\tif len(s.SecretMapping) == 0 {\n\t\t\t\t\t\t\ts.SecretMapping = nil\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// check if we get an expected backend config based on a frontend config\n\t\t\t\tassert.EqualValues(t, *test.backConf, *backConf)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSecretMatch(t *testing.T) {\n\ttcl := []*struct {\n\t\tname   string\n\t\tsecret Secret\n\t\tevent  metadata.Event\n\t\tmatch  bool\n\t}{\n\t\t{\n\t\t\tname:   \"should match event\",\n\t\t\tsecret: Secret{Events: []metadata.Event{\"pull_request\"}},\n\t\t\tevent:  \"pull_request\",\n\t\t\tmatch:  true,\n\t\t},\n\t\t{\n\t\t\tname:   \"should not match event\",\n\t\t\tsecret: Secret{Events: []metadata.Event{\"pull_request\"}},\n\t\t\tevent:  \"push\",\n\t\t\tmatch:  false,\n\t\t},\n\t\t{\n\t\t\tname:   \"should match when no event filters defined\",\n\t\t\tsecret: Secret{},\n\t\t\tevent:  \"pull_request\",\n\t\t\tmatch:  true,\n\t\t},\n\t\t{\n\t\t\tname:   \"pull close should match pull\",\n\t\t\tsecret: Secret{Events: []metadata.Event{\"pull_request\"}},\n\t\t\tevent:  \"pull_request_closed\",\n\t\t\tmatch:  true,\n\t\t},\n\t\t{\n\t\t\tname:   \"pull metadata change should match pull\",\n\t\t\tsecret: Secret{Events: []metadata.Event{\"pull_request\"}},\n\t\t\tevent:  \"pull_request_metadata\",\n\t\t\tmatch:  true,\n\t\t},\n\t}\n\n\tfor _, tc := range tcl {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tassert.Equal(t, tc.match, tc.secret.Match(tc.event))\n\t\t})\n\t}\n}\n\nfunc TestCompilerCompilePrivileged(t *testing.T) {\n\tcompiler := New(\n\t\tWithEscalated(\"test/image\"),\n\t)\n\n\tfronConf := &yaml_types.Workflow{\n\t\tSkipClone: true,\n\t\tSteps: yaml_types.ContainerList{\n\t\t\tContainerList: []*yaml_types.Container{\n\t\t\t\t{\n\t\t\t\t\tName:      \"privileged-plugin\",\n\t\t\t\t\tImage:     \"test/image\",\n\t\t\t\t\tDependsOn: []string{}, // no dependencies =>  enable dag mode & all steps are executed in parallel\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:     \"no-plugin\",\n\t\t\t\t\tImage:    \"test/image\",\n\t\t\t\t\tCommands: []string{\"echo 'i am not a plugin anymore'\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"not-privileged-image\",\n\t\t\t\t\tImage: \"some/other-image\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tbackConf, err := compiler.Compile(fronConf)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, backConf.Stages, 1)\n\tassert.Len(t, backConf.Stages[0].Steps, 3)\n\tassert.True(t, backConf.Stages[0].Steps[0].Privileged)\n\tassert.False(t, backConf.Stages[0].Steps[1].Privileged)\n\tassert.False(t, backConf.Stages[0].Steps[2].Privileged)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/convert.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"path\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/oklog/ulid/v2\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler/settings\"\n\tyaml_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils\"\n)\n\nconst (\n\t// The pluginWorkspaceBase should not be changed, only if you are sure what you do.\n\tpluginWorkspaceBase = \"/woodpecker\"\n\t// DefaultWorkspaceBase is set if not altered by the user.\n\tDefaultWorkspaceBase = pluginWorkspaceBase\n)\n\nfunc (c *Compiler) createProcess(container *yaml_types.Container, workflow *yaml_types.Workflow, stepType backend_types.StepType) (*backend_types.Step, error) {\n\tvar (\n\t\tuuid = ulid.Make()\n\n\t\tdetached   bool\n\t\tworkingDir string\n\n\t\tprivileged  = container.Privileged\n\t\tnetworkMode = container.NetworkMode\n\t)\n\n\tworkspaceBase := c.workspaceBase\n\tif container.IsPlugin() {\n\t\t// plugins have a predefined workspace base to not tamper with entrypoint executables\n\t\tworkspaceBase = pluginWorkspaceBase\n\t}\n\tworkspaceVolume := fmt.Sprintf(\"%s_default:%s\", c.prefix, workspaceBase)\n\n\tnetworks := []backend_types.Conn{\n\t\t{\n\t\t\tName:    fmt.Sprintf(\"%s_default\", c.prefix),\n\t\t\tAliases: []string{container.Name},\n\t\t},\n\t}\n\tfor _, network := range c.networks {\n\t\tnetworks = append(networks, backend_types.Conn{\n\t\t\tName: network,\n\t\t})\n\t}\n\n\textraHosts := make([]backend_types.HostAlias, len(container.ExtraHosts))\n\tfor i, extraHost := range container.ExtraHosts {\n\t\tname, ip, ok := strings.Cut(extraHost, \":\")\n\t\tif !ok {\n\t\t\treturn nil, &ErrExtraHostFormat{host: extraHost}\n\t\t}\n\t\textraHosts[i].Name = name\n\t\textraHosts[i].IP = ip\n\t}\n\n\tvar volumes []string\n\tif !c.local {\n\t\tvolumes = append(volumes, workspaceVolume)\n\t}\n\tvolumes = append(volumes, c.volumes...)\n\tfor _, volume := range container.Volumes.Volumes {\n\t\tvolumes = append(volumes, volume.String())\n\t}\n\n\t// append default environment variables\n\tenvironment := map[string]string{}\n\tmaps.Copy(environment, c.env)\n\n\tenvironment[\"CI_WORKSPACE\"] = path.Join(workspaceBase, c.workspacePath)\n\n\tif stepType == backend_types.StepTypeService || container.Detached {\n\t\tdetached = true\n\t}\n\n\tworkingDir = c.stepWorkingDir(container)\n\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := c.secrets[name]\n\t\tif !ok {\n\t\t\treturn \"\", fmt.Errorf(\"secret %q not found\", name)\n\t\t}\n\n\t\tevent := c.metadata.Curr.Event\n\t\terr := secret.Available(event, container)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\treturn secret.Value, nil\n\t}\n\n\tsecretMapping := map[string]string{}\n\n\tif err := settings.ParamsToEnv(container.Settings, environment, \"PLUGIN_\", true, getSecretValue, secretMapping); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err := settings.ParamsToEnv(container.Environment, environment, \"\", false, getSecretValue, secretMapping); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif utils.MatchImageDynamic(container.Image, c.escalated...) && container.IsPlugin() {\n\t\tprivileged = true\n\t}\n\n\tauthConfig := backend_types.Auth{}\n\tfor _, registry := range c.registries {\n\t\tif utils.MatchHostname(container.Image, registry.Hostname) {\n\t\t\tauthConfig.Username = registry.Username\n\t\t\tauthConfig.Password = registry.Password\n\t\t\tbreak\n\t\t}\n\t}\n\n\tvar ports []backend_types.Port\n\tfor _, portDef := range container.Ports {\n\t\tport, err := convertPort(portDef)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tports = append(ports, port)\n\t}\n\n\t// at least one constraint contain status success, or all constraints have no status set\n\tonSuccess := container.When.IncludesStatusSuccess(c.metadata, false, c.env)\n\t// at least one constraint must include the status failure.\n\tonFailure := container.When.IncludesStatusFailure(c.metadata, false, c.env)\n\n\tfailure := container.Failure\n\tif container.Failure == \"\" {\n\t\tfailure = string(metadata.FailureFail)\n\t}\n\n\t// TODO: remove with version 4.x\n\tif c.forceIgnoreServiceFailure && detached {\n\t\tfailure = string(metadata.FailureIgnore)\n\t}\n\n\treturn &backend_types.Step{\n\t\tName:           container.Name,\n\t\tUUID:           uuid.String(),\n\t\tType:           stepType,\n\t\tImage:          container.Image,\n\t\tPull:           container.Pull,\n\t\tDetached:       detached,\n\t\tPrivileged:     privileged,\n\t\tWorkingDir:     workingDir,\n\t\tWorkspaceBase:  workspaceBase,\n\t\tEnvironment:    environment,\n\t\tSecretMapping:  secretMapping,\n\t\tCommands:       container.Commands,\n\t\tEntrypoint:     container.Entrypoint,\n\t\tExtraHosts:     extraHosts,\n\t\tVolumes:        volumes,\n\t\tTmpfs:          container.Tmpfs,\n\t\tDevices:        container.Devices,\n\t\tNetworks:       networks,\n\t\tDNS:            container.DNS,\n\t\tDNSSearch:      container.DNSSearch,\n\t\tAuthConfig:     authConfig,\n\t\tOnSuccess:      onSuccess,\n\t\tOnFailure:      onFailure,\n\t\tFailure:        failure,\n\t\tNetworkMode:    networkMode,\n\t\tPorts:          ports,\n\t\tBackendOptions: container.BackendOptions,\n\t\tWorkflowLabels: workflow.Labels,\n\t}, nil\n}\n\nfunc (c *Compiler) stepWorkingDir(container *yaml_types.Container) string {\n\tif path.IsAbs(container.Directory) {\n\t\treturn container.Directory\n\t}\n\tbase := c.workspaceBase\n\tif container.IsPlugin() {\n\t\tbase = pluginWorkspaceBase\n\t}\n\treturn path.Join(base, c.workspacePath, container.Directory)\n}\n\nfunc convertPort(portDef string) (backend_types.Port, error) {\n\tvar err error\n\tvar port backend_types.Port\n\n\tnumber, protocol, _ := strings.Cut(portDef, \"/\")\n\tport.Protocol = protocol\n\n\tportNumber, err := strconv.ParseUint(number, 10, 16)\n\tif err != nil {\n\t\treturn port, err\n\t}\n\tport.Number = uint16(portNumber)\n\n\treturn port, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/convert_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestConvertPortNumber(t *testing.T) {\n\tportDef := \"1234\"\n\tactualPort, err := convertPort(portDef)\n\tassert.NoError(t, err)\n\tassert.Equal(t, backend_types.Port{\n\t\tNumber:   1234,\n\t\tProtocol: \"\",\n\t}, actualPort)\n}\n\nfunc TestConvertPortUdp(t *testing.T) {\n\tportDef := \"1234/udp\"\n\tactualPort, err := convertPort(portDef)\n\tassert.NoError(t, err)\n\tassert.Equal(t, backend_types.Port{\n\t\tNumber:   1234,\n\t\tProtocol: \"udp\",\n\t}, actualPort)\n}\n\nfunc TestConvertPortWrongOrder(t *testing.T) {\n\tportDef := \"tcp/1234\"\n\t_, err := convertPort(portDef)\n\tassert.Error(t, err)\n}\n\nfunc TestConvertPortWrongDelimiter(t *testing.T) {\n\tportDef := \"1234|udp\"\n\t_, err := convertPort(portDef)\n\tassert.Error(t, err)\n}\n\nfunc TestConvertPortWrong(t *testing.T) {\n\tportDef := \"http\"\n\t_, err := convertPort(portDef)\n\tassert.Error(t, err)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/dag.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"sort\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\ntype dagCompilerStep struct {\n\tstep      *backend_types.Step\n\tposition  int\n\tname      string\n\tdependsOn []string\n}\n\ntype dagCompiler struct {\n\tsteps []*dagCompilerStep\n}\n\nfunc newDAGCompiler(steps []*dagCompilerStep) dagCompiler {\n\treturn dagCompiler{\n\t\tsteps: steps,\n\t}\n}\n\nfunc (c dagCompiler) isDAG() bool {\n\tfor _, v := range c.steps {\n\t\tif v.dependsOn != nil {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (c dagCompiler) compile() ([]*backend_types.Stage, error) {\n\tif c.isDAG() {\n\t\treturn c.compileByDependsOn()\n\t}\n\treturn c.compileSequence()\n}\n\nfunc (c dagCompiler) compileSequence() ([]*backend_types.Stage, error) {\n\tstages := make([]*backend_types.Stage, 0, len(c.steps))\n\n\tfor _, s := range c.steps {\n\t\tstages = append(stages, &backend_types.Stage{\n\t\t\tSteps: []*backend_types.Step{s.step},\n\t\t})\n\t}\n\n\treturn stages, nil\n}\n\nfunc (c dagCompiler) compileByDependsOn() ([]*backend_types.Stage, error) {\n\tstepMap := make(map[string]*dagCompilerStep, len(c.steps))\n\tfor _, s := range c.steps {\n\t\tstepMap[s.name] = s\n\t}\n\treturn convertDAGToStages(stepMap)\n}\n\nfunc dfsVisit(steps map[string]*dagCompilerStep, name string, visited map[string]struct{}, path []string) error {\n\tif _, ok := visited[name]; ok {\n\t\treturn &ErrStepDependencyCycle{path: path}\n\t}\n\n\tvisited[name] = struct{}{}\n\tpath = append(path, name)\n\n\tfor _, dep := range steps[name].dependsOn {\n\t\tif err := dfsVisit(steps, dep, visited, path); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tdelete(visited, name)\n\n\treturn nil\n}\n\nfunc convertDAGToStages(steps map[string]*dagCompilerStep) ([]*backend_types.Stage, error) {\n\taddedSteps := make(map[string]struct{})\n\tstages := make([]*backend_types.Stage, 0)\n\n\tfor name, step := range steps {\n\t\t// check if all depends_on are valid\n\t\tfor _, dep := range step.dependsOn {\n\t\t\tif _, ok := steps[dep]; !ok {\n\t\t\t\treturn nil, &ErrStepMissingDependency{name: name, dep: dep}\n\t\t\t}\n\t\t}\n\n\t\t// check if there are cycles\n\t\tvisited := make(map[string]struct{})\n\t\tif err := dfsVisit(steps, name, visited, []string{}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfor len(steps) > 0 {\n\t\taddedNodesThisLevel := make(map[string]struct{})\n\t\tstage := new(backend_types.Stage)\n\n\t\tvar stepsToAdd []*dagCompilerStep\n\t\tfor name, step := range steps {\n\t\t\tif allDependenciesSatisfied(step, addedSteps) {\n\t\t\t\tstepsToAdd = append(stepsToAdd, step)\n\t\t\t\taddedNodesThisLevel[name] = struct{}{}\n\t\t\t\tdelete(steps, name)\n\t\t\t}\n\t\t}\n\n\t\t// as steps are from a map that has no deterministic order,\n\t\t// we sort the steps by original config position to make the order similar between pipelines\n\t\tsort.Slice(stepsToAdd, func(i, j int) bool {\n\t\t\treturn stepsToAdd[i].position < stepsToAdd[j].position\n\t\t})\n\n\t\tfor i := range stepsToAdd {\n\t\t\tstage.Steps = append(stage.Steps, stepsToAdd[i].step)\n\t\t}\n\n\t\tfor name := range addedNodesThisLevel {\n\t\t\taddedSteps[name] = struct{}{}\n\t\t}\n\n\t\tstages = append(stages, stage)\n\t}\n\n\treturn stages, nil\n}\n\nfunc allDependenciesSatisfied(step *dagCompilerStep, addedSteps map[string]struct{}) bool {\n\tfor _, childName := range step.dependsOn {\n\t\t_, ok := addedSteps[childName]\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/dag_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\nfunc TestConvertDAGToStages(t *testing.T) {\n\tsteps := map[string]*dagCompilerStep{\n\t\t\"step1\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"step3\"},\n\t\t},\n\t\t\"step2\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"step1\"},\n\t\t},\n\t\t\"step3\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"step2\"},\n\t\t},\n\t}\n\t_, err := convertDAGToStages(steps)\n\tassert.ErrorIs(t, err, &ErrStepDependencyCycle{})\n\n\tsteps = map[string]*dagCompilerStep{\n\t\t\"step1\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"step2\"},\n\t\t},\n\t\t\"step2\": {\n\t\t\tstep: &backend_types.Step{},\n\t\t},\n\t}\n\t_, err = convertDAGToStages(steps)\n\tassert.NoError(t, err)\n\n\tsteps = map[string]*dagCompilerStep{\n\t\t\"a\": {\n\t\t\tstep: &backend_types.Step{},\n\t\t},\n\t\t\"b\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"a\"},\n\t\t},\n\t\t\"c\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"a\"},\n\t\t},\n\t\t\"d\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"b\", \"c\"},\n\t\t},\n\t}\n\t_, err = convertDAGToStages(steps)\n\tassert.NoError(t, err)\n\n\tsteps = map[string]*dagCompilerStep{\n\t\t\"step1\": {\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{\"not-existing-step\"},\n\t\t},\n\t}\n\t_, err = convertDAGToStages(steps)\n\tassert.ErrorIs(t, err, &ErrStepMissingDependency{})\n\n\tsteps = map[string]*dagCompilerStep{\n\t\t\"echo env\": {\n\t\t\tposition: 0,\n\t\t\tname:     \"echo env\",\n\t\t\tstep: &backend_types.Step{\n\t\t\t\tUUID:  \"01HJDPEW6R7J0JBE3F1T7Q0TYX\",\n\t\t\t\tType:  \"commands\",\n\t\t\t\tName:  \"echo env\",\n\t\t\t\tImage: \"bash\",\n\t\t\t},\n\t\t},\n\t\t\"echo 1\": {\n\t\t\tposition:  1,\n\t\t\tname:      \"echo 1\",\n\t\t\tdependsOn: []string{\"echo env\", \"echo 2\"},\n\t\t\tstep: &backend_types.Step{\n\t\t\t\tUUID:  \"01HJDPF770QGRZER8RF79XVS4M\",\n\t\t\t\tType:  \"commands\",\n\t\t\t\tName:  \"echo 1\",\n\t\t\t\tImage: \"bash\",\n\t\t\t},\n\t\t},\n\t\t\"echo 2\": {\n\t\t\tposition: 2,\n\t\t\tname:     \"echo 2\",\n\t\t\tstep: &backend_types.Step{\n\t\t\t\tUUID:  \"01HJDPFF5RMEYZW0YTGR1Y1ZR0\",\n\t\t\t\tType:  \"commands\",\n\t\t\t\tName:  \"echo 2\",\n\t\t\t\tImage: \"bash\",\n\t\t\t},\n\t\t},\n\t}\n\tstages, err := convertDAGToStages(steps)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, []*backend_types.Stage{{\n\t\tSteps: []*backend_types.Step{{\n\t\t\tUUID:  \"01HJDPEW6R7J0JBE3F1T7Q0TYX\",\n\t\t\tType:  \"commands\",\n\t\t\tName:  \"echo env\",\n\t\t\tImage: \"bash\",\n\t\t}, {\n\t\t\tUUID:  \"01HJDPFF5RMEYZW0YTGR1Y1ZR0\",\n\t\t\tType:  \"commands\",\n\t\t\tName:  \"echo 2\",\n\t\t\tImage: \"bash\",\n\t\t}},\n\t}, {\n\t\tSteps: []*backend_types.Step{{\n\t\t\tUUID:  \"01HJDPF770QGRZER8RF79XVS4M\",\n\t\t\tType:  \"commands\",\n\t\t\tName:  \"echo 1\",\n\t\t\tImage: \"bash\",\n\t\t}},\n\t}}, stages)\n}\n\nfunc TestIsDag(t *testing.T) {\n\tsteps := []*dagCompilerStep{\n\t\t{\n\t\t\tstep: &backend_types.Step{},\n\t\t},\n\t}\n\tc := newDAGCompiler(steps)\n\tassert.False(t, c.isDAG())\n\n\tsteps = []*dagCompilerStep{\n\t\t{\n\t\t\tstep:      &backend_types.Step{},\n\t\t\tdependsOn: []string{},\n\t\t},\n\t}\n\tc = newDAGCompiler(steps)\n\tassert.True(t, c.isDAG())\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/errors.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport \"fmt\"\n\ntype ErrExtraHostFormat struct {\n\thost string\n}\n\nfunc (err *ErrExtraHostFormat) Error() string {\n\treturn fmt.Sprintf(\"extra host %s is in wrong format\", err.host)\n}\n\nfunc (*ErrExtraHostFormat) Is(target error) bool {\n\t_, ok := target.(*ErrExtraHostFormat)\n\treturn ok\n}\n\ntype ErrStepMissingDependency struct {\n\tname,\n\tdep string\n}\n\nfunc (err *ErrStepMissingDependency) Error() string {\n\treturn fmt.Sprintf(\"step '%s' depends on unknown step '%s'\", err.name, err.dep)\n}\n\nfunc (*ErrStepMissingDependency) Is(target error) bool {\n\t_, ok := target.(*ErrStepMissingDependency)\n\treturn ok\n}\n\ntype ErrStepDependencyCycle struct {\n\tpath []string\n}\n\nfunc (err *ErrStepDependencyCycle) Error() string {\n\treturn fmt.Sprintf(\"cycle detected: %v\", err.path)\n}\n\nfunc (*ErrStepDependencyCycle) Is(target error) bool {\n\t_, ok := target.(*ErrStepDependencyCycle)\n\treturn ok\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/option.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"maps\"\n\t\"net/url\"\n\t\"path\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n)\n\n// Option configures a compiler option.\ntype Option func(*Compiler)\n\nfunc noopOption() Option {\n\treturn func(*Compiler) {}\n}\n\n// WithOption configures the compiler with the given option if\n// boolean b evaluates to true.\nfunc WithOption(option Option, b bool) Option {\n\tswitch {\n\tcase b:\n\t\treturn option\n\tdefault:\n\t\treturn func(_ *Compiler) {}\n\t}\n}\n\n// WithVolumes configures the compiler with default volumes that\n// are mounted to each container in the pipeline.\nfunc WithVolumes(volumes ...string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.volumes = volumes\n\t}\n}\n\n// WithRegistry configures the compiler with registry credentials\n// that should be used to download images.\nfunc WithRegistry(registries ...Registry) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.registries = registries\n\t}\n}\n\n// WithSecret configures the compiler with external secrets\n// to be injected into the container at runtime.\nfunc WithSecret(secrets ...Secret) Option {\n\treturn func(compiler *Compiler) {\n\t\tfor _, secret := range secrets {\n\t\t\tcompiler.secrets[strings.ToLower(secret.Name)] = secret\n\t\t}\n\t}\n}\n\n// WithMetadata configures the compiler with the repository, pipeline\n// and system metadata. The metadata is used to remove steps from\n// the compiled pipeline configuration that should be skipped. The\n// metadata is also added to each container as environment variables.\nfunc WithMetadata(metadata metadata.Metadata) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.metadata = metadata\n\n\t\tmaps.Copy(compiler.env, metadata.Environ())\n\t}\n}\n\n// WithNetrc configures the compiler with netrc authentication\n// credentials added by default to every container in the pipeline.\nfunc WithNetrc(username, password, machine string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.cloneEnv[\"CI_NETRC_USERNAME\"] = username\n\t\tcompiler.cloneEnv[\"CI_NETRC_PASSWORD\"] = password\n\t\tcompiler.cloneEnv[\"CI_NETRC_MACHINE\"] = machine\n\t}\n}\n\n// WithWorkspace configures the compiler with the workspace base\n// and path. The workspace base is a volume created at runtime and\n// mounted into all containers in the pipeline. The base and path\n// are joined to provide the working directory for all pipeline and\n// plugin steps in the pipeline.\nfunc WithWorkspace(base, path string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.workspaceBase = base\n\t\tcompiler.workspacePath = path\n\t}\n}\n\n// WithWorkspaceFromURL configures the compiler with the workspace\n// base and path based on the repository url.\nfunc WithWorkspaceFromURL(base, u string) Option {\n\tsrcPath := \"src\"\n\tparsed, err := url.Parse(u)\n\tif err == nil {\n\t\tsrcPath = path.Join(srcPath, parsed.Hostname(), parsed.Path)\n\t}\n\treturn WithWorkspace(base, srcPath)\n}\n\n// WithEscalated configures the compiler to automatically execute\n// images as privileged containers if the match the given list.\nfunc WithEscalated(images ...string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.escalated = images\n\t}\n}\n\n// WithPrefix configures the compiler with the prefix. The prefix is\n// used to prefix container, volume and network names to avoid\n// collision at runtime.\nfunc WithPrefix(prefix string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.prefix = prefix\n\t}\n}\n\n// WithLocal configures the compiler with the local flag. The local\n// flag indicates the pipeline execution is running in a local development\n// environment with a mounted local working directory.\nfunc WithLocal(local bool) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.local = local\n\t}\n}\n\n// WithEnviron configures the compiler with environment variables\n// added by default to every container in the pipeline.\nfunc WithEnviron(env map[string]string) Option {\n\treturn func(compiler *Compiler) {\n\t\tmaps.Copy(compiler.env, env)\n\t}\n}\n\n// WithNetworks configures the compiler with additional networks\n// to be connected to pipeline containers.\nfunc WithNetworks(networks ...string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.networks = networks\n\t}\n}\n\nfunc WithDefaultClonePlugin(cloneImage string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.defaultClonePlugin = cloneImage\n\t}\n}\n\nfunc WithTrustedClonePlugins(images []string) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.trustedClonePlugins = images\n\t}\n}\n\n// WithTrustedSecurity configures the compiler with the trusted repo option.\nfunc WithTrustedSecurity(trusted bool) Option {\n\treturn func(compiler *Compiler) {\n\t\tcompiler.securityTrustedPipeline = trusted\n\t}\n}\n\ntype ProxyOptions struct {\n\tNoProxy    string\n\tHTTPProxy  string\n\tHTTPSProxy string\n}\n\n// WithProxy configures the compiler with HTTP_PROXY, HTTPS_PROXY,\n// and NO_PROXY environment variables added by default to every\n// container in the pipeline.\nfunc WithProxy(opt ProxyOptions) Option {\n\tif opt.HTTPProxy == \"\" &&\n\t\topt.HTTPSProxy == \"\" &&\n\t\topt.NoProxy == \"\" {\n\t\treturn noopOption()\n\t}\n\treturn WithEnviron(\n\t\tmap[string]string{\n\t\t\t\"no_proxy\":    opt.NoProxy,\n\t\t\t\"NO_PROXY\":    opt.NoProxy,\n\t\t\t\"http_proxy\":  opt.HTTPProxy,\n\t\t\t\"HTTP_PROXY\":  opt.HTTPProxy,\n\t\t\t\"HTTPS_PROXY\": opt.HTTPSProxy,\n\t\t\t\"https_proxy\": opt.HTTPSProxy,\n\t\t},\n\t)\n}\n\n// TODO: remove with version 4.x\nfunc WithForceIgnoreServiceFailure() Option {\n\treturn func(c *Compiler) {\n\t\tc.forceIgnoreServiceFailure = true\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/option_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage compiler\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\nfunc TestWithWorkspace(t *testing.T) {\n\tcompiler := New(\n\t\tWithWorkspace(\n\t\t\t\"/pipeline\",\n\t\t\t\"src/github.com/octocat/hello-world\",\n\t\t),\n\t)\n\tassert.Equal(t, \"/pipeline\", compiler.workspaceBase)\n\tassert.Equal(t, \"src/github.com/octocat/hello-world\", compiler.workspacePath)\n}\n\nfunc TestWithEscalated(t *testing.T) {\n\tcompiler := New(\n\t\tWithEscalated(\n\t\t\t\"docker\",\n\t\t\t\"docker-dev\",\n\t\t),\n\t)\n\tassert.Equal(t, \"docker\", compiler.escalated[0])\n\tassert.Equal(t, \"docker-dev\", compiler.escalated[1])\n}\n\nfunc TestWithVolumes(t *testing.T) {\n\tcompiler := New(\n\t\tWithVolumes(\n\t\t\t\"/tmp:/tmp\",\n\t\t\t\"/foo:/foo\",\n\t\t),\n\t)\n\tassert.Equal(t, \"/tmp:/tmp\", compiler.volumes[0])\n\tassert.Equal(t, \"/foo:/foo\", compiler.volumes[1])\n}\n\nfunc TestWithNetworks(t *testing.T) {\n\tcompiler := New(\n\t\tWithNetworks(\n\t\t\t\"overlay_1\",\n\t\t\t\"overlay_bar\",\n\t\t),\n\t)\n\tassert.Equal(t, \"overlay_1\", compiler.networks[0])\n\tassert.Equal(t, \"overlay_bar\", compiler.networks[1])\n}\n\nfunc TestWithPrefix(t *testing.T) {\n\tassert.Equal(t, \"someprefix_\", New(WithPrefix(\"someprefix_\")).prefix)\n}\n\nfunc TestWithMetadata(t *testing.T) {\n\tmetadata := metadata.Metadata{\n\t\tRepo: metadata.Repo{\n\t\t\tOwner:    \"octacat\",\n\t\t\tName:     \"hello-world\",\n\t\t\tPrivate:  true,\n\t\t\tForgeURL: \"https://github.com/octocat/hello-world\",\n\t\t\tCloneURL: \"https://github.com/octocat/hello-world.git\",\n\t\t},\n\t}\n\tcompiler := New(\n\t\tWithMetadata(metadata),\n\t)\n\n\tassert.Equal(t, metadata, compiler.metadata)\n\tassert.Equal(t, metadata.Repo.Name, compiler.env[\"CI_REPO_NAME\"])\n\tassert.Equal(t, metadata.Repo.ForgeURL, compiler.env[\"CI_REPO_URL\"])\n\tassert.Equal(t, metadata.Repo.CloneURL, compiler.env[\"CI_REPO_CLONE_URL\"])\n}\n\nfunc TestWithLocal(t *testing.T) {\n\tassert.True(t, New(WithLocal(true)).local)\n\tassert.False(t, New(WithLocal(false)).local)\n}\n\nfunc TestWithNetrc(t *testing.T) {\n\tcompiler := New(\n\t\tWithNetrc(\n\t\t\t\"octocat\",\n\t\t\t\"password\",\n\t\t\t\"github.com\",\n\t\t),\n\t)\n\tassert.Equal(t, \"octocat\", compiler.cloneEnv[\"CI_NETRC_USERNAME\"])\n\tassert.Equal(t, \"password\", compiler.cloneEnv[\"CI_NETRC_PASSWORD\"])\n\tassert.Equal(t, \"github.com\", compiler.cloneEnv[\"CI_NETRC_MACHINE\"])\n}\n\nfunc TestWithProxy(t *testing.T) {\n\t// alter the default values\n\tnoProxy := \"example.com\"\n\thttpProxy := \"bar.com\"\n\thttpsProxy := \"baz.com\"\n\n\ttestdata := map[string]string{\n\t\t\"no_proxy\":    noProxy,\n\t\t\"NO_PROXY\":    noProxy,\n\t\t\"http_proxy\":  httpProxy,\n\t\t\"HTTP_PROXY\":  httpProxy,\n\t\t\"https_proxy\": httpsProxy,\n\t\t\"HTTPS_PROXY\": httpsProxy,\n\t}\n\tcompiler := New(\n\t\tWithProxy(ProxyOptions{\n\t\t\tNoProxy:    noProxy,\n\t\t\tHTTPProxy:  httpProxy,\n\t\t\tHTTPSProxy: httpsProxy,\n\t\t}),\n\t)\n\tfor key, value := range testdata {\n\t\tassert.Equal(t, value, compiler.env[key])\n\t}\n}\n\nfunc TestWithEnviron(t *testing.T) {\n\tcompiler := New(\n\t\tWithEnviron(\n\t\t\tmap[string]string{\n\t\t\t\t\"RACK_ENV\": \"development\",\n\t\t\t\t\"SHOW\":     \"true\",\n\t\t\t},\n\t\t),\n\t)\n\tassert.Equal(t, \"development\", compiler.env[\"RACK_ENV\"])\n\tassert.Equal(t, \"true\", compiler.env[\"SHOW\"])\n}\n\nfunc TestDefaultClonePlugin(t *testing.T) {\n\tcompiler := New(\n\t\tWithDefaultClonePlugin(\"not-an-image\"),\n\t)\n\tassert.Equal(t, \"not-an-image\", compiler.defaultClonePlugin)\n}\n\nfunc TestWithTrustedClonePlugins(t *testing.T) {\n\tcompiler := New(WithTrustedClonePlugins([]string{\"not-an-image\"}))\n\tassert.ElementsMatch(t, []string{\"not-an-image\"}, compiler.trustedClonePlugins)\n\n\tcompiler = New()\n\tassert.ElementsMatch(t, constant.TrustedClonePlugins, compiler.trustedClonePlugins)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/settings/params.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage settings\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"codeberg.org/6543/go-yaml2json\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// ParamsToEnv uses reflection to convert a map[string]interface to a list\n// of environment variables.\nfunc ParamsToEnv(from map[string]any, to map[string]string, prefix string, upper bool, getSecretValue func(name string) (string, error), secretMapping map[string]string) (err error) {\n\tif to == nil {\n\t\treturn fmt.Errorf(\"no map to write to\")\n\t}\n\tfor k, v := range from {\n\t\tif v == nil || len(k) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tsanitizedParamKey := sanitizeParamKey(prefix, upper, k)\n\n\t\tsecretUsed := false\n\t\twrappedGetSecretValue := func(name string) (string, error) {\n\t\t\tsecretUsed = true\n\t\t\treturn getSecretValue(name)\n\t\t}\n\n\t\tto[sanitizedParamKey], err = sanitizeParamValue(v, wrappedGetSecretValue)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif secretUsed && secretMapping != nil {\n\t\t\tsecretMapping[sanitizedParamKey] = to[sanitizedParamKey]\n\t\t}\n\t}\n\treturn nil\n}\n\n// sanitizeParamKey formats the environment variable key.\nfunc sanitizeParamKey(prefix string, upper bool, k string) string {\n\tr := k\n\tif upper {\n\t\tr = strings.ReplaceAll(strings.ReplaceAll(k, \".\", \"_\"), \"-\", \"_\")\n\t\tr = strings.ToUpper(r)\n\t}\n\treturn prefix + r\n}\n\n// isComplex indicate if a data type can be turned into string without encoding as json.\nfunc isComplex(t reflect.Kind) bool {\n\tswitch t {\n\tcase reflect.Bool,\n\t\treflect.String,\n\t\treflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,\n\t\treflect.Float32, reflect.Float64:\n\t\treturn false\n\tdefault:\n\t\treturn true\n\t}\n}\n\n// sanitizeParamValue returns the value of a setting as string prepared to be injected as environment variable.\nfunc sanitizeParamValue(v any, getSecretValue func(name string) (string, error)) (string, error) {\n\tt := reflect.TypeOf(v)\n\tvv := reflect.ValueOf(v)\n\n\tswitch t.Kind() {\n\tcase reflect.Bool:\n\t\treturn strconv.FormatBool(vv.Bool()), nil\n\n\tcase reflect.String:\n\t\treturn vv.String(), nil\n\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:\n\t\treturn fmt.Sprintf(\"%v\", vv.Int()), nil\n\n\tcase reflect.Float32, reflect.Float64:\n\t\treturn fmt.Sprintf(\"%v\", vv.Float()), nil\n\n\tcase reflect.Map:\n\t\tswitch v := v.(type) {\n\t\t// gopkg.in/yaml.v3 only emits this map interface\n\t\tcase map[string]any:\n\t\t\t// check if it's a secret and return value if it's the case\n\t\t\tvalue, isSecret, err := injectSecret(v, getSecretValue)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t} else if isSecret {\n\t\t\t\treturn value, nil\n\t\t\t}\n\t\tdefault:\n\t\t\treturn \"\", fmt.Errorf(\"could not handle: %#v\", v)\n\t\t}\n\n\t\treturn handleComplex(vv.Interface(), getSecretValue)\n\n\tcase reflect.Slice, reflect.Array:\n\t\tif vv.Len() == 0 {\n\t\t\treturn \"\", nil\n\t\t}\n\n\t\t// if it's an interface unwrap and element check happen for each iteration later\n\t\tif t.Elem().Kind() == reflect.Interface ||\n\t\t\t// else check directly if element is not complex\n\t\t\t!isComplex(t.Elem().Kind()) {\n\t\t\tcontainsComplex := false\n\t\t\tin := make([]string, vv.Len())\n\n\t\t\tfor i := 0; i < vv.Len(); i++ {\n\t\t\t\tv := vv.Index(i).Interface()\n\n\t\t\t\t// if we handle a list with a nil entry we just return a empty list\n\t\t\t\tif v == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// ensure each element is not complex\n\t\t\t\tif isComplex(reflect.TypeOf(v).Kind()) {\n\t\t\t\t\tcontainsComplex = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tvar err error\n\t\t\t\tif in[i], err = sanitizeParamValue(v, getSecretValue); err != nil {\n\t\t\t\t\treturn \"\", err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !containsComplex {\n\t\t\t\treturn strings.Join(in, \",\"), nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// handle all elements which are not primitives, string-maps containing secrets or arrays\n\treturn handleComplex(vv.Interface(), getSecretValue)\n}\n\n// handleComplex uses yaml2json to get json strings as values for environment variables.\nfunc handleComplex(v any, getSecretValue func(name string) (string, error)) (string, error) {\n\tv, err := injectSecretRecursive(v, getSecretValue)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tout, err := yaml.Marshal(v)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tout, err = yaml2json.Convert(out)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(out), nil\n}\n\n// injectSecret probes if a map is a from_secret request.\n// If it's a from_secret request it either  returns the secret value or an error if the secret was not found\n// else it just indicates to progress normally using the provided map as is.\nfunc injectSecret(v map[string]any, getSecretValue func(name string) (string, error)) (string, bool, error) {\n\tif secretNameI, ok := v[\"from_secret\"]; ok {\n\t\tif secretName, ok := secretNameI.(string); ok {\n\t\t\tsecret, err := getSecretValue(secretName)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", false, err\n\t\t\t}\n\n\t\t\treturn secret, true, nil\n\t\t}\n\t\treturn \"\", false, fmt.Errorf(\"from_secret has to be a string\")\n\t}\n\treturn \"\", false, nil\n}\n\n// injectSecretRecursive iterates over all types and if they contain elements\n// it iterates recursively over them too, using injectSecret internally.\nfunc injectSecretRecursive(v any, getSecretValue func(name string) (string, error)) (any, error) {\n\tt := reflect.TypeOf(v)\n\tif t == nil {\n\t\treturn v, nil\n\t}\n\tif !isComplex(t.Kind()) {\n\t\treturn v, nil\n\t}\n\n\tswitch t.Kind() {\n\tcase reflect.Map:\n\t\tswitch v := v.(type) {\n\t\t// gopkg.in/yaml.v3 only emits this map interface\n\t\tcase map[string]any:\n\t\t\t// handle secrets\n\t\t\tvalue, isSecret, err := injectSecret(v, getSecretValue)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t} else if isSecret {\n\t\t\t\treturn value, nil\n\t\t\t}\n\n\t\t\tfor key, val := range v {\n\t\t\t\tv[key], err = injectSecretRecursive(val, getSecretValue)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn v, nil\n\t\tdefault:\n\t\t\treturn v, fmt.Errorf(\"could not handle: %#v\", v)\n\t\t}\n\n\tcase reflect.Array, reflect.Slice:\n\t\tvv := reflect.ValueOf(v)\n\t\tvl := make([]any, vv.Len())\n\n\t\tfor i := 0; i < vv.Len(); i++ {\n\t\t\tv, err := injectSecretRecursive(vv.Index(i).Interface(), getSecretValue)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tvl[i] = v\n\t\t}\n\t\treturn vl, nil\n\n\tdefault:\n\t\treturn v, nil\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/compiler/settings/params_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage settings\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestParamsToEnv(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"skip\":             nil,\n\t\t\"string\":           \"stringz\",\n\t\t\"int\":              1,\n\t\t\"float\":            1.2,\n\t\t\"bool\":             true,\n\t\t\"slice\":            []int{1, 2, 3},\n\t\t\"map\":              map[string]any{\"hello\": \"world\"},\n\t\t\"complex\":          []struct{ Name string }{{\"Jack\"}, {\"Jill\"}},\n\t\t\"complex2\":         struct{ Name string }{\"Jack\"},\n\t\t\"from.address\":     \"noreply@example.com\",\n\t\t\"tags\":             stringsToInterface(\"next\", \"latest\"),\n\t\t\"tag\":              stringsToInterface(\"next\"),\n\t\t\"my_secret\":        map[string]any{\"from_secret\": \"secret_token\"},\n\t\t\"UPPERCASE_SECRET\": map[string]any{\"from_secret\": \"SECRET_TOKEN\"},\n\t}\n\twant := map[string]string{\n\t\t\"PLUGIN_STRING\":           \"stringz\",\n\t\t\"PLUGIN_INT\":              \"1\",\n\t\t\"PLUGIN_FLOAT\":            \"1.2\",\n\t\t\"PLUGIN_BOOL\":             \"true\",\n\t\t\"PLUGIN_SLICE\":            \"1,2,3\",\n\t\t\"PLUGIN_MAP\":              `{\"hello\":\"world\"}`,\n\t\t\"PLUGIN_COMPLEX\":          `[{\"name\":\"Jack\"},{\"name\":\"Jill\"}]`,\n\t\t\"PLUGIN_COMPLEX2\":         `{\"name\":\"Jack\"}`,\n\t\t\"PLUGIN_FROM_ADDRESS\":     \"noreply@example.com\",\n\t\t\"PLUGIN_TAG\":              \"next\",\n\t\t\"PLUGIN_TAGS\":             \"next,latest\",\n\t\t\"PLUGIN_MY_SECRET\":        \"FooBar\",\n\t\t\"PLUGIN_UPPERCASE_SECRET\": \"FooBar\",\n\t}\n\tsecrets := map[string]string{\n\t\t\"secret_token\": \"FooBar\",\n\t}\n\tgot := map[string]string{}\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"secret %q not found or not allowed to be used\", name)\n\t}\n\tsecretMapping := map[string]string{}\n\tassert.NoError(t, ParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, secretMapping))\n\tassert.EqualValues(t, want, got, \"Problem converting plugin parameters to environment variables\")\n\n\t// handle edge cases (#1609)\n\tgot = map[string]string{}\n\tassert.NoError(t, ParamsToEnv(map[string]any{\"a\": []any{\"a\", nil}}, got, \"PLUGIN_\", true, nil, nil))\n\tassert.EqualValues(t, map[string]string{\"PLUGIN_A\": \"a,\"}, got)\n}\n\nfunc TestParamsToEnvPrefix(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"string\": \"stringz\",\n\t\t\"int\":    1,\n\t}\n\twantPrefixPlugin := map[string]string{\n\t\t\"PLUGIN_STRING\": \"stringz\",\n\t\t\"PLUGIN_INT\":    \"1\",\n\t}\n\tgot := map[string]string{}\n\tgetSecretValue := func(name string) (string, error) {\n\t\treturn \"\", fmt.Errorf(\"secret %q not found or not allowed to be used\", name)\n\t}\n\n\tassert.NoError(t, ParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, nil))\n\tassert.EqualValues(t, wantPrefixPlugin, got, \"Problem converting plugin parameters to environment variables\")\n\n\twantNoPrefix := map[string]string{\n\t\t\"STRING\": \"stringz\",\n\t\t\"INT\":    \"1\",\n\t}\n\n\t// handle edge cases (#1609)\n\tgot = map[string]string{}\n\tassert.NoError(t, ParamsToEnv(from, got, \"\", true, getSecretValue, nil))\n\tassert.EqualValues(t, wantNoPrefix, got, \"Problem converting plugin parameters to environment variables\")\n}\n\nfunc TestSanitizeParamKey(t *testing.T) {\n\tassert.EqualValues(t, \"PLUGIN_DRY_RUN\", sanitizeParamKey(\"PLUGIN_\", true, \"dry-run\"))\n\tassert.EqualValues(t, \"PLUGIN_DRY_RUN\", sanitizeParamKey(\"PLUGIN_\", true, \"dry_Run\"))\n\tassert.EqualValues(t, \"PLUGIN_DRY_RUN\", sanitizeParamKey(\"PLUGIN_\", true, \"dry.run\"))\n\tassert.EqualValues(t, \"PLUGIN_dry-run\", sanitizeParamKey(\"PLUGIN_\", false, \"dry-run\"))\n\tassert.EqualValues(t, \"PLUGIN_dry_Run\", sanitizeParamKey(\"PLUGIN_\", false, \"dry_Run\"))\n\tassert.EqualValues(t, \"PLUGIN_dry.run\", sanitizeParamKey(\"PLUGIN_\", false, \"dry.run\"))\n}\n\nfunc TestYAMLToParamsToEnv(t *testing.T) {\n\tfromYAML := []byte(`skip: ~\nstring: stringz\nint: 1\nfloat: 1.2\nbool: true\nslice: [1, 2, 3]\nmy_secret:\n  from_secret: secret_token\nmap:\n  key: \"value\"\n  entry2:\n    - \"a\"\n    - \"b\"\n    - 3\n  secret:\n    from_secret: secret_token\nlist.map:\n  - registry: https://codeberg.org\n    username: \"6543\"\n    password:\n      from_secret: cb_password\n`)\n\tvar from map[string]any\n\terr := yaml.Unmarshal(fromYAML, &from)\n\tassert.NoError(t, err)\n\n\twant := map[string]string{\n\t\t\"PLUGIN_STRING\":    \"stringz\",\n\t\t\"PLUGIN_INT\":       \"1\",\n\t\t\"PLUGIN_FLOAT\":     \"1.2\",\n\t\t\"PLUGIN_BOOL\":      \"true\",\n\t\t\"PLUGIN_SLICE\":     \"1,2,3\",\n\t\t\"PLUGIN_MY_SECRET\": \"FooBar\",\n\t\t\"PLUGIN_MAP\":       `{\"entry2\":[\"a\",\"b\",3],\"key\":\"value\",\"secret\":\"FooBar\"}`,\n\t\t\"PLUGIN_LIST_MAP\":  `[{\"password\":\"geheim\",\"registry\":\"https://codeberg.org\",\"username\":\"6543\"}]`,\n\t}\n\tsecrets := map[string]string{\n\t\t\"secret_token\": \"FooBar\",\n\t\t\"cb_password\":  \"geheim\",\n\t}\n\tgot := map[string]string{}\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"secret %q not found or not allowed to be used\", name)\n\t}\n\tgotSecretMapping := map[string]string{}\n\twantSecretMapping := map[string]string{\n\t\t\"PLUGIN_MY_SECRET\": \"FooBar\",\n\t\t\"PLUGIN_MAP\":       `{\"entry2\":[\"a\",\"b\",3],\"key\":\"value\",\"secret\":\"FooBar\"}`,\n\t\t\"PLUGIN_LIST_MAP\":  `[{\"password\":\"geheim\",\"registry\":\"https://codeberg.org\",\"username\":\"6543\"}]`,\n\t}\n\tassert.NoError(t, ParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, gotSecretMapping))\n\tassert.Equal(t, wantSecretMapping, gotSecretMapping, \"Problem collecting secret mapping\")\n\tassert.EqualValues(t, want, got, \"Problem converting plugin parameters to environment variables\")\n}\n\nfunc TestYAMLToParamsToEnvError(t *testing.T) {\n\tfromYAML := []byte(`my_secret:\n  from_secret: not_a_secret\n`)\n\tvar from map[string]any\n\terr := yaml.Unmarshal(fromYAML, &from)\n\tassert.NoError(t, err)\n\tsecrets := map[string]string{\n\t\t\"secret_token\": \"FooBar\",\n\t}\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"secret %q not found or not allowed to be used\", name)\n\t}\n\n\tsecretMapping := map[string]string{}\n\tassert.Error(t, ParamsToEnv(from, make(map[string]string), \"PLUGIN_\", true, getSecretValue, secretMapping))\n}\n\nfunc stringsToInterface(val ...string) []any {\n\tres := make([]any, len(val))\n\tfor i := range val {\n\t\tres[i] = val[i]\n\t}\n\treturn res\n}\n\nfunc TestSecretNotFound(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"map\": map[string]any{\"secret\": map[string]any{\"from_secret\": \"secret_token\"}},\n\t}\n\n\tsecrets := map[string]string{\n\t\t\"a_different_password\": \"secret\",\n\t}\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"secret %q not found or not allowed to be used\", name)\n\t}\n\tgot := map[string]string{}\n\tsecretMapping := map[string]string{}\n\tassert.ErrorContains(t,\n\t\tParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, secretMapping),\n\t\tfmt.Sprintf(\"secret %q not found or not allowed to be used\", \"secret_token\"))\n}\n\nfunc TestSecretMappingSimpleSecret(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"simple_secret\": map[string]any{\"from_secret\": \"my_token\"},\n\t\t\"regular_var\":   \"no_secret_here\",\n\t}\n\n\tsecrets := map[string]string{\n\t\t\"my_token\": \"secret_value_123\",\n\t}\n\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"secret %q not found\", name)\n\t}\n\n\tgot := map[string]string{}\n\tsecretMapping := map[string]string{}\n\n\tassert.NoError(t, ParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, secretMapping))\n\n\tassert.Equal(t, \"secret_value_123\", got[\"PLUGIN_SIMPLE_SECRET\"])\n\tassert.Equal(t, \"no_secret_here\", got[\"PLUGIN_REGULAR_VAR\"])\n\n\tassert.Equal(t, \"secret_value_123\", secretMapping[\"PLUGIN_SIMPLE_SECRET\"])\n\tassert.NotContains(t, secretMapping, \"PLUGIN_REGULAR_VAR\")\n}\n\nfunc TestSecretMappingComplexMapWithSecrets(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"config\": map[string]any{\n\t\t\t\"database\": map[string]any{\n\t\t\t\t\"host\":     \"localhost\",\n\t\t\t\t\"password\": map[string]any{\"from_secret\": \"db_password\"},\n\t\t\t\t\"port\":     5432,\n\t\t\t},\n\t\t\t\"api_key\": map[string]any{\"from_secret\": \"api_secret\"},\n\t\t\t\"timeout\": 30,\n\t\t},\n\t\t\"simple_var\": \"no_secrets\",\n\t}\n\n\tsecrets := map[string]string{\n\t\t\"db_password\": \"super_secret_db_pass\",\n\t\t\"api_secret\":  \"api_key_12345\",\n\t}\n\n\tgetSecretValue := func(name string) (string, error) {\n\t\tname = strings.ToLower(name)\n\t\tsecret, ok := secrets[name]\n\t\tif ok {\n\t\t\treturn secret, nil\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"secret %q not found\", name)\n\t}\n\n\tgot := map[string]string{}\n\tsecretMapping := map[string]string{}\n\n\tassert.NoError(t, ParamsToEnv(from, got, \"PLUGIN_\", true, getSecretValue, secretMapping))\n\n\texpectedJSON := `{\"api_key\":\"api_key_12345\",\"database\":{\"host\":\"localhost\",\"password\":\"super_secret_db_pass\",\"port\":5432},\"timeout\":30}`\n\tassert.Equal(t, expectedJSON, got[\"PLUGIN_CONFIG\"])\n\tassert.Equal(t, \"no_secrets\", got[\"PLUGIN_SIMPLE_VAR\"])\n\n\tassert.Equal(t, expectedJSON, secretMapping[\"PLUGIN_CONFIG\"])\n\tassert.NotContains(t, secretMapping, \"PLUGIN_SIMPLE_VAR\")\n}\n\nfunc TestComplexTypesWithNilValuesWontPanic(t *testing.T) {\n\tfrom := map[string]any{\n\t\t\"config\": []any{\n\t\t\t\"copy a b\",\n\t\t\tmap[string]any{\n\t\t\t\t\"foo\": nil,\n\t\t\t},\n\t\t},\n\t}\n\n\tgot := map[string]string{}\n\texpectedJSON := `[\"copy a b\",{\"foo\":null}]`\n\n\terr := ParamsToEnv(from, got, \"PLUGIN_\", true, nil, nil)\n\tassert.NoError(t, err)\n\tassert.Equal(t, expectedJSON, got[\"PLUGIN_CONFIG\"])\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/constraint.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"path\"\n\t\"slices\"\n\n\t\"github.com/expr-lang/expr\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\tyaml_base_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\nconst (\n\tstatusFailure = \"failure\"\n\tstatusSuccess = \"success\"\n)\n\ntype (\n\t// When defines a set of runtime constraints.\n\tWhen struct {\n\t\t// If true then read from a list of constraint\n\t\tConstraints []Constraint\n\t}\n\n\tConstraint struct {\n\t\tRef      List                          `yaml:\"ref,omitempty\"`\n\t\tRepo     List                          `yaml:\"repo,omitempty\"`\n\t\tInstance List                          `yaml:\"instance,omitempty\"`\n\t\tPlatform List                          `yaml:\"platform,omitempty\"`\n\t\tBranch   List                          `yaml:\"branch,omitempty\"`\n\t\tCron     List                          `yaml:\"cron,omitempty\"`\n\t\tStatus   yaml_base_types.StringOrSlice `yaml:\"status,omitempty\"`\n\t\tMatrix   Map                           `yaml:\"matrix,omitempty\"`\n\t\tLocal    optional.Option[bool]         `yaml:\"local,omitempty\"`\n\t\tPath     Path                          `yaml:\"path,omitempty\"`\n\t\tEvaluate string                        `yaml:\"evaluate,omitempty\"`\n\t\tEvent    yaml_base_types.StringOrSlice `yaml:\"event,omitempty\"`\n\t}\n)\n\nfunc (when *When) IsEmpty() bool {\n\treturn len(when.Constraints) == 0\n}\n\n// Returns true if at least one of the internal constraints is true.\nfunc (when *When) Match(metadata metadata.Metadata, global bool, env map[string]string) (bool, error) {\n\tfor _, c := range when.Constraints {\n\t\tmatch, err := c.Match(metadata, global, env)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tif match {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\n\tif when.IsEmpty() {\n\t\t// test against default Constraints\n\t\tempty := &Constraint{}\n\t\treturn empty.Match(metadata, global, env)\n\t}\n\treturn false, nil\n}\n\nfunc (when *When) IncludesStatusFailure(metadata metadata.Metadata, global bool, env map[string]string) bool {\n\tif when.IsEmpty() {\n\t\treturn false\n\t}\n\tfor _, c := range when.Constraints {\n\t\tif matches, err := c.Match(metadata, global, env); err == nil && matches {\n\t\t\tif slices.Contains(c.Status, statusFailure) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (when *When) IncludesStatusSuccess(metadata metadata.Metadata, global bool, env map[string]string) bool {\n\t// \"success\" acts differently than \"failure\" in that it's\n\t// presumed to be included unless it's specifically not part\n\t// of the list\n\tif when.IsEmpty() {\n\t\treturn true\n\t}\n\tfor _, c := range when.Constraints {\n\t\tif matches, err := c.Match(metadata, global, env); err == nil && matches {\n\t\t\tif len(c.Status) == 0 || slices.Contains(c.Status, statusSuccess) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// False if (any) non local.\nfunc (when *When) IsLocal() bool {\n\tfor _, c := range when.Constraints {\n\t\tif !c.Local.ValueOrDefault(true) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (when *When) UnmarshalYAML(value *yaml.Node) error {\n\tswitch value.Kind {\n\tcase yaml.SequenceNode:\n\t\tif err := value.Decode(&when.Constraints); err != nil {\n\t\t\treturn err\n\t\t}\n\n\tcase yaml.MappingNode:\n\t\tc := Constraint{}\n\t\tif err := value.Decode(&c); err != nil {\n\t\t\treturn err\n\t\t}\n\t\twhen.Constraints = append(when.Constraints, c)\n\n\tdefault:\n\t\treturn fmt.Errorf(\"not supported yaml kind: %v\", value.Kind)\n\t}\n\n\treturn nil\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (when When) MarshalYAML() (any, error) {\n\t// clean up local if true make it none as we will default to true\n\tfor i := range when.Constraints {\n\t\tif when.Constraints[i].Local.ValueOrDefault(true) {\n\t\t\twhen.Constraints[i].Local = optional.None[bool]()\n\t\t}\n\t}\n\n\tswitch len(when.Constraints) {\n\tcase 0:\n\t\treturn nil, nil\n\tcase 1:\n\t\treturn when.Constraints[0], nil\n\tdefault:\n\t\treturn when.Constraints, nil\n\t}\n}\n\n// Match returns true if all constraints match the given input. If a single\n// constraint fails a false value is returned.\nfunc (c *Constraint) Match(m metadata.Metadata, global bool, env map[string]string) (bool, error) {\n\tmatch := true\n\tif !global {\n\t\t// apply step only filters\n\t\tmatch = c.Matrix.Match(m.Workflow.Matrix)\n\t}\n\n\tmatch = match && c.Platform.Match(m.Sys.Platform) &&\n\t\t(len(c.Event) == 0 || slices.Contains(c.Event, string(m.Curr.Event))) &&\n\t\tc.Repo.Match(path.Join(m.Repo.Owner, m.Repo.Name)) &&\n\t\tc.Ref.Match(m.Curr.Commit.Ref) &&\n\t\tc.Instance.Match(m.Sys.Host)\n\n\t// changed files filter apply only for pull-request and push events\n\tif m.Curr.Event.IsPull() || m.Curr.Event == metadata.EventPush {\n\t\tmatch = match && c.Path.Match(m.Curr.Commit.ChangedFiles, m.Curr.Commit.Message)\n\t}\n\n\tif m.Curr.Event != metadata.EventTag {\n\t\tmatch = match && c.Branch.Match(m.Curr.Commit.Branch)\n\t}\n\n\tif m.Curr.Event == metadata.EventCron {\n\t\tmatch = match && c.Cron.Match(m.Curr.Cron)\n\t}\n\n\tif c.Evaluate != \"\" {\n\t\tif env == nil {\n\t\t\tenv = m.Environ()\n\t\t} else {\n\t\t\tmaps.Copy(env, m.Environ())\n\t\t}\n\t\tout, err := expr.Compile(c.Evaluate, expr.Env(env), expr.AllowUndefinedVariables(), expr.AsBool())\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tresult, err := expr.Run(out, env)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\tbResult, ok := result.(bool)\n\t\tif !ok {\n\t\t\treturn false, fmt.Errorf(\"could not parse result: %v\", result)\n\t\t}\n\t\tmatch = match && bResult\n\t}\n\n\treturn match, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/constraint_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n)\n\nfunc TestConstraintStatusSuccessFailure(t *testing.T) {\n\ttestdata := []struct {\n\t\tconf        string\n\t\twantSuccess bool\n\t\twantFail    bool\n\t}{\n\t\t{conf: \"\", wantSuccess: true, wantFail: false},\n\t\t{conf: \"{status: [failure]}\", wantSuccess: false, wantFail: true},\n\t\t{conf: \"{status: [success]}\", wantSuccess: true, wantFail: false},\n\t\t{conf: \"{status: [failure, success]}\", wantSuccess: true, wantFail: true},\n\t\t{conf: \"{event: push, status: [failure, success]}\", wantSuccess: false, wantFail: false},\n\t\t{conf: \"{event: pull_request, status: [failure, success]}\", wantSuccess: true, wantFail: true},\n\t\t{conf: \"{event: push, status: failure}\", wantSuccess: false, wantFail: false},\n\t\t{conf: \"{event: pull_request, status: [failure]}\", wantSuccess: false, wantFail: true},\n\t\t{conf: \"{status: success}\", wantSuccess: true, wantFail: false},\n\t\t{conf: \"[{}]\", wantSuccess: true, wantFail: false},\n\t\t{conf: \"[{status: success}]\", wantSuccess: true, wantFail: false},\n\t\t{conf: \"[{},{status: failure}]\", wantSuccess: true, wantFail: true},\n\t\t{conf: \"[{event: push, status: success},{status: failure}]\", wantSuccess: false, wantFail: true},\n\t\t{conf: \"[{status: failure},{event: push, status: success}]\", wantSuccess: false, wantFail: true},\n\t}\n\tfor _, test := range testdata {\n\t\tt.Run(test.conf, func(t *testing.T) {\n\t\t\tc := parseConstraints(t, test.conf)\n\t\t\tassert.Equalf(t,\n\t\t\t\ttest.wantSuccess,\n\t\t\t\tc.IncludesStatusSuccess(metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPull}}, true, map[string]string{}),\n\t\t\t\t\"include success is wrong for when: '%s'\", test.conf)\n\t\t\tassert.Equal(t,\n\t\t\t\ttest.wantFail,\n\t\t\t\tc.IncludesStatusFailure(metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPull}}, true, map[string]string{}),\n\t\t\t\t\"include fail is wrong for when: '%s'\", test.conf)\n\t\t})\n\t}\n}\n\nfunc TestConstraints(t *testing.T) {\n\ttestdata := []struct {\n\t\tdesc string\n\t\tconf string\n\t\twith metadata.Metadata\n\t\tenv  map[string]string\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tdesc: \"no constraints, must match on default events\",\n\t\t\tconf: \"\",\n\t\t\twith: metadata.Metadata{\n\t\t\t\tCurr: metadata.Pipeline{\n\t\t\t\t\tEvent: metadata.EventPush,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"global branch filter\",\n\t\t\tconf: \"{ branch: develop }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: \"main\"}}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"global branch filter\",\n\t\t\tconf: \"{ branch: main }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: \"main\"}}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"repo constraint\",\n\t\t\tconf: \"{ repo: owner/* }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: \"owner\", Name: \"repo\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"repo constraint\",\n\t\t\tconf: \"{ repo: octocat/* }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: \"owner\", Name: \"repo\"}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"ref constraint\",\n\t\t\tconf: \"{ ref: refs/tags/* }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Commit: metadata.Commit{Ref: \"refs/tags/v1.0.0\"}, Event: metadata.EventPush}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"ref constraint\",\n\t\t\tconf: \"{ ref: refs/tags/* }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Commit: metadata.Commit{Ref: \"refs/heads/main\"}, Event: metadata.EventPush}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"platform constraint\",\n\t\t\tconf: \"{ platform: linux/amd64 }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Platform: \"linux/amd64\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"platform constraint\",\n\t\t\tconf: \"{ repo: linux/amd64 }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Platform: \"windows/amd64\"}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"instance constraint\",\n\t\t\tconf: \"{ instance: agent.tld }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Host: \"agent.tld\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"instance constraint\",\n\t\t\tconf: \"{ instance: agent.tld }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Host: \"beta.agent.tld\"}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter cron by matching name\",\n\t\t\tconf: \"{ event: cron, cron: job1 }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventCron, Cron: \"job1\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter cron by name\",\n\t\t\tconf: \"{ event: cron, cron: job2 }\",\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventCron, Cron: \"job1\"}},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter with build-in env passes\",\n\t\t\tconf: \"{ branch: ${CI_REPO_DEFAULT_BRANCH} }\",\n\t\t\twith: metadata.Metadata{\n\t\t\t\tCurr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: \"stable\"}},\n\t\t\t\tRepo: metadata.Repo{Branch: \"stable\"},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter by eval based on event\",\n\t\t\tconf: `{ evaluate: 'CI_PIPELINE_EVENT == \"push\"' }`,\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter by eval based on event and repo\",\n\t\t\tconf: `{ evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\"' }`,\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: \"owner\", Name: \"repo\"}},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter by eval based on custom variable\",\n\t\t\tconf: `{ evaluate: 'TESTVAR == \"testval\"' }`,\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventManual}},\n\t\t\tenv:  map[string]string{\"TESTVAR\": \"testval\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"filter by eval based on custom variable\",\n\t\t\tconf: `{ evaluate: 'TESTVAR == \"testval\"' }`,\n\t\t\twith: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventManual}},\n\t\t\tenv:  map[string]string{\"TESTVAR\": \"qwe\"},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, test := range testdata {\n\t\tt.Run(test.desc, func(t *testing.T) {\n\t\t\tconf, err := metadata.EnvVarSubst(test.conf, test.with.Environ())\n\t\t\tassert.NoError(t, err)\n\t\t\tc := parseConstraints(t, conf)\n\t\t\tgot, err := c.Match(test.with, false, test.env)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, test.want, got)\n\t\t})\n\t}\n}\n\nfunc parseConstraints(t *testing.T, s string) *When {\n\tt.Helper()\n\tc := &When{}\n\trequire.NoError(t, yaml.Unmarshal([]byte(s), c))\n\treturn c\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/list.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/bmatcuk/doublestar/v4\"\n\t\"go.uber.org/multierr\"\n\t\"gopkg.in/yaml.v3\"\n\n\tyaml_base_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n)\n\n// List defines a runtime constraint for exclude & include string slices.\ntype List struct {\n\tInclude []string\n\tExclude []string\n}\n\n// IsEmpty return true if a constraint has no conditions.\nfunc (c List) IsEmpty() bool {\n\treturn len(c.Include) == 0 && len(c.Exclude) == 0\n}\n\n// Match returns true if the string matches the include patterns and does not\n// match any of the exclude patterns.\nfunc (c *List) Match(v string) bool {\n\tif c == nil {\n\t\treturn true\n\t}\n\tif c.Excludes(v) {\n\t\treturn false\n\t}\n\tif c.Includes(v) {\n\t\treturn true\n\t}\n\tif len(c.Include) == 0 {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// Includes returns true if the string matches the include patterns.\nfunc (c *List) Includes(v string) bool {\n\tfor _, pattern := range c.Include {\n\t\tif ok, _ := doublestar.Match(pattern, v); ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Excludes returns true if the string matches the exclude patterns.\nfunc (c *List) Excludes(v string) bool {\n\tfor _, pattern := range c.Exclude {\n\t\tif ok, _ := doublestar.Match(pattern, v); ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// UnmarshalYAML unmarshal the constraint.\nfunc (c *List) UnmarshalYAML(value *yaml.Node) error {\n\tout1 := struct {\n\t\tInclude yaml_base_types.StringOrSlice\n\t\tExclude yaml_base_types.StringOrSlice\n\t}{}\n\n\tvar out2 yaml_base_types.StringOrSlice\n\n\terr1 := value.Decode(&out1)\n\terr2 := value.Decode(&out2)\n\n\tc.Exclude = out1.Exclude\n\tc.Include = append( //nolint:gocritic\n\t\tout1.Include,\n\t\tout2...,\n\t)\n\n\tif err1 != nil && err2 != nil {\n\t\ty, _ := yaml.Marshal(value)\n\t\treturn fmt.Errorf(\"could not parse condition: %s: %w\", y, multierr.Append(err1, err2))\n\t}\n\n\treturn nil\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (c List) MarshalYAML() (any, error) {\n\tswitch {\n\tcase len(c.Include) == 0 && len(c.Exclude) == 0:\n\t\treturn nil, nil\n\tcase len(c.Exclude) == 0:\n\t\treturn yaml_base_types.StringOrSlice(c.Include), nil\n\tdefault:\n\t\t// we can not return type List as it would lead to infinite recursion :/\n\t\treturn struct {\n\t\t\tInclude yaml_base_types.StringOrSlice `yaml:\"include,omitempty\"`\n\t\t\tExclude yaml_base_types.StringOrSlice `yaml:\"exclude,omitempty\"`\n\t\t}{\n\t\t\tInclude: c.Include,\n\t\t\tExclude: c.Exclude,\n\t\t}, nil\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/list_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestConstraintList(t *testing.T) {\n\ttestdata := []struct {\n\t\tconf string\n\t\twith string\n\t\twant bool\n\t}{\n\t\t// string value\n\t\t{\n\t\t\tconf: \"main\",\n\t\t\twith: \"develop\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"main\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"feature/*\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: true,\n\t\t},\n\t\t// slice value\n\t\t{\n\t\t\tconf: \"[ main, feature/* ]\",\n\t\t\twith: \"develop\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"[ main, feature/* ]\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"[ main, feature/* ]\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: true,\n\t\t},\n\t\t// includes block\n\t\t{\n\t\t\tconf: \"include: main\",\n\t\t\twith: \"develop\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: main\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: feature/*\",\n\t\t\twith: \"main\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: feature/*\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: [ main, feature/* ]\",\n\t\t\twith: \"develop\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: [ main, feature/* ]\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: [ main, feature/* ]\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: true,\n\t\t},\n\t\t// excludes block\n\t\t{\n\t\t\tconf: \"exclude: main\",\n\t\t\twith: \"develop\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: main\",\n\t\t\twith: \"main\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: feature/*\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: feature/*\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: [ main, develop ]\",\n\t\t\twith: \"main\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: [ feature/*, bar ]\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: [ feature/*, bar ]\",\n\t\t\twith: \"feature/foo\",\n\t\t\twant: false,\n\t\t},\n\t\t// include and exclude blocks\n\t\t{\n\t\t\tconf: \"{ include: [ main, feature/* ], exclude: [ develop ] }\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ main, feature/* ], exclude: [ feature/bar ] }\",\n\t\t\twith: \"feature/bar\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ main, feature/* ], exclude: [ main, develop ] }\",\n\t\t\twith: \"main\",\n\t\t\twant: false,\n\t\t},\n\t\t// empty blocks\n\t\t{\n\t\t\tconf: \"\",\n\t\t\twith: \"main\",\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tc := parseConstraintList(t, test.conf)\n\t\tassert.Equal(t, test.want, c.Match(test.with))\n\t}\n}\n\nfunc parseConstraintList(t *testing.T, s string) *List {\n\tc := &List{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(s), c))\n\treturn c\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/map.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport \"github.com/bmatcuk/doublestar/v4\"\n\n// Map defines a runtime constraint for exclude & include map strings.\ntype Map struct {\n\tInclude map[string]string `yaml:\"include,omitempty\"`\n\tExclude map[string]string `yaml:\"exclude,omitempty\"`\n}\n\n// Match returns true if the params matches the include key values and does not\n// match any of the exclude key values.\nfunc (c *Map) Match(params map[string]string) bool {\n\t// when no includes or excludes automatically match\n\tif c == nil || len(c.Include) == 0 && len(c.Exclude) == 0 {\n\t\treturn true\n\t}\n\n\t// Exclusions are processed first. So we can include everything and then\n\t// selectively include others.\n\tif len(c.Exclude) != 0 {\n\t\tvar matches int\n\n\t\tfor key, val := range c.Exclude {\n\t\t\tif ok, _ := doublestar.Match(val, params[key]); ok {\n\t\t\t\tmatches++\n\t\t\t}\n\t\t}\n\t\tif matches == len(c.Exclude) {\n\t\t\treturn false\n\t\t}\n\t}\n\tfor key, val := range c.Include {\n\t\tif ok, _ := doublestar.Match(val, params[key]); !ok {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// UnmarshalYAML unmarshal the constraint map.\nfunc (c *Map) UnmarshalYAML(unmarshal func(any) error) error {\n\tout1 := struct {\n\t\tInclude map[string]string\n\t\tExclude map[string]string\n\t}{\n\t\tInclude: map[string]string{},\n\t\tExclude: map[string]string{},\n\t}\n\n\tout2 := map[string]string{}\n\n\t_ = unmarshal(&out1) // it contains include and exclude statement\n\t_ = unmarshal(&out2) // it contains no include/exclude statement, assume include as default\n\n\tc.Include = out1.Include\n\tc.Exclude = out1.Exclude\n\tfor k, v := range out2 {\n\t\tc.Include[k] = v\n\t}\n\treturn nil\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (c Map) MarshalYAML() (any, error) {\n\tswitch {\n\tcase len(c.Include) == 0 && len(c.Exclude) == 0:\n\t\treturn nil, nil\n\tcase len(c.Exclude) == 0:\n\t\treturn c.Include, nil\n\tcase len(c.Include) == 0 && len(c.Exclude) != 0:\n\t\treturn struct {\n\t\t\tExclude map[string]string\n\t\t}{Exclude: c.Exclude}, nil\n\tdefault:\n\t\t// we can not return type Map as it would lead to infinite recursion :/\n\t\treturn struct {\n\t\t\tInclude map[string]string `yaml:\"include,omitempty\"`\n\t\t\tExclude map[string]string `yaml:\"exclude,omitempty\"`\n\t\t}{\n\t\t\tInclude: c.Include,\n\t\t\tExclude: c.Exclude,\n\t\t}, nil\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/map_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestConstraintMap(t *testing.T) {\n\ttestdata := []struct {\n\t\tconf string\n\t\twith map[string]string\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tconf: \"GOLANG: 1.7\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"GOLANG: tip\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.1\", \"MYSQL\": \"5.6\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.0\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, REDIS: 3.* }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.0\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, BRANCH: release/**/test }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"BRANCH\": \"release/v1.12.1//test\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, BRANCH: release/**/test }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"BRANCH\": \"release/v1.12.1/qest\"},\n\t\t\twant: false,\n\t\t},\n\t\t// include syntax\n\t\t{\n\t\t\tconf: \"include: { GOLANG: 1.7 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: { GOLANG: tip }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.1\", \"MYSQL\": \"5.6\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.0\"},\n\t\t\twant: false,\n\t\t},\n\t\t// exclude syntax\n\t\t{\n\t\t\tconf: \"exclude: { GOLANG: 1.7 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: { GOLANG: tip }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.1\", \"MYSQL\": \"5.6\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.0\"},\n\t\t\twant: true,\n\t\t},\n\t\t// exclude AND include values\n\t\t{\n\t\t\tconf: \"{ include: { GOLANG: 1.7 }, exclude: { GOLANG: 1.7 } }\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\"},\n\t\t\twant: false,\n\t\t},\n\t\t// blanks\n\t\t{\n\t\t\tconf: \"\",\n\t\t\twith: map[string]string{\"GOLANG\": \"1.7\", \"REDIS\": \"3.0\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"GOLANG: 1.7\",\n\t\t\twith: map[string]string{},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ GOLANG: 1.7, REDIS: 3.0 }\",\n\t\t\twith: map[string]string{},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"include: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"exclude: { GOLANG: 1.7, REDIS: 3.1 }\",\n\t\t\twith: map[string]string{},\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tc := parseConstraintMap(t, test.conf)\n\t\tassert.Equal(t, test.want, c.Match(test.with), \"config: '%s', with: '%s'\", test.conf, test.with)\n\t}\n}\n\nfunc parseConstraintMap(t *testing.T, s string) *Map {\n\tc := &Map{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(s), c))\n\treturn c\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/path.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/bmatcuk/doublestar/v4\"\n\t\"gopkg.in/yaml.v3\"\n\n\tyaml_base_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\n// Path defines a runtime constrain for exclude & include paths.\ntype Path struct {\n\tInclude       []string              `yaml:\"include,omitempty\"`\n\tExclude       []string              `yaml:\"exclude,omitempty\"`\n\tIgnoreMessage string                `yaml:\"ignore_message,omitempty\"`\n\tOnEmpty       optional.Option[bool] `yaml:\"on_empty,omitempty\"`\n}\n\n// UnmarshalYAML unmarshal the constraint.\nfunc (c *Path) UnmarshalYAML(value *yaml.Node) error {\n\tout1 := struct {\n\t\tInclude       yaml_base_types.StringOrSlice `yaml:\"include\"`\n\t\tExclude       yaml_base_types.StringOrSlice `yaml:\"exclude\"`\n\t\tIgnoreMessage string                        `yaml:\"ignore_message\"`\n\t\tOnEmpty       optional.Option[bool]         `yaml:\"on_empty\"`\n\t}{}\n\n\tvar out2 yaml_base_types.StringOrSlice\n\n\terr1 := value.Decode(&out1)\n\terr2 := value.Decode(&out2)\n\n\tc.Exclude = out1.Exclude\n\tc.IgnoreMessage = out1.IgnoreMessage\n\tc.OnEmpty = out1.OnEmpty\n\tc.Include = append( //nolint:gocritic\n\t\tout1.Include,\n\t\tout2...,\n\t)\n\n\tif err1 != nil && err2 != nil {\n\t\ty, _ := yaml.Marshal(value)\n\t\treturn fmt.Errorf(\"could not parse condition: %s\", y)\n\t}\n\n\treturn nil\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (c Path) MarshalYAML() (any, error) {\n\t// if only Include is set return simple syntax\n\tif len(c.Exclude) == 0 &&\n\t\tlen(c.IgnoreMessage) == 0 &&\n\t\tc.OnEmpty.ValueOrDefault(true) {\n\t\tif len(c.Include) == 0 {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn yaml_base_types.StringOrSlice(c.Include), nil\n\t}\n\n\t// clean up on_empty if true make it none as we will default to true\n\tif c.OnEmpty.ValueOrDefault(true) {\n\t\tc.OnEmpty = optional.None[bool]()\n\t}\n\n\t// we can not return type Path as it would lead to infinite recursion :/\n\treturn struct {\n\t\tInclude       yaml_base_types.StringOrSlice `yaml:\"include,omitempty\"`\n\t\tExclude       yaml_base_types.StringOrSlice `yaml:\"exclude,omitempty\"`\n\t\tIgnoreMessage string                        `yaml:\"ignore_message,omitempty\"`\n\t\tOnEmpty       optional.Option[bool]         `yaml:\"on_empty,omitempty\"`\n\t}{\n\t\tInclude:       c.Include,\n\t\tExclude:       c.Exclude,\n\t\tIgnoreMessage: c.IgnoreMessage,\n\t\tOnEmpty:       c.OnEmpty,\n\t}, nil\n}\n\n// Match returns true if file paths in string slice matches the include and not exclude patterns\n// or if commit message contains ignore message.\nfunc (c *Path) Match(v []string, message string) bool {\n\t// ignore file pattern matches if the commit message contains a pattern\n\tif len(c.IgnoreMessage) > 0 && strings.Contains(strings.ToLower(message), strings.ToLower(c.IgnoreMessage)) {\n\t\treturn true\n\t}\n\n\t// return value based on 'on_empty', if there are no commit files (empty commit)\n\tif len(v) == 0 {\n\t\treturn c.OnEmpty.ValueOrDefault(true)\n\t}\n\n\tif len(c.Exclude) > 0 && c.Excludes(v) {\n\t\treturn false\n\t}\n\tif len(c.Include) > 0 && !c.Includes(v) {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// Includes returns true if the string matches any of the include patterns.\nfunc (c *Path) Includes(v []string) bool {\n\tfor _, pattern := range c.Include {\n\t\tfor _, file := range v {\n\t\t\tif ok, _ := doublestar.Match(pattern, file); ok {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// Excludes returns true if all of the strings match any of the exclude patterns.\nfunc (c *Path) Excludes(v []string) bool {\n\tfor _, file := range v {\n\t\tmatched := false\n\t\tfor _, pattern := range c.Exclude {\n\t\t\tif ok, _ := doublestar.Match(pattern, file); ok {\n\t\t\t\tmatched = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !matched {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/path_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestConstraintPath(t *testing.T) {\n\ttestdata := []struct {\n\t\tconf    string\n\t\twith    []string\n\t\tmessage string\n\t\twant    bool\n\t}{\n\t\t{\n\t\t\tconf: \"\",\n\t\t\twith: []string{\"CHANGELOG.md\", \"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"CHANGELOG.md\",\n\t\t\twith: []string{\"CHANGELOG.md\", \"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"'*.md'\",\n\t\t\twith: []string{\"CHANGELOG.md\", \"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"['*.md']\",\n\t\t\twith: []string{\"CHANGELOG.md\", \"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"'docs/*'\",\n\t\t\twith: []string{\"docs/README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"'docs/*'\",\n\t\t\twith: []string{\"docs/sub/README.md\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"'docs/**'\",\n\t\t\twith: []string{\"docs/README.md\", \"docs/sub/README.md\", \"docs/sub-sub/README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"'docs/**'\",\n\t\t\twith: []string{\"README.md\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ README.md ] }\",\n\t\t\twith: []string{\"CHANGELOG.md\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ exclude: [ README.md ] }\",\n\t\t\twith: []string{\"design.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t// include and exclude blocks\n\t\t{\n\t\t\tconf: \"{ include: [ '*.md', '*.ini' ], exclude: [ CHANGELOG.md ] }\",\n\t\t\twith: []string{\"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ '*.md' ], exclude: [ CHANGELOG.md ] }\",\n\t\t\twith: []string{\"CHANGELOG.md\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ '*.md' ], exclude: [ CHANGELOG.md ] }\",\n\t\t\twith: []string{\"README.md\", \"CHANGELOG.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ exclude: [ CHANGELOG.md ] }\",\n\t\t\twith: []string{\"README.md\", \"CHANGELOG.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ exclude: [ CHANGELOG.md, docs/**/*.md ] }\",\n\t\t\twith: []string{\"docs/main.md\", \"CHANGELOG.md\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ exclude: [ CHANGELOG.md, docs/**/*.md ] }\",\n\t\t\twith: []string{\"docs/main.md\", \"CHANGELOG.md\", \"README.md\"},\n\t\t\twant: true,\n\t\t},\n\t\t// commit message ignore matches\n\t\t{\n\t\t\tconf:    \"{ include: [ README.md ], ignore_message: '[ALL]' }\",\n\t\t\twith:    []string{\"CHANGELOG.md\"},\n\t\t\tmessage: \"Build them [ALL]\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tconf:    \"{ exclude: [ '*.php' ], ignore_message: '[ALL]' }\",\n\t\t\twith:    []string{\"myfile.php\"},\n\t\t\tmessage: \"Build them [ALL]\",\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tconf:    \"{ ignore_message: '[ALL]' }\",\n\t\t\twith:    []string{},\n\t\t\tmessage: \"Build them [ALL]\",\n\t\t\twant:    true,\n\t\t},\n\t\t// empty commit\n\t\t{\n\t\t\tconf: \"{ include: [ README.md ] }\",\n\t\t\twith: []string{},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ README.md ], on_empty: false }\",\n\t\t\twith: []string{},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tconf: \"{ include: [ README.md ], on_empty: true }\",\n\t\t\twith: []string{},\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tc := parseConstraintPath(t, test.conf)\n\t\tassert.Equal(t, test.want, c.Match(test.with, test.message))\n\t}\n}\n\nfunc parseConstraintPath(t *testing.T, s string) *Path {\n\tc := &Path{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(s), c))\n\treturn c\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/constraint/skip.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constraint\n\nimport (\n\t\"regexp\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n)\n\nvar skipPipelineRegex = regexp.MustCompile(`\\[(?i:ci *skip|skip *ci)\\]`)\n\nfunc IsSkipCommitMessage(event metadata.Event, commitMessage string) bool {\n\tif event == metadata.EventPush || event.IsPull() {\n\t\tskipMatch := skipPipelineRegex.FindString(commitMessage)\n\t\tif len(skipMatch) > 0 {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/error.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage linter\n\nimport (\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n)\n\nfunc newLinterError(message, file, field string, isWarning bool) *pipeline_errors.PipelineError {\n\treturn &pipeline_errors.PipelineError{\n\t\tType:      pipeline_errors.PipelineErrorTypeLinter,\n\t\tMessage:   message,\n\t\tData:      &pipeline_errors.LinterErrorData{File: file, Field: field},\n\t\tIsWarning: isWarning,\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/linter.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage linter\n\nimport (\n\t\"fmt\"\n\n\t\"codeberg.org/6543/xyaml\"\n\t\"go.uber.org/multierr\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\n// networkModeNone is a const we use to check to allow to drop network completely\n// this should be exempt from privileged action as it makes the container even more unprivileged.\nconst networkModeNone = \"none\"\n\n// A Linter lints a pipeline configuration.\ntype Linter struct {\n\ttrusted             TrustedConfiguration\n\tprivilegedPlugins   *[]string\n\ttrustedClonePlugins *[]string\n}\n\ntype TrustedConfiguration struct {\n\tNetwork  bool\n\tVolumes  bool\n\tSecurity bool\n}\n\n// New creates a new Linter with options.\nfunc New(opts ...Option) *Linter {\n\tlinter := new(Linter)\n\tfor _, opt := range opts {\n\t\topt(linter)\n\t}\n\treturn linter\n}\n\ntype WorkflowConfig struct {\n\t// File is the path to the configuration file.\n\tFile string\n\n\t// RawConfig is the raw configuration.\n\tRawConfig string\n\n\t// Config is the parsed configuration.\n\tWorkflow *types.Workflow\n}\n\n// Lint lints the configuration.\nfunc (l *Linter) Lint(configs []*WorkflowConfig) error {\n\tvar linterErr error\n\n\tfor _, config := range configs {\n\t\tif err := l.lintFile(config); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t}\n\n\treturn linterErr\n}\n\nfunc (l *Linter) lintFile(config *WorkflowConfig) error {\n\tvar linterErr error\n\n\tif len(config.Workflow.Steps.ContainerList) == 0 {\n\t\tlinterErr = multierr.Append(linterErr, newLinterError(\"Invalid or missing `steps` section\", config.File, \"steps\", false))\n\t}\n\n\tif err := l.lintCloneSteps(config); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\n\tif err := l.lintContainers(config, \"clone\"); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\tif err := l.lintContainers(config, \"steps\"); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\tif err := l.lintContainers(config, \"services\"); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\n\tif err := l.lintSchema(config); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\tif err := l.lintDeprecations(config); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\tif err := l.lintBadHabits(config); err != nil {\n\t\tlinterErr = multierr.Append(linterErr, err)\n\t}\n\n\treturn linterErr\n}\n\nfunc (l *Linter) lintCloneSteps(config *WorkflowConfig) error {\n\tif len(config.Workflow.Clone.ContainerList) == 0 {\n\t\treturn nil\n\t}\n\n\ttrustedClonePlugins := constant.TrustedClonePlugins\n\tif l.trustedClonePlugins != nil {\n\t\ttrustedClonePlugins = *l.trustedClonePlugins\n\t}\n\n\tvar linterErr error\n\tfor _, container := range config.Workflow.Clone.ContainerList {\n\t\tif !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) {\n\t\t\tlinterErr = multierr.Append(linterErr,\n\t\t\t\tnewLinterError(\n\t\t\t\t\t\"Specified clone image does not match allow list, netrc is not injected\",\n\t\t\t\t\tconfig.File, fmt.Sprintf(\"clone.%s\", container.Name), true),\n\t\t\t)\n\t\t}\n\t}\n\treturn linterErr\n}\n\nfunc (l *Linter) lintContainers(config *WorkflowConfig, area string) error {\n\tvar linterErr error\n\n\tvar containers []*types.Container\n\n\tswitch area {\n\tcase \"clone\":\n\t\tcontainers = config.Workflow.Clone.ContainerList\n\tcase \"steps\":\n\t\tcontainers = config.Workflow.Steps.ContainerList\n\tcase \"services\":\n\t\tcontainers = config.Workflow.Services.ContainerList\n\t}\n\n\tfor _, container := range containers {\n\t\tif err := l.lintImage(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t\tif err := l.lintTrusted(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t\tif err := l.lintSettings(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t\tif err := l.lintPrivilegedPlugins(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t\tif err := l.lintContainerDeprecations(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t\tif err := l.lintDependsOn(config, container, area); err != nil {\n\t\t\tlinterErr = multierr.Append(linterErr, err)\n\t\t}\n\t}\n\n\treturn linterErr\n}\n\nfunc (l *Linter) lintDependsOn(config *WorkflowConfig, c *types.Container, area string) error {\n\tif area != \"steps\" {\n\t\treturn nil\n\t}\n\n\tvar linterErr error\ncheck:\n\tfor _, dep := range c.DependsOn {\n\t\tfor _, step := range config.Workflow.Steps.ContainerList {\n\t\t\tif dep == step.Name {\n\t\t\t\tcontinue check\n\t\t\t}\n\t\t}\n\t\tlinterErr = multierr.Append(linterErr,\n\t\t\tnewLinterError(\n\t\t\t\t\"One or more of the specified dependencies do not exist\",\n\t\t\t\tconfig.File, fmt.Sprintf(\"%s.%s.depends_on\", area, c.Name), false,\n\t\t\t),\n\t\t)\n\t}\n\treturn linterErr\n}\n\nfunc (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area string) error {\n\tif len(c.Image) == 0 {\n\t\treturn newLinterError(\"Invalid or missing image\", config.File, fmt.Sprintf(\"%s.%s\", area, c.Name), false)\n\t}\n\treturn nil\n}\n\nfunc (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error {\n\t// lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918\n\tif utils.MatchImage(c.Image, \"plugins/docker\", \"plugins/gcr\", \"plugins/ecr\", \"woodpeckerci/plugin-docker-buildx\") && !c.Privileged {\n\t\tmsg := fmt.Sprintf(\"The formerly privileged plugin `%s` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`\", c.Image)\n\t\t// check first if user did not add them back\n\t\tif l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) {\n\t\t\treturn newLinterError(msg, config.File, fmt.Sprintf(\"%s.%s\", area, c.Name), false)\n\t\t} else if l.privilegedPlugins == nil {\n\t\t\t// if linter has no info of current privileged plugins, it's just a warning\n\t\t\treturn newLinterError(msg, config.File, fmt.Sprintf(\"%s.%s\", area, c.Name), true)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field string) error {\n\tif len(c.Settings) == 0 {\n\t\treturn nil\n\t}\n\tif len(c.Commands) != 0 {\n\t\treturn newLinterError(\"Cannot configure both `commands` and `settings`\", config.File, fmt.Sprintf(\"%s.%s\", field, c.Name), false)\n\t}\n\tif len(c.Entrypoint) != 0 {\n\t\treturn newLinterError(\"Cannot configure both `entrypoint` and `settings`\", config.File, fmt.Sprintf(\"%s.%s\", field, c.Name), false)\n\t}\n\tif len(c.Environment) != 0 {\n\t\treturn newLinterError(\"Should not configure both `environment` and `settings`\", config.File, fmt.Sprintf(\"%s.%s\", field, c.Name), true)\n\t}\n\treturn nil\n}\n\nfunc (l *Linter) lintContainerDeprecations(config *WorkflowConfig, c *types.Container, field string) error {\n\treturn nil\n}\n\nfunc (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area string) error {\n\tyamlPath := fmt.Sprintf(\"%s.%s\", area, c.Name)\n\terrors := []string{}\n\tif !l.trusted.Security {\n\t\tif c.Privileged {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `privileged` mode\")\n\t\t}\n\t}\n\tif !l.trusted.Network {\n\t\tif len(c.DNS) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use custom `dns`\")\n\t\t}\n\t\tif len(c.DNSSearch) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `dns_search`\")\n\t\t}\n\t\tif len(c.ExtraHosts) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `extra_hosts`\")\n\t\t}\n\t\tif len(c.NetworkMode) != 0 && c.NetworkMode != networkModeNone {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `network_mode`\")\n\t\t}\n\t}\n\tif !l.trusted.Volumes {\n\t\tif len(c.Devices) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `devices`\")\n\t\t}\n\t\tif len(c.Volumes.Volumes) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `volumes`\")\n\t\t}\n\t\tif len(c.Tmpfs) != 0 {\n\t\t\terrors = append(errors, \"Insufficient trust level to use `tmpfs`\")\n\t\t}\n\t}\n\n\tif len(errors) > 0 {\n\t\tvar err error\n\n\t\tfor _, e := range errors {\n\t\t\terr = multierr.Append(err, newLinterError(e, config.File, yamlPath, false))\n\t\t}\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (l *Linter) lintSchema(config *WorkflowConfig) error {\n\tvar linterErr error\n\tschemaErrors, err := schema.LintString(config.RawConfig)\n\tif err != nil {\n\t\tfor _, schemaError := range schemaErrors {\n\t\t\tlinterErr = multierr.Append(linterErr, newLinterError(\n\t\t\t\tschemaError.Description(),\n\t\t\t\tconfig.File,\n\t\t\t\tschemaError.Field(),\n\t\t\t\ttrue, // TODO: let pipelines fail if the schema is invalid\n\t\t\t))\n\t\t}\n\t}\n\treturn linterErr\n}\n\nfunc (l *Linter) lintDeprecations(config *WorkflowConfig) error {\n\tparsed := new(types.Workflow)\n\terr := xyaml.Unmarshal([]byte(config.RawConfig), parsed)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(parsed.RunsOn) > 0 { //nolint:staticcheck\n\t\terr = multierr.Append(err, &pipeline_errors.PipelineError{\n\t\t\tType:      pipeline_errors.PipelineErrorTypeDeprecation,\n\t\t\tIsWarning: true,\n\t\t\tMessage:   \"Usage of `runs_on` is deprecated, use `when.status`\",\n\t\t\tData: pipeline_errors.DeprecationErrorData{\n\t\t\t\tFile:  config.File,\n\t\t\t\tField: fmt.Sprintf(\"%s.runs_on\", config.File),\n\t\t\t\tDocs:  \"https://woodpecker-ci.org/docs/usage/workflow-syntax#status\",\n\t\t\t},\n\t\t})\n\t}\n\n\treturn err\n}\n\nfunc (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) {\n\tparsed := new(types.Workflow)\n\terr = xyaml.Unmarshal([]byte(config.RawConfig), parsed)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trootEventFilters := len(parsed.When.Constraints) > 0\n\tfor _, c := range parsed.When.Constraints {\n\t\tif len(c.Event) == 0 {\n\t\t\trootEventFilters = false\n\t\t\tbreak\n\t\t}\n\t}\n\tif !rootEventFilters {\n\t\t// root whens do not necessarily have an event filter, check steps\n\t\tfor _, step := range parsed.Steps.ContainerList {\n\t\t\tvar field string\n\t\t\tvar msg string\n\t\t\tif len(step.When.Constraints) == 0 {\n\t\t\t\tfield = fmt.Sprintf(\"steps.%s\", step.Name)\n\t\t\t\tmsg = \"Consider adding a `when` block with an `event` filter to this step or the entire workflow\"\n\t\t\t} else {\n\t\t\t\tstepEventIndex := -1\n\t\t\t\tfor i, c := range step.When.Constraints {\n\t\t\t\t\tif len(c.Event) == 0 {\n\t\t\t\t\t\tstepEventIndex = i\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif stepEventIndex > -1 {\n\t\t\t\t\tfield = fmt.Sprintf(\"steps.%s.when[%d]\", step.Name, stepEventIndex)\n\t\t\t\t\tmsg = \"Set an event filter for all steps or the entire workflow on all items of the `when` block\"\n\t\t\t\t}\n\t\t\t}\n\t\t\tif field != \"\" {\n\t\t\t\terr = multierr.Append(err, &pipeline_errors.PipelineError{\n\t\t\t\t\tType:    pipeline_errors.PipelineErrorTypeBadHabit,\n\t\t\t\t\tMessage: msg,\n\t\t\t\t\tData: pipeline_errors.BadHabitErrorData{\n\t\t\t\t\t\tFile:  config.File,\n\t\t\t\t\t\tField: field,\n\t\t\t\t\t\tDocs:  \"https://woodpecker-ci.org/docs/usage/linter#event-filter-for-all-steps\",\n\t\t\t\t\t},\n\t\t\t\t\tIsWarning: true,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/linter_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage linter_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter\"\n)\n\nfunc TestLint(t *testing.T) {\n\ttestdatas := []struct{ Title, Data string }{{\n\t\tTitle: \"map\", Data: `\nwhen:\n  event: push\n\nsteps:\n  build:\n    image: docker\n    volumes:\n      - /tmp:/tmp\n    commands:\n      - go build\n      - go test\n  publish:\n    image: woodpeckerci/plugin-kaniko\n    settings:\n      repo: foo/bar\n      foo: bar\nservices:\n  redis:\n    image: redis\n`,\n\t}, {\n\t\tTitle: \"list\", Data: `\nwhen:\n  event: push\n\nsteps:\n  - name: build\n    image: docker\n    volumes:\n      - /tmp:/tmp\n    commands:\n      - go build\n      - go test\n  - name: publish\n    image: woodpeckerci/plugin-kaniko\n    settings:\n      repo: foo/bar\n      foo: bar\nservices:\n  - name: redis\n    image: redis\n`,\n\t}, {\n\t\tTitle: \"merge maps\", Data: `\nwhen:\n  event: push\n\nvariables:\n  step_template: &base-step\n    image: golang:1.19\n    commands:\n      - go version\n\nsteps:\n  test base step:\n    <<: *base-step\n  test base step with latest image:\n    <<: *base-step\n    image: golang:latest\n`,\n\t}, {\n\t\tTitle: \"explicitly privileged container\",\n\t\tData:  \"{steps: { build: { image: plugins/docker, privileged: true, settings: { test: 'true' } } }, when: { branch: main, event: push } } }\",\n\t}}\n\n\tfor _, testd := range testdatas {\n\t\tt.Run(testd.Title, func(t *testing.T) {\n\t\t\tconf, err := yaml.ParseString(testd.Data)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, linter.New(linter.WithTrusted(linter.TrustedConfiguration{\n\t\t\t\tNetwork:  true,\n\t\t\t\tVolumes:  true,\n\t\t\t\tSecurity: true,\n\t\t\t})).Lint([]*linter.WorkflowConfig{{\n\t\t\t\tFile:      testd.Title,\n\t\t\t\tRawConfig: testd.Data,\n\t\t\t\tWorkflow:  conf,\n\t\t\t}}), \"expected lint returns no errors\")\n\t\t})\n\t}\n}\n\nfunc TestLintErrors(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: \"\",\n\t\t\twant: \"Invalid or missing `steps` section\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: '' }  }\",\n\t\t\twant: \"Invalid or missing image\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, privileged: true }  }\",\n\t\t\twant: \"Insufficient trust level to use `privileged` mode\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, dns: [ 8.8.8.8 ] }  }\",\n\t\t\twant: \"Insufficient trust level to use custom `dns`\",\n\t\t},\n\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, dns_search: [ example.com ] }  }\",\n\t\t\twant: \"Insufficient trust level to use `dns_search`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, devices: [ '/dev/tty0:/dev/tty0' ] }  }\",\n\t\t\twant: \"Insufficient trust level to use `devices`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, extra_hosts: [ 'somehost:162.242.195.82' ] }  }\",\n\t\t\twant: \"Insufficient trust level to use `extra_hosts`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, network_mode: host }  }\",\n\t\t\twant: \"Insufficient trust level to use `network_mode`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, volumes: [ '/opt/data:/var/lib/mysql' ] }  }\",\n\t\t\twant: \"Insufficient trust level to use `volumes`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, network_mode: 'container:name' }  }\",\n\t\t\twant: \"Insufficient trust level to use `network_mode`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, settings: { test: 'true' }, commands: [ 'echo ja', 'echo nein' ] } }\",\n\t\t\twant: \"Cannot configure both `commands` and `settings`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, settings: { test: 'true' }, entrypoint: [ '/bin/fish' ] } }\",\n\t\t\twant: \"Cannot configure both `entrypoint` and `settings`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, settings: { test: 'true' }, environment: { 'TEST': 'true' } } }\",\n\t\t\twant: \"Should not configure both `environment` and `settings`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"{pipeline: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push } }\",\n\t\t\twant: \"Additional property pipeline is not allowed\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"{steps: { build: { image: plugins/docker, settings: { test: 'true' } } }, when: { branch: main, event: push } } }\",\n\t\t\twant: \"The formerly privileged plugin `plugins/docker` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"{steps: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push }, clone: { git: { image: some-other/plugin-git:v1.1.0 } } }\",\n\t\t\twant: \"Specified clone image does not match allow list, netrc is not injected\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang }, publish: { image: golang, depends_on: [ binary ] } }\",\n\t\t\twant: \"One or more of the specified dependencies do not exist\",\n\t\t},\n\t}\n\n\tfor _, test := range testdata {\n\t\tconf, err := yaml.ParseString(test.from)\n\t\trequire.NoError(t, err)\n\n\t\tlerr := linter.New().Lint([]*linter.WorkflowConfig{{\n\t\t\tFile:      test.from,\n\t\t\tRawConfig: test.from,\n\t\t\tWorkflow:  conf,\n\t\t}})\n\t\tassert.Error(t, lerr, \"expected lint error for configuration\", test.from)\n\n\t\tlerrors := errors.GetPipelineErrors(lerr)\n\t\tfound := false\n\t\tfor _, lerr := range lerrors {\n\t\t\tif lerr.Message == test.want {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tassert.True(t, found, \"Expected error %q, got %q\", test.want, lerrors)\n\t}\n}\n\nfunc TestBadHabits(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang } }\",\n\t\t\twant: \"Consider adding a `when` block with an `event` filter to this step or the entire workflow\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"when: [{branch: xyz}, {event: push}]\\nsteps: { build: { image: golang } }\",\n\t\t\twant: \"Consider adding a `when` block with an `event` filter to this step or the entire workflow\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"steps: { build: { image: golang, when: [{branch: main}] } }\",\n\t\t\twant: \"Set an event filter for all steps or the entire workflow on all items of the `when` block\",\n\t\t},\n\t}\n\n\tfor _, test := range testdata {\n\t\tconf, err := yaml.ParseString(test.from)\n\t\tassert.NoError(t, err)\n\n\t\tlerr := linter.New().Lint([]*linter.WorkflowConfig{{\n\t\t\tFile:      test.from,\n\t\t\tRawConfig: test.from,\n\t\t\tWorkflow:  conf,\n\t\t}})\n\t\tassert.Error(t, lerr, \"expected lint error for configuration\", test.from)\n\n\t\tlerrors := errors.GetPipelineErrors(lerr)\n\t\tfound := false\n\t\tfor _, lerr := range lerrors {\n\t\t\tif lerr.Message == test.want {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tassert.True(t, found, \"Expected error %q, got %q\", test.want, lerrors)\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/option.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage linter\n\n// Option configures a linting option.\ntype Option func(*Linter)\n\n// WithTrusted adds the trusted option to the linter.\nfunc WithTrusted(trusted TrustedConfiguration) Option {\n\treturn func(linter *Linter) {\n\t\tlinter.trusted = trusted\n\t}\n}\n\n// PrivilegedPlugins adds the list of privileged plugins.\nfunc PrivilegedPlugins(plugins []string) Option {\n\treturn func(linter *Linter) {\n\t\tlinter.privilegedPlugins = &plugins\n\t}\n}\n\n// WithTrustedClonePlugins adds the list of trusted clone plugins.\nfunc WithTrustedClonePlugins(plugins []string) Option {\n\treturn func(linter *Linter) {\n\t\tlinter.trustedClonePlugins = &plugins\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-array-syntax.yaml",
    "content": "clone:\n  - name: git\n    image: woodpeckerci/plugin-git\n    settings:\n      partial: true\n  - name: testdata\n    image: woodpeckerci/plugin-git\n    settings:\n      remote: https://gitserver/owner/testdata.git\n      path: testdata\n\nsteps:\n  - name: build\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  - name: database\n    image: mysql\n  - name: cache\n    image: redis\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-backend-options.yaml",
    "content": "steps:\n  - name: Build Container\n    image: woodpeckerci/plugin-kaniko:1.2.1\n    backend_options:\n      kubernetes:\n        secrets:\n          - name: aws-secret\n            key: credentials\n            target:\n              file: /root/.aws/credentials\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken-plugin.yaml",
    "content": "steps:\n  publish:\n    image: plugins/docker\n    settings:\n      repo: foo/bar\n      tags: latest\n    environment:\n      CGO: 0\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken-plugin2.yaml",
    "content": "steps:\n  publish:\n    image: plugins/docker\n    settings:\n      repo: foo/bar\n      tags: latest\n    commands:\n      - env\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken.yaml",
    "content": "branches: main\n\nmatri:\n  GO_VERSION:\n    - 1.14\n    - 1.13\n\nsteps:\n  test:\n    image: golang:${GO_VERSION}\n    commands:\n      - echo \"test ${DATABAS}\"\n\n  build:\n    commands: go build\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-clone-skip.yaml",
    "content": "steps:\n  test:\n    image: alpine\n    commands:\n      - echo \"test\"\n\nskip_clone: true\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-clone.yaml",
    "content": "clone:\n  git:\n    image: plugins/git:next\n    depth: 50\n    path: bitbucket.org/foo/bar\n    recursive: true\n    submodule_override:\n      my-module: https://github.com/octocat/my-module.git\n\nsteps:\n  test:\n    image: alpine\n    commands:\n      - echo \"test\"\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-custom-backend.yaml",
    "content": "steps:\n  build:\n    image: golang\n    commands:\n      - go build\n      - go test\n    backend_options:\n      custom_backend:\n        option1: xyz\n        option2: [1, 2, 3]\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-dag.yaml",
    "content": "steps:\n  first:\n    image: test\n\n  second:\n    depends_on: first\n    image: test\n\n  next:\n    image: test\n    depends_on:\n      - first\n      - second\n\n  some:\n    image: test\n    depends_on:\n      - first\n\n  last:\n    image: test\n    depends_on: next\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-kubernetes-backend-tolerations.yaml",
    "content": "steps:\n  build:\n    image: golang\n    commands:\n      - go build\n      - go test\n    backend_options:\n      kubernetes:\n        tolerations:\n          - key: 'partial-object'\n            operator: 'Equal'\n            value: 'pipeline'\n            effect: 'NoSchedule'\n          - key: 'complete-object'\n            operator: 'Equal'\n            value: 'pipeline'\n            effect: 'NoSchedule'\n            tolerationSeconds: 10\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-labels.yaml",
    "content": "labels:\n  location: europe\n  weather: sun\n  hostname: ''\n\nsteps:\n  build:\n    image: golang:latest\n    commands:\n      - go test\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-matrix.yaml",
    "content": "steps:\n  test:\n    image: golang:${GO_VERSION}\n    commands:\n      - echo \"test ${DATABASE}\"\n\nmatrix:\n  GO_VERSION:\n    - 1.4\n    - 1.3\n  DATABASE:\n    - mysql:5.5\n    - mysql:6.5\n    - mariadb:10.1\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-merge-map-and-sequence.yaml",
    "content": "variables:\n  step_template: &base-step\n    image: golang:1.19\n    commands: &base-cmds\n      - go version\n      - whoami\n\nsteps:\n  test-base-step:\n    <<: *base-step\n  test base step with latest image:\n    <<: *base-step\n    image: golang:latest\n  test list overwrite:\n    <<: *base-step\n    commands:\n      - <<: *base-cmds\n      - hostname\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-multi.yaml",
    "content": "steps:\n  deploy:\n    image: golang\n    commands:\n      - go test\n\ndepends_on:\n  - lint\n  - build\n  - test\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-pipeline-when.yaml",
    "content": "when:\n  - branch: [main, deploy]\n    event: push\n    path:\n      - 'folder/**'\n      - '**/*.c'\n  - tag: 'v**'\n    event: tag\n  - event: cron\n    cron:\n      include:\n        - hello\n  - event:\n      exclude: pull_request_closed\n    evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n  - event:\n      exclude: pull_request_metadata\n    evaluate: 'CI_COMMIT_AUTHOR == \"woodpecker-ci\"'\n\nsteps:\n  echo:\n    image: alpine\n    commands:\n      - echo \"test\"\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-plugin.yaml",
    "content": "steps:\n  build:\n    image: golang\n    commands:\n      - go build\n      - go test\n\n  publish:\n    image: plugins/docker\n    settings:\n      repo: foo/bar\n      tags: latest\n\n  notify:\n    image: plugins/slack\n    settings:\n      channel: dev\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-run-on.yaml",
    "content": "steps:\n  build:\n    image: golang\n    commands:\n      - go test\n\nruns_on: [success, failure]\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-service.yaml",
    "content": "steps:\n  build:\n    image: golang\n    commands:\n      - go build\n      - go test\n\nservices:\n  database:\n    image: mysql\n    ports:\n      - 3306\n    entrypoint: ['entrypoint.sh']\n    environment:\n      MYSQL_DATABASE: test\n      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'\n\n  cache:\n    image: redis\n    failure: ignore\n    directory: /tmp/\n    ports:\n      - '6379'\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-step.yaml",
    "content": "steps:\n  image:\n    image: golang\n    commands:\n      - go test\n\n  image-pull:\n    image: golang\n    pull: true\n    commands:\n      - go test\n\n  single-command:\n    image: golang\n    commands: go test\n\n  entrypoint:\n    image: alpine\n    entrypoint: ['some_entry', '--some-flag']\n\n  single-entrypoint:\n    image: alpine\n    entrypoint: some_entry\n\n  commands:\n    privileged: true\n    image: golang\n    commands:\n      - go get\n      - go test\n\n  environment:\n    image: golang\n    environment:\n      CGO: 0\n      GOOS: linux\n      GOARCH: amd64\n    commands:\n      - go test\n\n  detached:\n    image: redis\n    detach: true\n\n  volume:\n    image: docker\n    commands:\n      - docker build --rm -t octocat/hello-world .\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-when.yaml",
    "content": "when:\n  status: [success, failure]\n\nsteps:\n  when-branch:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      branch: main\n\n  when-branch-array:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      branch: [main, deploy]\n\n  when-event:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      event: push\n      branch:\n        include: main\n        exclude: [develop, feature/*]\n\n  when-event-array:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      event:\n        - manual\n        - push\n        - pull_request\n        - pull_request_closed\n        - pull_request_metadata\n        - tag\n        - deployment\n        - release\n\n  when-ref:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      - ref: 'refs/tags/v**'\n      - ref:\n          include: 'refs/tags/v**'\n          exclude: 'refs/tags/v1.**'\n\n  when-status:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      - status: [success, failure]\n      - status: failure\n\n  when-platform:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      platform: linux/amd64\n\n  when-platform-array:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      platform: [linux/*, windows/amd64]\n\n  when-environment:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      event: deployment\n\n  when-matrix:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      matrix:\n        GO_VERSION: 1.5\n        REDIS_VERSION: 2.8\n\n  when-instance:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      instance: stage.woodpecker.company.com\n\n  when-path:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      path: 'folder/**'\n\n  when-path-array:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      path:\n        - 'folder/**'\n        - '**/*.c'\n\n  when-path-include-exclude:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      path:\n        include: ['.woodpecker/*.yml', '*.ini']\n        exclude: ['*.md', 'docs/**']\n        ignore_message: '[ALL]'\n        on_empty: true\n\n  when-repo:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      repo: test/test\n\n  when-multi:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      - event: pull_request\n        repo: test/test\n      - event: push\n        branch: main\n\n  when-cron:\n    image: alpine\n    commands:\n      - echo \"test\"\n    when:\n      cron: 'update locales'\n      event: cron\n\n  when-cron-list:\n    image: alpine\n    commands: echo \"test\"\n    when:\n      - event: cron\n        cron:\n          include:\n            - test\n            - hello\n          exclude: hi\n\n  when-evaluate:\n    image: alpine\n    commands: echo \"test\"\n    when:\n      - event: push\n        evaluate: 'CI_PIPELINE_EVENT == \"push\" && CI_REPO == \"owner/repo\"'\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/.woodpecker/test-workspace.yaml",
    "content": "workspace:\n  base: /go\n  path: src/github.com/octocat/hello-world\n\nsteps:\n  build:\n    image: golang:latest\n    commands:\n      - go test\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/schema.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage schema\n\nimport (\n\t\"bytes\"\n\t_ \"embed\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"codeberg.org/6543/go-yaml2json\"\n\t\"codeberg.org/6543/xyaml\"\n\t\"github.com/xeipuuv/gojsonschema\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n//go:embed schema.json\nvar schemaDefinition []byte\n\n// Lint lints an io.Reader against the Woodpecker `schema.json`.\nfunc Lint(r io.Reader) ([]gojsonschema.ResultError, error) {\n\tschemaLoader := gojsonschema.NewBytesLoader(schemaDefinition)\n\n\t// read yaml config\n\trBytes, err := io.ReadAll(r)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load yml file %w\", err)\n\t}\n\n\t// resolve sequence merges\n\tyamlDoc := new(yaml.Node)\n\tif err := xyaml.Unmarshal(rBytes, yamlDoc); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse yml file %w\", err)\n\t}\n\n\t// convert to json\n\tjsonDoc, err := yaml2json.ConvertNode(yamlDoc)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to convert yaml %w\", err)\n\t}\n\n\tdocumentLoader := gojsonschema.NewBytesLoader(jsonDoc)\n\tresult, err := gojsonschema.Validate(schemaLoader, documentLoader)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"validation failed %w\", err)\n\t}\n\n\tif !result.Valid() {\n\t\treturn result.Errors(), fmt.Errorf(\"config not valid\")\n\t}\n\n\treturn nil, nil\n}\n\nfunc LintString(s string) ([]gojsonschema.ResultError, error) {\n\treturn Lint(bytes.NewBufferString(s))\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/schema.json",
    "content": "{\n  \"title\": \"Woodpecker CI configuration file\",\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"$id\": \"https://raw.githubusercontent.com/woodpecker-ci/woodpecker/main/pipeline/frontend/yaml/linter/schema/schema.json\",\n  \"description\": \"Schema of a Woodpecker pipeline file. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax\",\n  \"type\": \"object\",\n  \"required\": [\"steps\"],\n  \"additionalProperties\": false,\n  \"properties\": {\n    \"$schema\": {\n      \"type\": \"string\",\n      \"format\": \"uri\"\n    },\n    \"variables\": {\n      \"description\": \"Use yaml aliases to define variables. Read more: https://woodpecker-ci.org/docs/usage/advanced-usage\"\n    },\n    \"clone\": {\n      \"$ref\": \"#/definitions/clone\"\n    },\n    \"skip_clone\": {\n      \"type\": \"boolean\"\n    },\n    \"when\": {\n      \"$ref\": \"#/definitions/workflow_when\"\n    },\n    \"steps\": {\n      \"$ref\": \"#/definitions/step_list\"\n    },\n    \"services\": {\n      \"$ref\": \"#/definitions/services\"\n    },\n    \"workspace\": {\n      \"$ref\": \"#/definitions/workspace\"\n    },\n    \"matrix\": {\n      \"$ref\": \"#/definitions/matrix\"\n    },\n    \"labels\": {\n      \"$ref\": \"#/definitions/labels\"\n    },\n    \"depends_on\": {\n      \"type\": \"array\",\n      \"minLength\": 1,\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"runs_on\": {\n      \"type\": \"array\",\n      \"description\": \"Deprecated: use `when.status` instead. Read more: https://woodpecker-ci.org/docs/usage/workflows#flow-control\",\n      \"minLength\": 1,\n      \"items\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"definitions\": {\n    \"string_or_string_slice\": {\n      \"oneOf\": [\n        {\n          \"type\": \"array\",\n          \"minLength\": 1,\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        {\n          \"type\": \"string\"\n        }\n      ]\n    },\n    \"clone\": {\n      \"description\": \"Configures the clone step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#clone\",\n      \"oneOf\": [\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": false,\n          \"properties\": {\n            \"git\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"image\": {\n                  \"$ref\": \"#/definitions/step_image\"\n                },\n                \"settings\": {\n                  \"$ref\": \"#/definitions/clone_settings\"\n                }\n              }\n            }\n          }\n        },\n        {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/step\"\n          },\n          \"minLength\": 1\n        }\n      ]\n    },\n    \"clone_settings\": {\n      \"description\": \"Change the settings of your clone plugin. Read more: https://woodpecker-ci.org/plugins/git-clone\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"depth\": {\n          \"type\": \"number\",\n          \"description\": \"If specified, uses git's --depth option to create a shallow clone with a limited number of commits, overwritten by partial\"\n        },\n        \"recursive\": {\n          \"type\": \"boolean\",\n          \"default\": false,\n          \"description\": \"Clones submodules recursively\"\n        },\n        \"partial\": {\n          \"type\": \"boolean\",\n          \"description\": \"Only fetch the one commit and it's blob objects to resolve all files, overwrite depth with 1\"\n        },\n        \"lfs\": {\n          \"type\": \"boolean\",\n          \"default\": true,\n          \"description\": \"Set this to false to disable retrieval of LFS files\"\n        },\n        \"tags\": {\n          \"type\": \"boolean\",\n          \"description\": \"Fetches tags when set to true, default is false if event is not tag else true\"\n        }\n      },\n      \"additionalProperties\": {\n        \"type\": [\"boolean\", \"string\", \"number\", \"array\", \"object\"]\n      }\n    },\n    \"step_list\": {\n      \"description\": \"The steps section defines a list of steps which will be executed serially, in the order in which they are defined. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps\",\n      \"oneOf\": [\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/step\"\n          },\n          \"minProperties\": 1\n        },\n        {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/step\"\n          },\n          \"minLength\": 1\n        }\n      ]\n    },\n    \"workflow_when\": {\n      \"description\": \"Whole workflow can be skipped based on conditions. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#when---global-workflow-conditions\",\n      \"oneOf\": [\n        {\n          \"type\": \"array\",\n          \"minLength\": 1,\n          \"items\": {\n            \"$ref\": \"#/definitions/workflow_when_condition\"\n          }\n        },\n        {\n          \"$ref\": \"#/definitions/workflow_when_condition\"\n        }\n      ]\n    },\n    \"workflow_when_condition\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Execute a step only on a specific repository. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#repo\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"branch\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#branch\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"event\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#event\",\n          \"default\": [],\n          \"$ref\": \"#/definitions/event_constraint_list\"\n        },\n        \"ref\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#ref\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"cron\": {\n          \"description\": \"filter cron by title. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#cron\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"status\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflows#flow-control\",\n          \"oneOf\": [\n            {\n              \"type\": \"array\",\n              \"minLength\": 1,\n              \"items\": {\n                \"type\": \"string\",\n                \"enum\": [\"success\", \"failure\"]\n              }\n            },\n            {\n              \"type\": \"string\",\n              \"enum\": [\"success\", \"failure\"]\n            }\n          ]\n        },\n        \"platform\": {\n          \"description\": \"Execute a step only on a specific platform. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#platform\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"instance\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#instance\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"path\": {\n          \"description\": \"Execute a step only on commit with certain files added/removed/modified. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#path\",\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"string\"\n              }\n            },\n            {\n              \"type\": \"object\",\n              \"properties\": {\n                \"include\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                },\n                \"exclude\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                },\n                \"ignore_message\": {\n                  \"type\": \"string\"\n                },\n                \"on_empty\": {\n                  \"type\": \"boolean\"\n                }\n              },\n              \"additionalProperties\": false\n            }\n          ]\n        },\n        \"evaluate\": {\n          \"description\": \"Execute a step only if the expression evaluates to true. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#evaluate\",\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"step\": {\n      \"description\": \"A step of your workflow executes either arbitrary commands or uses a plugin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps\",\n      \"anyOf\": [\n        {\n          \"$ref\": \"#/definitions/commands_step\"\n        },\n        {\n          \"$ref\": \"#/definitions/plugin_step\"\n        }\n      ]\n    },\n    \"commands_step\": {\n      \"description\": \"Every step of your pipeline executes arbitrary commands inside a specified docker container. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps\",\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"required\": [\"image\"],\n      \"allOf\": [\n        {\n          \"if\": {\n            \"properties\": {\n              \"detach\": {\n                \"const\": true\n              }\n            }\n          },\n          \"then\": {},\n          \"else\": {\n            \"anyOf\": [\n              {\n                \"required\": [\"commands\"]\n              },\n              {\n                \"required\": [\"entrypoint\"]\n              }\n            ]\n          }\n        }\n      ],\n      \"properties\": {\n        \"name\": {\n          \"description\": \"The name of the step. Can be used if using the array style steps list.\",\n          \"type\": \"string\"\n        },\n        \"image\": {\n          \"$ref\": \"#/definitions/step_image\"\n        },\n        \"privileged\": {\n          \"$ref\": \"#/definitions/step_privileged\"\n        },\n        \"pull\": {\n          \"$ref\": \"#/definitions/step_pull\"\n        },\n        \"commands\": {\n          \"$ref\": \"#/definitions/step_commands\"\n        },\n        \"environment\": {\n          \"$ref\": \"#/definitions/step_environment\"\n        },\n        \"directory\": {\n          \"$ref\": \"#/definitions/step_directory\"\n        },\n        \"when\": {\n          \"$ref\": \"#/definitions/step_when\"\n        },\n        \"volumes\": {\n          \"$ref\": \"#/definitions/step_volumes\"\n        },\n        \"depends_on\": {\n          \"description\": \"Execute a step after another step has finished.\",\n          \"$ref\": \"#/definitions/string_or_string_slice\"\n        },\n        \"detach\": {\n          \"description\": \"Detach a step to run in background until pipeline finishes. Read more: https://woodpecker-ci.org/docs/usage/services#detachment\",\n          \"type\": \"boolean\"\n        },\n        \"failure\": {\n          \"description\": \"How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#failure\",\n          \"type\": \"string\",\n          \"enum\": [\"fail\", \"ignore\", \"cancel\"],\n          \"default\": \"fail\"\n        },\n        \"backend_options\": {\n          \"$ref\": \"#/definitions/step_backend_options\"\n        },\n        \"entrypoint\": {\n          \"$ref\": \"#/definitions/step_entrypoint\"\n        },\n        \"dns\": {\n          \"description\": \"Change DNS server for step. Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#dns\",\n          \"$ref\": \"#/definitions/string_or_string_slice\"\n        },\n        \"dns_search\": {\n          \"description\": \"Change DNS lookup domain for step. Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#dns\",\n          \"$ref\": \"#/definitions/string_or_string_slice\"\n        }\n      }\n    },\n    \"plugin_step\": {\n      \"description\": \"Plugins let you execute predefined functions in a more secure context. Read more: https://woodpecker-ci.org/docs/usage/plugins/overview\",\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"required\": [\"image\"],\n      \"properties\": {\n        \"name\": {\n          \"description\": \"The name of the step. Can be used if using the array style steps list.\",\n          \"type\": \"string\"\n        },\n        \"image\": {\n          \"$ref\": \"#/definitions/step_image\"\n        },\n        \"privileged\": {\n          \"$ref\": \"#/definitions/step_privileged\"\n        },\n        \"pull\": {\n          \"$ref\": \"#/definitions/step_pull\"\n        },\n        \"directory\": {\n          \"$ref\": \"#/definitions/step_directory\"\n        },\n        \"settings\": {\n          \"$ref\": \"#/definitions/step_settings\"\n        },\n        \"when\": {\n          \"$ref\": \"#/definitions/step_when\"\n        },\n        \"volumes\": {\n          \"$ref\": \"#/definitions/step_volumes\"\n        },\n        \"depends_on\": {\n          \"description\": \"Execute a step after another step has finished.\",\n          \"$ref\": \"#/definitions/string_or_string_slice\"\n        },\n        \"detach\": {\n          \"description\": \"Detach a step to run in background until pipeline finishes. Read more: https://woodpecker-ci.org/docs/usage/services#detachment\",\n          \"type\": \"boolean\"\n        },\n        \"failure\": {\n          \"description\": \"How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#failure\",\n          \"type\": \"string\",\n          \"enum\": [\"fail\", \"ignore\"],\n          \"default\": \"fail\"\n        },\n        \"backend_options\": {\n          \"$ref\": \"#/definitions/step_backend_options\"\n        }\n      }\n    },\n    \"step_when\": {\n      \"description\": \"Steps can be skipped based on conditions. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#when---conditional-execution\",\n      \"oneOf\": [\n        {\n          \"type\": \"array\",\n          \"minLength\": 1,\n          \"items\": {\n            \"$ref\": \"#/definitions/step_when_condition\"\n          }\n        },\n        {\n          \"$ref\": \"#/definitions/step_when_condition\"\n        }\n      ]\n    },\n    \"step_when_condition\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"repo\": {\n          \"description\": \"Execute a step only on a specific repository. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#repo\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"branch\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#branch\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"event\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#event\",\n          \"$ref\": \"#/definitions/event_constraint_list\"\n        },\n        \"ref\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#ref\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"cron\": {\n          \"description\": \"filter cron by title. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#cron\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"status\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#status\",\n          \"oneOf\": [\n            {\n              \"type\": \"array\",\n              \"minLength\": 1,\n              \"items\": {\n                \"type\": \"string\",\n                \"enum\": [\"success\", \"failure\"]\n              }\n            },\n            {\n              \"type\": \"string\",\n              \"enum\": [\"success\", \"failure\"]\n            }\n          ]\n        },\n        \"platform\": {\n          \"description\": \"Execute a step only on a specific platform. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#platform\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"matrix\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/matrix-workflows\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": [\"boolean\", \"string\", \"number\"]\n          }\n        },\n        \"instance\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#instance\",\n          \"$ref\": \"#/definitions/constraint_list\"\n        },\n        \"path\": {\n          \"description\": \"Execute a step only on commit with certain files added/removed/modified. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#path\",\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"string\"\n              }\n            },\n            {\n              \"type\": \"object\",\n              \"properties\": {\n                \"include\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                },\n                \"exclude\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                },\n                \"ignore_message\": {\n                  \"type\": \"string\"\n                },\n                \"on_empty\": {\n                  \"type\": \"boolean\"\n                }\n              },\n              \"additionalProperties\": false\n            }\n          ]\n        },\n        \"evaluate\": {\n          \"description\": \"Execute a step only if the expression evaluates to true. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#evaluate\",\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"event_enum\": {\n      \"enum\": [\n        \"push\",\n        \"pull_request\",\n        \"pull_request_closed\",\n        \"pull_request_metadata\",\n        \"tag\",\n        \"deployment\",\n        \"cron\",\n        \"manual\",\n        \"release\"\n      ]\n    },\n    \"event_constraint_list\": {\n      \"oneOf\": [\n        {\n          \"$ref\": \"#/definitions/event_enum\"\n        },\n        {\n          \"type\": \"array\",\n          \"minLength\": 1,\n          \"items\": {\n            \"$ref\": \"#/definitions/event_enum\"\n          }\n        }\n      ]\n    },\n    \"constraint_list\": {\n      \"oneOf\": [\n        {\n          \"type\": \"string\"\n        },\n        {\n          \"type\": \"array\",\n          \"minLength\": 1,\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": false,\n          \"properties\": {\n            \"include\": {\n              \"oneOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"minLength\": 1,\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            },\n            \"exclude\": {\n              \"oneOf\": [\n                {\n                  \"type\": \"string\"\n                },\n                {\n                  \"type\": \"array\",\n                  \"minLength\": 1,\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              ]\n            }\n          }\n        }\n      ]\n    },\n    \"step_image\": {\n      \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#image\",\n      \"type\": \"string\"\n    },\n    \"step_privileged\": {\n      \"description\": \"Run the step in privileged mode. Read more: https://woodpecker-ci.org/docs/next/usage/workflow-syntax#privileged-mode\",\n      \"type\": \"boolean\",\n      \"default\": false\n    },\n    \"step_pull\": {\n      \"description\": \"Always pull the latest image on pipeline execution Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#image\",\n      \"type\": \"boolean\"\n    },\n    \"step_commands\": {\n      \"description\": \"Commands of every pipeline step are executed serially as if you would enter them into your local shell. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#commands\",\n      \"oneOf\": [\n        {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"minLength\": 1\n        },\n        {\n          \"type\": \"string\"\n        }\n      ]\n    },\n    \"step_environment\": {\n      \"description\": \"Pass environment variables to a pipeline step. Read more: https://woodpecker-ci.org/docs/usage/environment\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": [\"boolean\", \"string\", \"number\", \"array\", \"object\"]\n      }\n    },\n    \"step_entrypoint\": {\n      \"description\": \"Defines container entrypoint.\",\n      \"$ref\": \"#/definitions/string_or_string_slice\"\n    },\n    \"step_settings\": {\n      \"description\": \"Change the settings of your plugin. Read more: https://woodpecker-ci.org/docs/usage/plugins/overview\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": [\"boolean\", \"string\", \"number\", \"array\", \"object\"]\n      }\n    },\n    \"step_volumes\": {\n      \"description\": \"Mount files or folders from the host machine into your step container. Read more: https://woodpecker-ci.org/docs/usage/volumes\",\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"minLength\": 1\n    },\n    \"step_directory\": {\n      \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#directory\",\n      \"type\": \"string\"\n    },\n    \"step_backend_options\": {\n      \"description\": \"Advanced options for the different agent backends\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"kubernetes\": {\n          \"$ref\": \"#/definitions/step_backend_kubernetes\"\n        }\n      }\n    },\n    \"step_backend_kubernetes\": {\n      \"description\": \"Advanced options for the kubernetes agent backends\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"labels\": {\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": [\"boolean\", \"string\", \"number\"]\n          }\n        },\n        \"annotations\": {\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"type\": [\"boolean\", \"string\", \"number\"]\n          }\n        },\n        \"tolerations\": {\n          \"description\": \"The tolerations section defines a list of references to the native Kubernetes tolerations. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#tolerations\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/step_backend_kubernetes_toleration_object\"\n          },\n          \"minLength\": 1\n        },\n        \"securityContext\": {\n          \"$ref\": \"#/definitions/step_backend_kubernetes_security_context\"\n        },\n        \"runtimeClassName\": {\n          \"description\": \"Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#runtimeclassname\",\n          \"type\": \"string\"\n        },\n        \"secrets\": {\n          \"description\": \"The secrets section defines a list of references to the native Kubernetes secrets\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/step_kubernetes_secret\"\n          },\n          \"minLength\": 1\n        }\n      }\n    },\n    \"step_backend_kubernetes_resources\": {\n      \"description\": \"Resources for the kubernetes backend. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#step-specific-configuration\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"requests\": {\n          \"$ref\": \"#/definitions/step_kubernetes_resources_object\"\n        },\n        \"limits\": {\n          \"$ref\": \"#/definitions/step_kubernetes_resources_object\"\n        }\n      }\n    },\n    \"step_backend_kubernetes_security_context\": {\n      \"description\": \"Pods / containers security context. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#security-context\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"privileged\": {\n          \"type\": \"boolean\"\n        },\n        \"runAsNonRoot\": {\n          \"type\": \"boolean\"\n        },\n        \"runAsUser\": {\n          \"type\": \"number\"\n        },\n        \"runAsGroup\": {\n          \"type\": \"number\"\n        },\n        \"fsGroup\": {\n          \"type\": \"number\"\n        },\n        \"seccompProfile\": {\n          \"$ref\": \"#/definitions/step_backend_kubernetes_secprofile\"\n        },\n        \"apparmorProfile\": {\n          \"$ref\": \"#/definitions/step_backend_kubernetes_secprofile\"\n        }\n      }\n    },\n    \"step_backend_kubernetes_secprofile\": {\n      \"description\": \"Pods / containers security profile. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#step-specific-configuration\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"type\": {\n          \"type\": \"string\"\n        },\n        \"localhostProfile\": {\n          \"type\": \"string\"\n        }\n      }\n    },\n    \"step_kubernetes_resources_object\": {\n      \"description\": \"A list of kubernetes resource mappings\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    },\n    \"step_backend_kubernetes_service_account\": {\n      \"description\": \"serviceAccountName to be use by job. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#service-account\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"requests\": {\n          \"$ref\": \"#/definitions/step_kubernetes_service_account_object\"\n        },\n        \"limits\": {\n          \"$ref\": \"#/definitions/step_kubernetes_service_account_object\"\n        }\n      }\n    },\n    \"step_kubernetes_service_account_object\": {\n      \"description\": \"A list of kubernetes resource mappings\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      }\n    },\n    \"step_kubernetes_secret\": {\n      \"description\": \"A reference to a native Kubernetes secret\",\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"name\": {\n          \"description\": \"The name of the secret. Can be used if using the array style secrets list.\",\n          \"type\": \"string\"\n        },\n        \"key\": {\n          \"description\": \"The key of the secret to select from.\",\n          \"type\": \"string\"\n        },\n        \"target\": {\n          \"$ref\": \"#/definitions/step_kubernetes_secret_target\"\n        }\n      }\n    },\n    \"step_kubernetes_secret_target\": {\n      \"description\": \"A target which a native Kubernetes secret maps to.\",\n      \"oneOf\": [\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": false,\n          \"properties\": {\n            \"env\": {\n              \"description\": \"The name of the environment variable which secret maps to.\",\n              \"type\": \"string\"\n            }\n          }\n        },\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": false,\n          \"properties\": {\n            \"file\": {\n              \"description\": \"The filename (path) which secret maps to.\",\n              \"type\": \"string\"\n            }\n          }\n        }\n      ]\n    },\n    \"step_backend_kubernetes_toleration_object\": {\n      \"description\": \"Toleration entry for the kubernetes backend.\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"key\": {\n          \"type\": \"string\"\n        },\n        \"operator\": {\n          \"type\": \"string\"\n        },\n        \"value\": {\n          \"type\": \"string\"\n        },\n        \"effect\": {\n          \"type\": \"string\"\n        },\n        \"tolerationSeconds\": {\n          \"type\": \"integer\"\n        }\n      }\n    },\n    \"services\": {\n      \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/services\",\n      \"oneOf\": [\n        {\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/service\"\n          },\n          \"minProperties\": 1\n        },\n        {\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/service\"\n          },\n          \"minLength\": 1\n        }\n      ]\n    },\n    \"service\": {\n      \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/services\",\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"minProperties\": 1,\n      \"required\": [\"image\"],\n      \"properties\": {\n        \"name\": {\n          \"description\": \"The name of the service. Can be used if using the array style services list\",\n          \"type\": \"string\"\n        },\n        \"image\": {\n          \"$ref\": \"#/definitions/step_image\"\n        },\n        \"privileged\": {\n          \"$ref\": \"#/definitions/step_privileged\"\n        },\n        \"pull\": {\n          \"$ref\": \"#/definitions/step_pull\"\n        },\n        \"commands\": {\n          \"$ref\": \"#/definitions/step_commands\"\n        },\n        \"environment\": {\n          \"$ref\": \"#/definitions/step_environment\"\n        },\n        \"entrypoint\": {\n          \"$ref\": \"#/definitions/step_entrypoint\"\n        },\n        \"directory\": {\n          \"$ref\": \"#/definitions/step_directory\"\n        },\n        \"settings\": {\n          \"$ref\": \"#/definitions/step_settings\"\n        },\n        \"when\": {\n          \"$ref\": \"#/definitions/step_when\"\n        },\n        \"volumes\": {\n          \"$ref\": \"#/definitions/step_volumes\"\n        },\n        \"failure\": {\n          \"description\": \"How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/services#stopping\",\n          \"type\": \"string\",\n          \"enum\": [\"fail\", \"ignore\"],\n          \"default\": \"fail\"\n        },\n        \"backend_options\": {\n          \"$ref\": \"#/definitions/step_backend_options\"\n        },\n        \"ports\": {\n          \"description\": \"expose ports to which other steps can connect to\",\n          \"type\": \"array\",\n          \"items\": {\n            \"oneOf\": [\n              {\n                \"type\": \"number\"\n              },\n              {\n                \"type\": \"string\"\n              }\n            ]\n          },\n          \"minLength\": 1\n        }\n      }\n    },\n    \"workspace\": {\n      \"description\": \"Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#workspace\",\n      \"type\": \"object\",\n      \"additionalProperties\": true\n    },\n    \"matrix\": {\n      \"description\": \"Execute pipeline for each matrix combination. Read more: https://woodpecker-ci.org/docs/usage/matrix-workflows\",\n      \"type\": \"object\",\n      \"properties\": {\n        \"include\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\"\n          },\n          \"minLength\": 1\n        }\n      },\n      \"additionalProperties\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": [\"boolean\", \"string\", \"number\"]\n        },\n        \"minLength\": 1\n      }\n    },\n    \"labels\": {\n      \"description\": \"Configures the labels used for the agent selection. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#labels\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": [\"boolean\", \"string\", \"number\"]\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/linter/schema/schema_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage schema_test\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema\"\n)\n\nfunc TestSchema(t *testing.T) {\n\tt.Parallel()\n\n\ttestTable := []struct {\n\t\tname     string\n\t\ttestFile string\n\t\tfail     bool\n\t}{\n\t\t{\n\t\t\tname:     \"Clone\",\n\t\t\ttestFile: \".woodpecker/test-clone.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Clone skip\",\n\t\t\ttestFile: \".woodpecker/test-clone-skip.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Matrix\",\n\t\t\ttestFile: \".woodpecker/test-matrix.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Multi Pipeline\",\n\t\t\ttestFile: \".woodpecker/test-multi.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Plugin\",\n\t\t\ttestFile: \".woodpecker/test-plugin.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Run on\",\n\t\t\ttestFile: \".woodpecker/test-run-on.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Service\",\n\t\t\ttestFile: \".woodpecker/test-service.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Step\",\n\t\t\ttestFile: \".woodpecker/test-step.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"When\",\n\t\t\ttestFile: \".woodpecker/test-when.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Workspace\",\n\t\t\ttestFile: \".woodpecker/test-workspace.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Labels\",\n\t\t\ttestFile: \".woodpecker/test-labels.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Map and Sequence Merge\", // https://woodpecker-ci.org/docs/next/usage/advanced-yaml-syntax\n\t\t\ttestFile: \".woodpecker/test-merge-map-and-sequence.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Backend options\",\n\t\t\ttestFile: \".woodpecker/test-backend-options.yaml\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Broken Config\",\n\t\t\ttestFile: \".woodpecker/test-broken.yaml\",\n\t\t\tfail:     true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Array syntax\",\n\t\t\ttestFile: \".woodpecker/test-array-syntax.yaml\",\n\t\t\tfail:     false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Step DAG syntax\",\n\t\t\ttestFile: \".woodpecker/test-dag.yaml\",\n\t\t\tfail:     false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Custom backend\",\n\t\t\ttestFile: \".woodpecker/test-custom-backend.yaml\",\n\t\t\tfail:     false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Broken Plugin by environment\",\n\t\t\ttestFile: \".woodpecker/test-broken-plugin.yaml\",\n\t\t\tfail:     true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Broken Plugin by commands\",\n\t\t\ttestFile: \".woodpecker/test-broken-plugin2.yaml\",\n\t\t\tfail:     true,\n\t\t},\n\t\t{\n\t\t\tname:     \"Kubernetes backend tolerations\",\n\t\t\ttestFile: \".woodpecker/test-kubernetes-backend-tolerations.yaml\",\n\t\t\tfail:     false,\n\t\t},\n\t}\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfi, err := os.Open(tt.testFile)\n\t\t\tassert.NoError(t, err, \"could not open test file\")\n\t\t\tdefer fi.Close()\n\t\t\tconfigErrors, err := schema.Lint(fi)\n\t\t\tif tt.fail {\n\t\t\t\tif len(configErrors) == 0 {\n\t\t\t\t\tassert.Error(t, err, \"Expected config errors but got none\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err, fmt.Sprintf(\"Validation failed: %v\", configErrors))\n\t\t\t\tt.Run(\"parse\", func(t *testing.T) {\n\t\t\t\t\tconfig, err := io.ReadAll(fi)\n\t\t\t\t\trequire.NoError(t, err)\n\t\t\t\t\tparsedConfig, err := yaml.ParseBytes(config)\n\t\t\t\t\tassert.NoError(t, err, \"if schema lint passes, we should be able to parse it\")\n\t\t\t\t\tassert.NotNil(t, parsedConfig)\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/matrix/matrix.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage matrix\n\nimport (\n\t\"strings\"\n\n\t\"codeberg.org/6543/xyaml\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n)\n\nconst (\n\tlimitTags = 10\n\tlimitAxis = 25\n)\n\n// Matrix represents the pipeline matrix.\ntype Matrix map[string][]string\n\n// Axis represents a single permutation of entries from the pipeline matrix.\ntype Axis map[string]string\n\n// String returns a string representation of an Axis as a comma-separated list\n// of environment variables.\nfunc (a Axis) String() string {\n\tvar envs []string\n\tfor k, v := range a {\n\t\tenvs = append(envs, k+\"=\"+v)\n\t}\n\treturn strings.Join(envs, \" \")\n}\n\n// Parse parses the Yaml matrix definition.\nfunc Parse(data []byte) ([]Axis, error) {\n\taxis, err := parseList(data)\n\tif err == nil && len(axis) != 0 {\n\t\treturn axis, nil\n\t}\n\n\tmatrix, err := parse(data)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(matrix) == 0 {\n\t\treturn []Axis{}, nil\n\t}\n\n\treturn calc(matrix), nil\n}\n\n// ParseString parses the Yaml string matrix definition.\nfunc ParseString(data string) ([]Axis, error) {\n\treturn Parse([]byte(data))\n}\n\nfunc calc(matrix Matrix) []Axis {\n\t// calculate number of permutations and extract the list of tags\n\t// (ie go_version, redis_version, etc)\n\tvar perm int\n\tvar tags []string\n\tfor k, v := range matrix {\n\t\tperm *= len(v)\n\t\tif perm == 0 {\n\t\t\tperm = len(v)\n\t\t}\n\t\ttags = append(tags, k)\n\t}\n\n\t// structure to hold the transformed result set\n\tvar axisList []Axis\n\n\t// for each axis calculate the unique set of values that should be used.\n\tfor p := 0; p < perm; p++ {\n\t\taxis := map[string]string{}\n\t\tdecrease := perm\n\t\tfor i, tag := range tags {\n\t\t\telems := matrix[tag]\n\t\t\tdecrease /= len(elems)\n\t\t\telem := p / decrease % len(elems)\n\t\t\taxis[tag] = elems[elem]\n\n\t\t\t// enforce a maximum number of tags in the pipeline matrix.\n\t\t\tif i > limitTags {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// append to the list of axis.\n\t\taxisList = append(axisList, axis)\n\n\t\t// enforce a maximum number of axis that should be calculated.\n\t\tif p > limitAxis {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn axisList\n}\n\nfunc parse(raw []byte) (Matrix, error) {\n\tdata := struct {\n\t\tMatrix map[string][]string\n\t}{}\n\tif err := xyaml.Unmarshal(raw, &data); err != nil {\n\t\treturn nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler}\n\t}\n\treturn data.Matrix, nil\n}\n\nfunc parseList(raw []byte) ([]Axis, error) {\n\tdata := struct {\n\t\tMatrix struct {\n\t\t\tInclude []Axis\n\t\t}\n\t}{}\n\n\tif err := xyaml.Unmarshal(raw, &data); err != nil {\n\t\treturn nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler}\n\t}\n\treturn data.Matrix.Include, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/matrix/matrix_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage matrix\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestMatrix(t *testing.T) {\n\taxis, _ := ParseString(fakeMatrix)\n\tassert.Len(t, axis, 24)\n\n\tset := map[string]bool{}\n\tfor _, perm := range axis {\n\t\tset[perm.String()] = true\n\t}\n\tassert.Len(t, set, 24)\n}\n\nfunc TestMatrixEmpty(t *testing.T) {\n\taxis, err := ParseString(\"\")\n\tassert.NoError(t, err)\n\tassert.Empty(t, axis)\n}\n\nfunc TestMatrixIncluded(t *testing.T) {\n\taxis, err := ParseString(fakeMatrixInclude)\n\tassert.NoError(t, err)\n\tassert.Len(t, axis, 2)\n\tassert.Equal(t, \"1.5\", axis[0][\"go_version\"])\n\tassert.Equal(t, \"1.6\", axis[1][\"go_version\"])\n\tassert.Equal(t, \"3.4\", axis[0][\"python_version\"])\n\tassert.Equal(t, \"3.4\", axis[1][\"python_version\"])\n}\n\nvar fakeMatrix = `\nmatrix:\n  go_version:\n    - go1\n    - go1.2\n  python_version:\n    - 3.2\n    - 3.3\n  django_version:\n    - 1.7\n    - 1.7.1\n    - 1.7.2\n  redis_version:\n    - 2.6\n    - 2.8\n`\n\nvar fakeMatrixInclude = `\nmatrix:\n  include:\n    - go_version: 1.5\n      python_version: 3.4\n    - go_version: 1.6\n      python_version: 3.4\n`\n"
  },
  {
    "path": "pipeline/frontend/yaml/parse.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage yaml\n\nimport (\n\t\"codeberg.org/6543/xyaml\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n)\n\n// ParseBytes parses the configuration from bytes b.\nfunc ParseBytes(b []byte) (*types.Workflow, error) {\n\tout := new(types.Workflow)\n\terr := xyaml.Unmarshal(b, out)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// ParseString parses the configuration from string s.\nfunc ParseString(s string) (*types.Workflow, error) {\n\treturn ParseBytes(\n\t\t[]byte(s),\n\t)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/parse_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage yaml\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\tyaml_base_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n)\n\nfunc TestParse(t *testing.T) {\n\tt.Run(\"Should unmarshal a string\", func(t *testing.T) {\n\t\tout, err := ParseString(sampleYaml)\n\t\tassert.NoError(t, err)\n\n\t\tassert.Contains(t, out.When.Constraints[0].Event, \"tester\")\n\n\t\tassert.Equal(t, \"/go\", out.Workspace.Base)\n\t\tassert.Equal(t, \"src/github.com/octocat/hello-world\", out.Workspace.Path)\n\t\tassert.Equal(t, \"database\", out.Services.ContainerList[0].Name)\n\t\tassert.Equal(t, \"mysql\", out.Services.ContainerList[0].Image)\n\t\tassert.Equal(t, \"test\", out.Steps.ContainerList[0].Name)\n\t\tassert.Equal(t, \"golang\", out.Steps.ContainerList[0].Image)\n\t\tassert.Equal(t, yaml_base_types.StringOrSlice{\"go install\", \"go test\"}, out.Steps.ContainerList[0].Commands)\n\t\tassert.Equal(t, \"build\", out.Steps.ContainerList[1].Name)\n\t\tassert.Equal(t, \"golang\", out.Steps.ContainerList[1].Image)\n\t\tassert.Equal(t, yaml_base_types.StringOrSlice{\"go build\"}, out.Steps.ContainerList[1].Commands)\n\t\tassert.Equal(t, \"notify\", out.Steps.ContainerList[2].Name)\n\t\tassert.Equal(t, \"slack\", out.Steps.ContainerList[2].Image)\n\t\tassert.Equal(t, \"frontend\", out.Labels[\"com.example.team\"])\n\t\tassert.Equal(t, \"build\", out.Labels[\"com.example.type\"])\n\t\tassert.Equal(t, \"lint\", out.DependsOn[0])\n\t\tassert.Equal(t, \"test\", out.DependsOn[1])\n\t\tassert.EqualValues(t, []string{\"success\", \"failure\"}, out.When.Constraints[0].Status)\n\t\tassert.False(t, out.SkipClone)\n\t})\n\n\tt.Run(\"Should fail on invalid yaml\", func(t *testing.T) {\n\t\t_, err := ParseString(\"notvalid\")\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"Should handle simple yaml anchors\", func(t *testing.T) {\n\t\tout, err := ParseString(simpleYamlAnchors)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"notify_success\", out.Steps.ContainerList[0].Name)\n\t\tassert.Equal(t, \"plugins/slack\", out.Steps.ContainerList[0].Image)\n\t})\n\n\tt.Run(\"Should unmarshal variables\", func(t *testing.T) {\n\t\tout, err := ParseString(sampleVarYaml)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"notify_fail\", out.Steps.ContainerList[0].Name)\n\t\tassert.Equal(t, \"plugins/slack\", out.Steps.ContainerList[0].Image)\n\t\tassert.Equal(t, \"notify_success\", out.Steps.ContainerList[1].Name)\n\t\tassert.Equal(t, \"plugins/slack\", out.Steps.ContainerList[1].Image)\n\n\t\tassert.Empty(t, out.Steps.ContainerList[0].When.Constraints)\n\t\tassert.Equal(t, \"notify_success\", out.Steps.ContainerList[1].Name)\n\t\tassert.Equal(t, \"plugins/slack\", out.Steps.ContainerList[1].Image)\n\t\tassert.Equal(t, yaml_base_types.StringOrSlice{\"push\"}, out.Steps.ContainerList[1].When.Constraints[0].Event)\n\t})\n}\n\nfunc TestMatch(t *testing.T) {\n\tmatchConfig, err := ParseString(sampleYaml)\n\tassert.NoError(t, err)\n\n\tt.Run(\"Should match event tester\", func(t *testing.T) {\n\t\tmatch, err := matchConfig.When.Match(metadata.Metadata{\n\t\t\tCurr: metadata.Pipeline{\n\t\t\t\tEvent: \"tester\",\n\t\t\t},\n\t\t}, false, nil)\n\t\tassert.True(t, match)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"Should match event tester2\", func(t *testing.T) {\n\t\tmatch, err := matchConfig.When.Match(metadata.Metadata{\n\t\t\tCurr: metadata.Pipeline{\n\t\t\t\tEvent: \"tester2\",\n\t\t\t},\n\t\t}, false, nil)\n\t\tassert.True(t, match)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"Should match branch tester\", func(t *testing.T) {\n\t\tmatch, err := matchConfig.When.Match(metadata.Metadata{\n\t\t\tCurr: metadata.Pipeline{\n\t\t\t\tCommit: metadata.Commit{\n\t\t\t\t\tBranch: \"tester\",\n\t\t\t\t},\n\t\t\t},\n\t\t}, true, nil)\n\t\tassert.True(t, match)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"Should not match event push\", func(t *testing.T) {\n\t\tmatch, err := matchConfig.When.Match(metadata.Metadata{\n\t\t\tCurr: metadata.Pipeline{\n\t\t\t\tEvent: \"push\",\n\t\t\t},\n\t\t}, false, nil)\n\t\tassert.False(t, match)\n\t\tassert.NoError(t, err)\n\t})\n}\n\nfunc TestParseLegacy(t *testing.T) {\n\tsampleYamlPipeline := `\nlabels:\n  platform: linux/amd64\n\nsteps:\n  say hello:\n    image: bash\n    commands: echo hello\n`\n\n\tsampleYamlPipelineLegacyIgnore := `\nplatform: windows/amd64\nlabels:\n  platform: linux/amd64\n\nsteps:\n  say hello:\n    image: bash\n    commands: echo hello\n\npipeline:\n  old crap:\n    image: bash\n    commands: meh!\n`\n\n\tworkflow1, err := ParseString(sampleYamlPipeline)\n\trequire.NoError(t, err)\n\n\tworkflow2, err := ParseString(sampleYamlPipelineLegacyIgnore)\n\trequire.NoError(t, err)\n\n\tassert.EqualValues(t, workflow1, workflow2)\n\tassert.Len(t, workflow1.Steps.ContainerList, 1)\n\tassert.EqualValues(t, \"say hello\", workflow1.Steps.ContainerList[0].Name)\n}\n\nvar sampleYaml = `\nimage: hello-world\nwhen:\n  - event:\n    - tester\n    - tester2\n    status: [ success, failure ]\n  - branch:\n    - tester\n    status: [ success, failure ]\nworkspace:\n  path: src/github.com/octocat/hello-world\n  base: /go\nsteps:\n  test:\n    image: golang\n    commands:\n      - go install\n      - go test\n  build:\n    image: golang\n    commands:\n      - go build\n    when:\n      event: push\n    depends_on: []\n  notify:\n    image: slack\n    settings:\n      channel: dev\n    when:\n      event: failure\nservices:\n  database:\n    image: mysql\nlabels:\n  com.example.type: \"build\"\n  com.example.team: \"frontend\"\ndepends_on:\n  - lint\n  - test\n`\n\nvar simpleYamlAnchors = `\nvars:\n  image: &image plugins/slack\nsteps:\n  notify_success:\n    image: *image\n`\n\nvar sampleVarYaml = `\nvariables: &SLACK\n  image: plugins/slack\nsteps:\n  notify_fail: *SLACK\n  notify_success:\n    << : *SLACK\n    when:\n      event: push\n  echo:\n    when:\n    - path: wow.sh\n      repo: \"test\"\n      branch:\n        exclude: main\n    - path:\n      - test.yaml\n      - test.zig\n    - path:\n        exclude: a\n        on_empty: true\n    - ref: ref/tags/v1\n      path:\n  env:\n    image: print\n    environment:\n      DRIVER: next\n      PLATFORM: linux\n`\n\nfunc TestReSerialize(t *testing.T) {\n\twork1, err := ParseString(sampleVarYaml)\n\trequire.NoError(t, err)\n\n\twork1Bin, err := yaml.Marshal(work1)\n\trequire.NoError(t, err)\n\n\tassert.EqualValues(t, `steps:\n    - name: notify_fail\n      image: plugins/slack\n    - name: notify_success\n      image: plugins/slack\n      when:\n        event: push\n    - name: echo\n      when:\n        - repo: test\n          branch:\n            exclude: main\n          path: wow.sh\n        - path:\n            - test.yaml\n            - test.zig\n        - path:\n            exclude: a\n        - ref: ref/tags/v1\n    - name: env\n      image: print\n      environment:\n        DRIVER: next\n        PLATFORM: linux\n`, string(work1Bin))\n\n\twork2, err := ParseString(sampleYaml)\n\trequire.NoError(t, err)\n\n\tworkBin2, err := yaml.Marshal(work2)\n\trequire.NoError(t, err)\n\n\t// TODO: fix \"steps.[1].depends_on: []\" to be re-serialized!\n\tassert.EqualValues(t, `when:\n    - status:\n        - success\n        - failure\n      event:\n        - tester\n        - tester2\n    - branch: tester\n      status:\n        - success\n        - failure\nworkspace:\n    base: /go\n    path: src/github.com/octocat/hello-world\nsteps:\n    - name: test\n      image: golang\n      commands:\n        - go install\n        - go test\n    - name: build\n      image: golang\n      commands: go build\n      when:\n        event: push\n    - name: notify\n      image: slack\n      settings:\n        channel: dev\n      when:\n        event: failure\nservices:\n    - name: database\n      image: mysql\nlabels:\n    com.example.team: frontend\n    com.example.type: build\ndepends_on:\n    - lint\n    - test\n`, string(workBin2))\n}\n\nfunc TestSlice(t *testing.T) {\n\tout, err := ParseString(sampleYaml)\n\trequire.NoError(t, err)\n\n\tt.Run(\"should marshal a not set slice to nil\", func(t *testing.T) {\n\t\tassert.Equal(t, \"test\", out.Steps.ContainerList[0].Name)\n\t\tassert.Nil(t, out.Steps.ContainerList[0].DependsOn)\n\t\tassert.Empty(t, out.Steps.ContainerList[0].DependsOn)\n\t})\n\n\tt.Run(\"should marshal an empty slice\", func(t *testing.T) {\n\t\tassert.Equal(t, \"build\", out.Steps.ContainerList[1].Name)\n\t\tassert.NotNil(t, out.Steps.ContainerList[1].DependsOn)\n\t\tassert.Empty(t, (out.Steps.ContainerList[1].DependsOn))\n\t})\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/base/int.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage base\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\n\t\"github.com/docker/go-units\"\n)\n\n// StringOrInt represents a string or an integer.\ntype StringOrInt int64\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (s *StringOrInt) UnmarshalYAML(unmarshal func(any) error) error {\n\tvar intType int64\n\tif err := unmarshal(&intType); err == nil {\n\t\t*s = StringOrInt(intType)\n\t\treturn nil\n\t}\n\n\tvar stringType string\n\tif err := unmarshal(&stringType); err == nil {\n\t\tintType, err := strconv.ParseInt(stringType, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*s = StringOrInt(intType)\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"failed to unmarshal StringOrInt\")\n}\n\n// MemStringOrInt represents a string or an integer\n// the String supports notations like 10m for then Megabyte of memory.\ntype MemStringOrInt int64\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (s *MemStringOrInt) UnmarshalYAML(unmarshal func(any) error) error {\n\tvar intType int64\n\tif err := unmarshal(&intType); err == nil {\n\t\t*s = MemStringOrInt(intType)\n\t\treturn nil\n\t}\n\n\tvar stringType string\n\tif err := unmarshal(&stringType); err == nil {\n\t\tintType, err := units.RAMInBytes(stringType)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*s = MemStringOrInt(intType)\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"failed to unmarshal MemStringOrInt\")\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/base/int_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage base\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype StructStringOrInt struct {\n\tFoo StringOrInt\n}\n\nfunc TestStringOrIntYaml(t *testing.T) {\n\tfor _, str := range []string{`{foo: 10}`, `{foo: \"10\"}`} {\n\t\ts := StructStringOrInt{}\n\t\tassert.NoError(t, yaml.Unmarshal([]byte(str), &s))\n\n\t\tassert.Equal(t, StringOrInt(10), s.Foo)\n\n\t\td, err := yaml.Marshal(&s)\n\t\tassert.NoError(t, err)\n\n\t\ts2 := StructStringOrInt{}\n\t\tassert.NoError(t, yaml.Unmarshal(d, &s2))\n\n\t\tassert.Equal(t, StringOrInt(10), s2.Foo)\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/base/slice.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage base\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// StringOrSlice represents a string or an array of strings.\n// We need to override the yaml decoder to accept both options.\ntype StringOrSlice []string\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (s *StringOrSlice) UnmarshalYAML(unmarshal func(any) error) error {\n\tvar stringType string\n\tif err := unmarshal(&stringType); err == nil {\n\t\t*s = []string{stringType}\n\t\treturn nil\n\t}\n\n\tvar sliceType []any\n\tif err := unmarshal(&sliceType); err == nil {\n\t\tparts, err := toStrings(sliceType)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t*s = parts\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"failed to unmarshal StringOrSlice\")\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (s StringOrSlice) MarshalYAML() (any, error) {\n\tif len(s) == 0 {\n\t\treturn nil, nil\n\t} else if len(s) == 1 {\n\t\treturn s[0], nil\n\t}\n\treturn []string(s), nil\n}\n\nfunc toStrings(s []any) ([]string, error) {\n\tif s == nil {\n\t\treturn nil, nil\n\t}\n\tr := make([]string, len(s))\n\tfor k, v := range s {\n\t\tif sv, ok := v.(string); ok {\n\t\t\tr[k] = sv\n\t\t} else {\n\t\t\treturn nil, fmt.Errorf(\"cannot unmarshal '%v' of type %T into a string value\", v, v)\n\t\t}\n\t}\n\treturn r, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/base/slice_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage base\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype StructStringOrSlice struct {\n\tFoo StringOrSlice `yaml:\"foo\"`\n\tBar StringOrSlice `yaml:\"bar,omitempty\"`\n}\n\nfunc TestStringOrSliceYaml(t *testing.T) {\n\tt.Run(\"unmarshal\", func(t *testing.T) {\n\t\tstr := `{foo: [bar, baz]}`\n\n\t\ts := StructStringOrSlice{}\n\t\tassert.NoError(t, yaml.Unmarshal([]byte(str), &s))\n\n\t\tassert.Equal(t, StringOrSlice{\"bar\", \"baz\"}, s.Foo)\n\n\t\td, err := yaml.Marshal(&s)\n\t\tassert.NoError(t, err)\n\n\t\ts2 := StructStringOrSlice{}\n\t\tassert.NoError(t, yaml.Unmarshal(d, &s2))\n\n\t\tassert.Equal(t, StringOrSlice{\"bar\", \"baz\"}, s2.Foo)\n\t})\n\n\tt.Run(\"marshal\", func(t *testing.T) {\n\t\tstr := StructStringOrSlice{}\n\t\tout, err := yaml.Marshal(str)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, \"foo: null\\n\", string(out))\n\n\t\tstr = StructStringOrSlice{Foo: []string{\"a\\\"\"}}\n\t\tout, err = yaml.Marshal(str)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, \"foo: a\\\"\\n\", string(out))\n\n\t\tstr = StructStringOrSlice{Foo: []string{\"a\", \"b\", \"c\"}}\n\t\tout, err = yaml.Marshal(str)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, `foo:\n    - a\n    - b\n    - c\n`, string(out))\n\t})\n\n\tstr := `{foo: [bar, \"baz\"]}`\n\ts := StructStringOrSlice{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(str), &s))\n\tassert.Equal(t, StringOrSlice{\"bar\", \"baz\"}, s.Foo)\n\n\td, err := yaml.Marshal(&s)\n\tassert.NoError(t, err)\n\n\ts = StructStringOrSlice{}\n\tassert.NoError(t, yaml.Unmarshal(d, &s))\n\tassert.Equal(t, StringOrSlice{\"bar\", \"baz\"}, s.Foo)\n\n\tstr = `{foo: []}`\n\ts = StructStringOrSlice{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(str), &s))\n\tassert.Equal(t, StringOrSlice{}, s.Foo)\n\n\tstr = `{}`\n\ts = StructStringOrSlice{}\n\tassert.NoError(t, yaml.Unmarshal([]byte(str), &s))\n\tassert.Nil(t, s.Foo)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/container.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils\"\n)\n\n// Container defines a container.\ntype Container struct {\n\t// common\n\tName        string             `yaml:\"name,omitempty\"`\n\tImage       string             `yaml:\"image,omitempty\"`\n\tPull        bool               `yaml:\"pull,omitempty\"`\n\tCommands    base.StringOrSlice `yaml:\"commands,omitempty\"`\n\tEntrypoint  base.StringOrSlice `yaml:\"entrypoint,omitempty\"`\n\tDirectory   string             `yaml:\"directory,omitempty\"`\n\tSettings    map[string]any     `yaml:\"settings,omitempty\"`\n\tEnvironment map[string]any     `yaml:\"environment,omitempty\"`\n\t// flow control\n\tDependsOn base.StringOrSlice `yaml:\"depends_on,omitempty\"`\n\tWhen      constraint.When    `yaml:\"when,omitempty\"`\n\tFailure   string             `yaml:\"failure,omitempty\"`\n\tDetached  bool               `yaml:\"detach,omitempty\"`\n\t// state\n\tVolumes Volumes `yaml:\"volumes,omitempty\"`\n\t// network\n\tPorts     []string           `yaml:\"ports,omitempty\"`\n\tDNS       base.StringOrSlice `yaml:\"dns,omitempty\"`\n\tDNSSearch base.StringOrSlice `yaml:\"dns_search,omitempty\"`\n\t// backend specific\n\tBackendOptions map[string]any `yaml:\"backend_options,omitempty\"`\n\n\t// ACTIVE DEVELOPMENT BELOW\n\n\t// Docker and Kubernetes Specific\n\tPrivileged bool `yaml:\"privileged,omitempty\"`\n\n\t// Undocumented\n\tDevices     []string `yaml:\"devices,omitempty\"`\n\tExtraHosts  []string `yaml:\"extra_hosts,omitempty\"`\n\tNetworkMode string   `yaml:\"network_mode,omitempty\"`\n\tTmpfs       []string `yaml:\"tmpfs,omitempty\"`\n}\n\nfunc (c *Container) IsPlugin() bool {\n\treturn len(c.Commands) == 0 &&\n\t\tlen(c.Entrypoint) == 0 &&\n\t\tlen(c.Environment) == 0\n}\n\nfunc (c *Container) IsTrustedCloneImage(trustedClonePlugins []string) bool {\n\treturn c.IsPlugin() && utils.MatchImageDynamic(c.Image, trustedClonePlugins...)\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/container_list.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"fmt\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// ContainerList contains ordered collection of containers.\ntype ContainerList struct {\n\tContainerList []*Container\n}\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (c *ContainerList) UnmarshalYAML(value *yaml.Node) error {\n\tswitch value.Kind {\n\t// We support maps ...\n\tcase yaml.MappingNode:\n\t\tc.ContainerList = make([]*Container, 0, len(value.Content)/2+1)\n\t\t// We cannot use decode on specific values\n\t\t// since if we try to load from a map, the order\n\t\t// will not be kept. Therefor use value.Content\n\t\t// and take the map values i%2=1\n\t\tfor i, n := range value.Content {\n\t\t\tif i%2 == 1 {\n\t\t\t\tcontainer := &Container{}\n\t\t\t\tif err := n.Decode(container); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tif container.Name == \"\" {\n\t\t\t\t\tcontainer.Name = fmt.Sprintf(\"%v\", value.Content[i-1].Value)\n\t\t\t\t}\n\n\t\t\t\tc.ContainerList = append(c.ContainerList, container)\n\t\t\t}\n\t\t}\n\n\t// ... and lists\n\tcase yaml.SequenceNode:\n\t\tc.ContainerList = make([]*Container, 0, len(value.Content))\n\t\tfor i, n := range value.Content {\n\t\t\tcontainer := &Container{}\n\t\t\tif err := n.Decode(container); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif container.Name == \"\" {\n\t\t\t\tcontainer.Name = fmt.Sprintf(\"step-%d\", i)\n\t\t\t}\n\n\t\t\tc.ContainerList = append(c.ContainerList, container)\n\t\t}\n\n\tdefault:\n\t\treturn fmt.Errorf(\"yaml node type[%d]: '%s' not supported\", value.Kind, value.Tag)\n\t}\n\n\treturn nil\n}\n\n// MarshalYAML implements custom Yaml marshaling.\nfunc (c ContainerList) MarshalYAML() (any, error) {\n\treturn c.ContainerList, nil\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/container_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base\"\n)\n\nvar containerYaml = []byte(`\nimage: golang:latest\ncommands:\n  - go build\n  - go test\ndetach: true\ndevices:\n  - /dev/ttyUSB0:/dev/ttyUSB0\ndirectory: example/\ndns: 8.8.8.8\ndns_search: example.com\nentrypoint: [/bin/sh, -c]\nenvironment:\n  RACK_ENV: development\n  SHOW: true\nextra_hosts:\n - somehost:162.242.195.82\n - otherhost:50.31.209.229\n - ipv6:2001:db8::10\nname: my-build-container\nnetwork_mode: bridge\nnetworks:\n  - some-network\n  - other-network\npull: true\nprivileged: true\nvolumes:\n  - /var/lib/mysql\n  - /opt/data:/var/lib/mysql\n  - /etc/configs:/etc/configs/:ro\ntmpfs:\n  - /var/lib/test\nwhen:\n  - branch: main\n  - event: cron\n    cron: job1\nsettings:\n  foo: bar\n  baz: false\nports:\n  - 8080\n  - 4443/tcp\n  - 51820/udp\n`)\n\nfunc TestUnmarshalContainer(t *testing.T) {\n\twant := Container{\n\t\tCommands:    base.StringOrSlice{\"go build\", \"go test\"},\n\t\tDetached:    true,\n\t\tDevices:     []string{\"/dev/ttyUSB0:/dev/ttyUSB0\"},\n\t\tDirectory:   \"example/\",\n\t\tDNS:         base.StringOrSlice{\"8.8.8.8\"},\n\t\tDNSSearch:   base.StringOrSlice{\"example.com\"},\n\t\tEntrypoint:  []string{\"/bin/sh\", \"-c\"},\n\t\tEnvironment: map[string]any{\"RACK_ENV\": \"development\", \"SHOW\": true},\n\t\tExtraHosts:  []string{\"somehost:162.242.195.82\", \"otherhost:50.31.209.229\", \"ipv6:2001:db8::10\"},\n\t\tImage:       \"golang:latest\",\n\t\tName:        \"my-build-container\",\n\t\tNetworkMode: \"bridge\",\n\t\tPull:        true,\n\t\tPrivileged:  true,\n\t\tTmpfs:       base.StringOrSlice{\"/var/lib/test\"},\n\t\tVolumes: Volumes{\n\t\t\tVolumes: []*Volume{\n\t\t\t\t{Source: \"\", Destination: \"/var/lib/mysql\"},\n\t\t\t\t{Source: \"/opt/data\", Destination: \"/var/lib/mysql\"},\n\t\t\t\t{Source: \"/etc/configs\", Destination: \"/etc/configs/\", AccessMode: \"ro\"},\n\t\t\t},\n\t\t},\n\t\tWhen: constraint.When{\n\t\t\tConstraints: []constraint.Constraint{\n\t\t\t\t{\n\t\t\t\t\tBranch: constraint.List{\n\t\t\t\t\t\tInclude: []string{\"main\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tEvent: base.StringOrSlice{\"cron\"},\n\t\t\t\t\tCron: constraint.List{\n\t\t\t\t\t\tInclude: []string{\"job1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tSettings: map[string]any{\n\t\t\t\"foo\": \"bar\",\n\t\t\t\"baz\": false,\n\t\t},\n\t\tPorts: []string{\"8080\", \"4443/tcp\", \"51820/udp\"},\n\t}\n\tgot := Container{}\n\terr := yaml.Unmarshal(containerYaml, &got)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, want, got, \"problem parsing container\")\n}\n\n// TestUnmarshalContainers unmarshals a map of containers. The order is\n// retained and the container key may be used as the container name if a\n// name is not explicitly provided.\nfunc TestUnmarshalContainers(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant []*Container\n\t}{\n\t\t{\n\t\t\tfrom: \"build: { image: golang }\",\n\t\t\twant: []*Container{\n\t\t\t\t{\n\t\t\t\t\tName:  \"build\",\n\t\t\t\t\tImage: \"golang\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tfrom: \"test: { name: unit_test, image: node, settings: { normal_setting: true } }\",\n\t\t\twant: []*Container{\n\t\t\t\t{\n\t\t\t\t\tName:  \"unit_test\",\n\t\t\t\t\tImage: \"node\",\n\t\t\t\t\tSettings: map[string]any{\n\t\t\t\t\t\t\"normal_setting\": true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tfrom: `publish-agent:\n    image: print/env\n    settings:\n      repo: woodpeckerci/woodpecker-agent\n      dry_run: true\n      dockerfile: docker/Dockerfile.agent\n      tag: [next, latest]\n    when:\n      branch: ${CI_REPO_DEFAULT_BRANCH}\n      event: push`,\n\t\t\twant: []*Container{\n\t\t\t\t{\n\t\t\t\t\tName:  \"publish-agent\",\n\t\t\t\t\tImage: \"print/env\",\n\t\t\t\t\tSettings: map[string]any{\n\t\t\t\t\t\t\"repo\":       \"woodpeckerci/woodpecker-agent\",\n\t\t\t\t\t\t\"dockerfile\": \"docker/Dockerfile.agent\",\n\t\t\t\t\t\t\"tag\":        stringsToInterface(\"next\", \"latest\"),\n\t\t\t\t\t\t\"dry_run\":    true,\n\t\t\t\t\t},\n\t\t\t\t\tWhen: constraint.When{\n\t\t\t\t\t\tConstraints: []constraint.Constraint{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tEvent:  base.StringOrSlice{\"push\"},\n\t\t\t\t\t\t\t\tBranch: constraint.List{Include: []string{\"${CI_REPO_DEFAULT_BRANCH}\"}},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tfrom: `publish-cli:\n    image: print/env\n    settings:\n      repo: woodpeckerci/woodpecker-cli\n      dockerfile: docker/Dockerfile.cli\n      tag: [next]\n    when:\n      branch: ${CI_REPO_DEFAULT_BRANCH}\n      event: push`,\n\t\t\twant: []*Container{\n\t\t\t\t{\n\t\t\t\t\tName:  \"publish-cli\",\n\t\t\t\t\tImage: \"print/env\",\n\t\t\t\t\tSettings: map[string]any{\n\t\t\t\t\t\t\"repo\":       \"woodpeckerci/woodpecker-cli\",\n\t\t\t\t\t\t\"dockerfile\": \"docker/Dockerfile.cli\",\n\t\t\t\t\t\t\"tag\":        stringsToInterface(\"next\"),\n\t\t\t\t\t},\n\t\t\t\t\tWhen: constraint.When{\n\t\t\t\t\t\tConstraints: []constraint.Constraint{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tEvent:  base.StringOrSlice{\"push\"},\n\t\t\t\t\t\t\t\tBranch: constraint.List{Include: []string{\"${CI_REPO_DEFAULT_BRANCH}\"}},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tfrom: `publish-cli:\n    image: print/env\n    when:\n      - branch: ${CI_REPO_DEFAULT_BRANCH}\n        event: push\n      - event: pull_request`,\n\t\t\twant: []*Container{\n\t\t\t\t{\n\t\t\t\t\tName:  \"publish-cli\",\n\t\t\t\t\tImage: \"print/env\",\n\t\t\t\t\tWhen: constraint.When{\n\t\t\t\t\t\tConstraints: []constraint.Constraint{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tEvent:  base.StringOrSlice{\"push\"},\n\t\t\t\t\t\t\t\tBranch: constraint.List{Include: []string{\"${CI_REPO_DEFAULT_BRANCH}\"}},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tEvent: base.StringOrSlice{\"pull_request\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tin := []byte(test.from)\n\t\tgot := ContainerList{}\n\t\terr := yaml.Unmarshal(in, &got)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, test.want, got.ContainerList, \"problem parsing containers %q\", test.from)\n\t}\n}\n\n// TestUnmarshalContainersErr unmarshals a container map where invalid inputs\n// are provided to verify error messages are returned.\nfunc TestUnmarshalContainersErr(t *testing.T) {\n\ttestdata := []string{\n\t\t\"foo: { name: [ foo, bar] }\",\n\t\t\"- foo\",\n\t}\n\tfor _, test := range testdata {\n\t\tin := []byte(test)\n\t\tcontainers := new(ContainerList)\n\t\terr := yaml.Unmarshal(in, &containers)\n\t\tassert.Error(t, err, \"wanted error for containers %q\", test)\n\t}\n}\n\nfunc stringsToInterface(val ...string) []any {\n\tres := make([]any, len(val))\n\tfor i := range val {\n\t\tres[i] = val[i]\n\t}\n\treturn res\n}\n\nfunc TestIsPlugin(t *testing.T) {\n\tassert.True(t, (&Container{}).IsPlugin())\n\tassert.True(t, (&Container{\n\t\tCommands: base.StringOrSlice([]string{}),\n\t}).IsPlugin())\n\tassert.False(t, (&Container{\n\t\tCommands: base.StringOrSlice([]string{\"echo 'this is not a plugin'\"}),\n\t}).IsPlugin())\n\tassert.True(t, (&Container{\n\t\tEntrypoint: base.StringOrSlice([]string{}),\n\t}).IsPlugin())\n\tassert.False(t, (&Container{\n\t\tEntrypoint: base.StringOrSlice([]string{\"echo 'this is not a plugin'\"}),\n\t}).IsPlugin())\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/network.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\n// Networks represents a list of service networks in compose file.\n// It has several representation, hence this specific struct.\ntype Networks struct {\n\tNetworks []*Network\n}\n\n// Network represents a  service network in compose file.\ntype Network struct {\n\tName        string   `yaml:\"-\"`\n\tAliases     []string `yaml:\"aliases,omitempty\"`\n\tIPv4Address string   `yaml:\"ipv4_address,omitempty\"`\n\tIPv6Address string   `yaml:\"ipv6_address,omitempty\"`\n}\n\n// MarshalYAML implements the Marshaller interface.\nfunc (n Networks) MarshalYAML() (any, error) {\n\tm := map[string]*Network{}\n\tfor _, network := range n.Networks {\n\t\tm[network.Name] = network\n\t}\n\treturn m, nil\n}\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (n *Networks) UnmarshalYAML(unmarshal func(any) error) error {\n\tvar sliceType []any\n\tif err := unmarshal(&sliceType); err == nil {\n\t\tn.Networks = []*Network{}\n\t\tfor _, network := range sliceType {\n\t\t\tname, ok := network.(string)\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", name, name)\n\t\t\t}\n\t\t\tn.Networks = append(n.Networks, &Network{\n\t\t\t\tName: name,\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t}\n\n\tvar mapType map[any]any\n\tif err := unmarshal(&mapType); err == nil {\n\t\tn.Networks = []*Network{}\n\t\tfor mapKey, mapValue := range mapType {\n\t\t\tname, ok := mapKey.(string)\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", name, name)\n\t\t\t}\n\t\t\tnetwork, err := handleNetwork(name, mapValue)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tn.Networks = append(n.Networks, network)\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"failed to unmarshal Networks\")\n}\n\nfunc handleNetwork(name string, value any) (*Network, error) {\n\tif value == nil {\n\t\treturn &Network{\n\t\t\tName: name,\n\t\t}, nil\n\t}\n\tswitch v := value.(type) {\n\tcase map[string]any:\n\t\tnetwork := &Network{\n\t\t\tName: name,\n\t\t}\n\n\t\tvar ok bool\n\t\tfor mapKey, mapValue := range v {\n\t\t\tswitch mapKey {\n\t\t\tcase \"aliases\":\n\t\t\t\taliases, ok := mapValue.([]any)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn &Network{}, fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", aliases, aliases)\n\t\t\t\t}\n\t\t\t\tnetwork.Aliases = []string{}\n\t\t\t\tfor _, alias := range aliases {\n\t\t\t\t\ta, ok := alias.(string)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\treturn &Network{}, fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", aliases, aliases)\n\t\t\t\t\t}\n\t\t\t\t\tnetwork.Aliases = append(network.Aliases, a)\n\t\t\t\t}\n\t\t\tcase \"ipv4_address\":\n\t\t\t\tnetwork.IPv4Address, ok = mapValue.(string)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn &Network{}, fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", network, network)\n\t\t\t\t}\n\t\t\tcase \"ipv6_address\":\n\t\t\t\tnetwork.IPv6Address, ok = mapValue.(string)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn &Network{}, fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", network, network)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// Ignorer unknown keys ?\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treturn network, nil\n\tdefault:\n\t\treturn &Network{}, fmt.Errorf(\"failed to unmarshal Network: %#v\", value)\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/network_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestMarshalNetworks(t *testing.T) {\n\tnetworks := []struct {\n\t\tnetworks Networks\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tnetworks: Networks{},\n\t\t\texpected: \"{}\\n\",\n\t\t},\n\t\t{\n\t\t\tnetworks: Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `network1: {}\nnetwork2: {}\n`,\n\t\t},\n\t\t{\n\t\t\tnetworks: Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"network1\",\n\t\t\t\t\t\tAliases: []string{\"alias1\", \"alias2\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `network1:\n    aliases:\n        - alias1\n        - alias2\nnetwork2: {}\n`,\n\t\t},\n\t\t{\n\t\t\tnetworks: Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"network1\",\n\t\t\t\t\t\tAliases: []string{\"alias1\", \"alias2\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:        \"network2\",\n\t\t\t\t\t\tIPv4Address: \"172.16.238.10\",\n\t\t\t\t\t\tIPv6Address: \"2001:3984:3989::10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `network1:\n    aliases:\n        - alias1\n        - alias2\nnetwork2:\n    ipv4_address: 172.16.238.10\n    ipv6_address: 2001:3984:3989::10\n`,\n\t\t},\n\t}\n\tfor _, network := range networks {\n\t\tbytes, err := yaml.Marshal(network.networks)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, network.expected, string(bytes), \"should be equal\")\n\t}\n}\n\nfunc TestUnmarshalNetworks(t *testing.T) {\n\tnetworks := []struct {\n\t\tyaml     string\n\t\texpected *Networks\n\t}{\n\t\t{\n\t\t\tyaml: `- network1\n- network2`,\n\t\t\texpected: &Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `network1:`,\n\t\t\texpected: &Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `network1: {}`,\n\t\t\texpected: &Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"network1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `network1:\n  aliases:\n    - alias1\n    - alias2`,\n\t\t\texpected: &Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"network1\",\n\t\t\t\t\t\tAliases: []string{\"alias1\", \"alias2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `network1:\n  aliases:\n    - alias1\n    - alias2\n  ipv4_address: 172.16.238.10\n  ipv6_address: 2001:3984:3989::10`,\n\t\t\texpected: &Networks{\n\t\t\t\tNetworks: []*Network{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:        \"network1\",\n\t\t\t\t\t\tAliases:     []string{\"alias1\", \"alias2\"},\n\t\t\t\t\t\tIPv4Address: \"172.16.238.10\",\n\t\t\t\t\t\tIPv6Address: \"2001:3984:3989::10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, network := range networks {\n\t\tactual := &Networks{}\n\t\terr := yaml.Unmarshal([]byte(network.yaml), actual)\n\t\tassert.NoError(t, err)\n\t\tassert.EqualValues(t, network.expected, actual)\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/volume.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// Volumes represents a list of service volumes in compose file.\n// It has several representation, hence this specific struct.\ntype Volumes struct {\n\tVolumes []*Volume\n}\n\n// Volume represent a service volume.\ntype Volume struct {\n\tSource      string `yaml:\"-\"`\n\tDestination string `yaml:\"-\"`\n\tAccessMode  string `yaml:\"-\"`\n}\n\n// String implements the Stringer interface.\nfunc (v *Volume) String() string {\n\tvar paths []string\n\tif v.Source != \"\" {\n\t\tpaths = []string{v.Source, v.Destination}\n\t} else {\n\t\tpaths = []string{v.Destination}\n\t}\n\tif v.AccessMode != \"\" {\n\t\tpaths = append(paths, v.AccessMode)\n\t}\n\treturn strings.Join(paths, \":\")\n}\n\n// MarshalYAML implements the Marshaller interface.\nfunc (v Volumes) MarshalYAML() (any, error) {\n\tvs := []string{}\n\tfor _, volume := range v.Volumes {\n\t\tvs = append(vs, volume.String())\n\t}\n\treturn vs, nil\n}\n\n// UnmarshalYAML implements the Unmarshaler interface.\nfunc (v *Volumes) UnmarshalYAML(unmarshal func(any) error) error {\n\tvar sliceType []any\n\tif err := unmarshal(&sliceType); err == nil {\n\t\tv.Volumes = []*Volume{}\n\t\tfor _, volume := range sliceType {\n\t\t\tname, ok := volume.(string)\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"cannot unmarshal '%v' to type %T into a string value\", name, name)\n\t\t\t}\n\t\t\telements := strings.SplitN(name, \":\", 3)\n\t\t\tvar vol *Volume\n\t\t\t//nolint:mnd\n\t\t\tswitch {\n\t\t\tcase len(elements) == 1:\n\t\t\t\tvol = &Volume{\n\t\t\t\t\tDestination: elements[0],\n\t\t\t\t}\n\t\t\tcase len(elements) == 2:\n\t\t\t\tvol = &Volume{\n\t\t\t\t\tSource:      elements[0],\n\t\t\t\t\tDestination: elements[1],\n\t\t\t\t}\n\t\t\tcase len(elements) == 3:\n\t\t\t\tvol = &Volume{\n\t\t\t\t\tSource:      elements[0],\n\t\t\t\t\tDestination: elements[1],\n\t\t\t\t\tAccessMode:  elements[2],\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// FIXME\n\t\t\t\treturn fmt.Errorf(\"\")\n\t\t\t}\n\t\t\tv.Volumes = append(v.Volumes, vol)\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn errors.New(\"failed to unmarshal Volumes\")\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/volume_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestMarshalVolumes(t *testing.T) {\n\tvolumes := []struct {\n\t\tvolumes  Volumes\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tvolumes: Volumes{},\n\t\t\texpected: `[]\n`,\n\t\t},\n\t\t{\n\t\t\tvolumes: Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `- /in/the/container\n`,\n\t\t},\n\t\t{\n\t\t\tvolumes: Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"./a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t\tAccessMode:  \"ro\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `- ./a/path:/in/the/container:ro\n`,\n\t\t},\n\t\t{\n\t\t\tvolumes: Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"./a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `- ./a/path:/in/the/container\n`,\n\t\t},\n\t\t{\n\t\t\tvolumes: Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"./a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"named\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `- ./a/path:/in/the/container\n- named:/in/the/container\n`,\n\t\t},\n\t}\n\tfor _, volume := range volumes {\n\t\tbytes, err := yaml.Marshal(volume.volumes)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, volume.expected, string(bytes), \"should be equal\")\n\t}\n}\n\nfunc TestUnmarshalVolumes(t *testing.T) {\n\tvolumes := []struct {\n\t\tyaml     string\n\t\texpected *Volumes\n\t}{\n\t\t{\n\t\t\tyaml: `- ./a/path:/in/the/container`,\n\t\t\texpected: &Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"./a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `- /in/the/container`,\n\t\t\texpected: &Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `- /a/path:/in/the/container:ro`,\n\t\t\texpected: &Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"/a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t\tAccessMode:  \"ro\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tyaml: `- /a/path:/in/the/container\n- named:/somewhere/in/the/container`,\n\t\t\texpected: &Volumes{\n\t\t\t\tVolumes: []*Volume{\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"/a/path\",\n\t\t\t\t\t\tDestination: \"/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tSource:      \"named\",\n\t\t\t\t\t\tDestination: \"/somewhere/in/the/container\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, volume := range volumes {\n\t\tactual := &Volumes{}\n\t\terr := yaml.Unmarshal([]byte(volume.yaml), actual)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, volume.expected, actual, \"should be equal\")\n\t}\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/types/workflow.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint\"\n)\n\ntype (\n\t// Workflow defines a workflow configuration.\n\tWorkflow struct {\n\t\tWhen      constraint.When   `yaml:\"when,omitempty\"`\n\t\tWorkspace Workspace         `yaml:\"workspace,omitempty\"`\n\t\tClone     ContainerList     `yaml:\"clone,omitempty\"`\n\t\tSteps     ContainerList     `yaml:\"steps,omitempty\"`\n\t\tServices  ContainerList     `yaml:\"services,omitempty\"`\n\t\tLabels    map[string]string `yaml:\"labels,omitempty\"`\n\t\tDependsOn []string          `yaml:\"depends_on,omitempty\"`\n\t\tSkipClone bool              `yaml:\"skip_clone,omitempty\"`\n\t\t// Deprecated: use when.status. TODO remove in next major.\n\t\tRunsOn []string `yaml:\"runs_on,omitempty\"`\n\t}\n\n\t// Workspace defines a pipeline workspace.\n\tWorkspace struct {\n\t\tBase string\n\t\tPath string\n\t}\n)\n"
  },
  {
    "path": "pipeline/frontend/yaml/utils/image.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"strings\"\n\n\t\"github.com/distribution/reference\"\n)\n\n// trimImage returns the short image name without tag.\nfunc trimImage(name string) string {\n\tref, err := reference.ParseAnyReference(name)\n\tif err != nil {\n\t\treturn name\n\t}\n\tnamed, err := reference.ParseNamed(ref.String())\n\tif err != nil {\n\t\treturn name\n\t}\n\tnamed = reference.TrimNamed(named)\n\treturn reference.FamiliarName(named)\n}\n\n// expandImage returns the fully qualified image name.\nfunc expandImage(name string) string {\n\tref, err := reference.ParseAnyReference(name)\n\tif err != nil {\n\t\treturn name\n\t}\n\tnamed, err := reference.ParseNamed(ref.String())\n\tif err != nil {\n\t\treturn name\n\t}\n\tnamed = reference.TagNameOnly(named)\n\treturn named.String()\n}\n\n// MatchImage returns true if the image name matches\n// an image in the list. Note the image tag is not used\n// in the matching logic.\nfunc MatchImage(from string, to ...string) bool {\n\tfrom = trimImage(from)\n\tfor _, match := range to {\n\t\tif from == trimImage(match) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// MatchImageDynamic check if image is in list based on list.\n// If an list entry has a tag specified it only will match if both are the same, else the tag is ignored.\nfunc MatchImageDynamic(from string, to ...string) bool {\n\tfullFrom := expandImage(from)\n\ttrimFrom := trimImage(from)\n\tfor _, match := range to {\n\t\tif imageHasTag(match) {\n\t\t\tif fullFrom == expandImage(match) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t} else {\n\t\t\tif trimFrom == trimImage(match) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc imageHasTag(name string) bool {\n\treturn strings.Contains(name, \":\")\n}\n\n// ParseNamed parses an image as a reference to validate it then parses it as a named reference.\nfunc ParseNamed(image string) (reference.Named, error) {\n\tref, err := reference.ParseAnyReference(image)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn reference.ParseNamed(ref.String())\n}\n\n// MatchHostname returns true if the image hostname\n// matches the specified hostname.\nfunc MatchHostname(image, hostname string) bool {\n\tnamed, err := ParseNamed(image)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif hostname == \"index.docker.io\" {\n\t\thostname = \"docker.io\"\n\t}\n\treturn reference.Domain(named) == hostname\n}\n"
  },
  {
    "path": "pipeline/frontend/yaml/utils/image_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_trimImage(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:latest\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:1.0.0\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:latest\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:1.0.0\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"index.docker.io/library/golang:1.0.0\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"docker.io/library/golang:1.0.0\",\n\t\t\twant: \"golang\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/library/golang:1.0.0\",\n\t\t\twant: \"gcr.io/library/golang\",\n\t\t},\n\t\t// error cases, return input unmodified\n\t\t{\n\t\t\tfrom: \"foo/bar?baz:boo\",\n\t\t\twant: \"foo/bar?baz:boo\",\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tassert.Equal(t, test.want, trimImage(test.from))\n\t}\n}\n\nfunc Test_expandImage(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\twant: \"docker.io/library/golang:latest\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:latest\",\n\t\t\twant: \"docker.io/library/golang:latest\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:1.0.0\",\n\t\t\twant: \"docker.io/library/golang:1.0.0\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang\",\n\t\t\twant: \"docker.io/library/golang:latest\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:latest\",\n\t\t\twant: \"docker.io/library/golang:latest\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:1.0.0\",\n\t\t\twant: \"docker.io/library/golang:1.0.0\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"index.docker.io/library/golang:1.0.0\",\n\t\t\twant: \"docker.io/library/golang:1.0.0\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang\",\n\t\t\twant: \"gcr.io/golang:latest\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang:1.0.0\",\n\t\t\twant: \"gcr.io/golang:1.0.0\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803\",\n\t\t\twant: \"codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803\",\n\t\t},\n\t\t// error cases, return input unmodified\n\t\t{\n\t\t\tfrom: \"foo/bar?baz:boo\",\n\t\t\twant: \"foo/bar?baz:boo\",\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tassert.Equal(t, test.want, expandImage(test.from))\n\t}\n}\n\nfunc Test_imageHasTag(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom string\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:latest\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:1.0.0\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:latest\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:1.0.0\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"index.docker.io/library/golang:1.0.0\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang:1.0.0\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803\",\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tassert.Equal(t, test.want, imageHasTag(test.from))\n\t}\n}\n\nfunc Test_matchImage(t *testing.T) {\n\ttestdata := []struct {\n\t\tfrom, to string\n\t\twant     bool\n\t}{\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang:latest\",\n\t\t\tto:   \"golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:latest\",\n\t\t\tto:   \"golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"index.docker.io/library/golang:1.0.0\",\n\t\t\tto:   \"golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"golang:latest\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"library/golang:latest\",\n\t\t\tto:   \"library/golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang\",\n\t\t\tto:   \"gcr.io/golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang:1.0.0\",\n\t\t\tto:   \"gcr.io/golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang:latest\",\n\t\t\tto:   \"gcr.io/golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"gcr.io/golang\",\n\t\t\tto:   \"gcr.io/golang:latest\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"library/golang\",\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"gcr.io/project/golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"gcr.io/library/golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"golang\",\n\t\t\tto:   \"gcr.io/golang\",\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tfrom: \"woodpeckerci/plugin-kaniko\",\n\t\t\tto:   \"docker.io/woodpeckerci/plugin-kaniko\",\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tassert.Equal(t, test.want, MatchImage(test.from, test.to))\n\t}\n}\n\nfunc Test_matchImageDynamic(t *testing.T) {\n\ttestdata := []struct {\n\t\tname, from string\n\t\tto         []string\n\t\twant       bool\n\t}{\n\t\t{\n\t\t\tname: \"simple compare\",\n\t\t\tfrom: \"golang\",\n\t\t\tto:   []string{\"golang\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"compare non-taged image whit list who tag requirement\",\n\t\t\tfrom: \"golang\",\n\t\t\tto:   []string{\"golang:v3.0\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"compare taged image whit list who tag no requirement\",\n\t\t\tfrom: \"golang:v3.0\",\n\t\t\tto:   []string{\"golang\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"compare taged image whit list who has image with no tag requirement\",\n\t\t\tfrom: \"golang:1.0\",\n\t\t\tto:   []string{\"golang\", \"golang:2.0\"},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"compare taged image whit list who only has images with tag requirement\",\n\t\t\tfrom: \"golang:1.0\",\n\t\t\tto:   []string{\"golang:latest\", \"golang:2.0\"},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"compare taged image whit list who only has images with tag requirement\",\n\t\t\tfrom: \"golang:1.0\",\n\t\t\tto:   []string{\"golang:latest\", \"golang:1.0\"},\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tif !assert.Equal(t, test.want, MatchImageDynamic(test.from, test.to...)) {\n\t\t\tt.Logf(\"test data: '%s' -> '%s'\", test.from, test.to)\n\t\t}\n\t}\n}\n\nfunc Test_matchHostname(t *testing.T) {\n\ttestdata := []struct {\n\t\timage, hostname string\n\t\twant            bool\n\t}{\n\t\t{\n\t\t\timage:    \"golang\",\n\t\t\thostname: \"docker.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"golang:latest\",\n\t\t\thostname: \"docker.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"golang:latest\",\n\t\t\thostname: \"index.docker.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"library/golang:latest\",\n\t\t\thostname: \"docker.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"docker.io/library/golang:1.0.0\",\n\t\t\thostname: \"docker.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"gcr.io/golang\",\n\t\t\thostname: \"docker.io\",\n\t\t\twant:     false,\n\t\t},\n\t\t{\n\t\t\timage:    \"gcr.io/golang:1.0.0\",\n\t\t\thostname: \"gcr.io\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"1.2.3.4:8000/golang:1.0.0\",\n\t\t\thostname: \"1.2.3.4:8000\",\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\timage:    \"*&^%\",\n\t\t\thostname: \"1.2.3.4:8000\",\n\t\t\twant:     false,\n\t\t},\n\t}\n\tfor _, test := range testdata {\n\t\tassert.Equal(t, test.want, MatchHostname(test.image, test.hostname))\n\t}\n}\n"
  },
  {
    "path": "pipeline/logging/logger.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logging\n\nimport (\n\t\"io\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// Logger handles the process logging.\ntype Logger func(*backend_types.Step, io.ReadCloser) error\n"
  },
  {
    "path": "pipeline/runtime/helpers_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage runtime\n\nimport (\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n\ttracer_mocks \"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks\"\n)\n\n// newTestTracer creates a MockTracer that accepts any number of Trace calls.\nfunc newTestTracer(t *testing.T) *tracer_mocks.MockTracer {\n\tt.Helper()\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Return(nil).Maybe()\n\treturn tracer\n}\n\n// newTestLogger creates a noop logger.\nfunc newTestLogger(t *testing.T) logging.Logger {\n\treturn func(_ *types.Step, rc io.ReadCloser) error {\n\t\t_, _ = io.Copy(io.Discard, rc)\n\t\treturn rc.Close()\n\t}\n}\n\n// getTracerStates extracts all state.State values passed to Trace() calls\n// on a mockery-generated MockTracer. Thread-safe because mock.Mock.Calls\n// is append-only and we only read after the workflow completes.\nfunc getTracerStates(tracer *tracer_mocks.MockTracer) []state.State {\n\t// for systems under load we wait for tracer to make it's calls\n\ttime.Sleep(120 * time.Microsecond)\n\n\tvar states []state.State\n\tfor _, call := range tracer.Calls {\n\t\tif call.Method == \"Trace\" {\n\t\t\ts, _ := call.Arguments.Get(0).(*state.State)\n\t\t\tstates = append(states, *s)\n\t\t}\n\t}\n\treturn states\n}\n\n// indexOfTrace returns the first index where predicate matches, or -1.\nfunc indexOfTrace(traces []state.State, match func(s state.State) bool) int {\n\tfor i := range traces {\n\t\tif match(traces[i]) {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n"
  },
  {
    "path": "pipeline/runtime/option.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing\"\n)\n\n// Option configures a Runtime.\ntype Option func(*Runtime)\n\n// WithLogger sets the function used to stream step logs.\nfunc WithLogger(logger logging.Logger) Option {\n\treturn func(r *Runtime) {\n\t\tr.logger = logger\n\t}\n}\n\n// WithTracer sets the tracer used to report step state changes.\nfunc WithTracer(tracer tracing.Tracer) Option {\n\treturn func(r *Runtime) {\n\t\tr.tracer = tracer\n\t}\n}\n\n// WithContext sets the workflow execution context.\nfunc WithContext(ctx context.Context) Option {\n\treturn func(r *Runtime) {\n\t\tr.ctx = ctx\n\t}\n}\n\n// WithDescription sets the descriptive key-value pairs attached to every log line.\nfunc WithDescription(desc map[string]string) Option {\n\treturn func(r *Runtime) {\n\t\tr.description = desc\n\t}\n}\n\n// WithTaskUUID sets a specific task UUID instead of the auto-generated one.\nfunc WithTaskUUID(uuid string) Option {\n\treturn func(r *Runtime) {\n\t\tr.taskUUID = uuid\n\t}\n}\n"
  },
  {
    "path": "pipeline/runtime/runtime.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\t\"github.com/oklog/ulid/v2\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// Runtime represents a workflow state executed by a specific backend.\n// Each workflow gets its own Runtime instance.\ntype Runtime struct {\n\t// err holds the first error that occurred in the workflow.\n\terr utils.Protected[error]\n\n\tspec    *backend_types.Config\n\tengine  backend_types.Backend\n\tstarted int64\n\n\t// ctx is the context for the current workflow execution.\n\t// All normal (non-cleanup) step operations must use this context.\n\t// Cleanup operations should use the runnerCtx passed to Run().\n\tctx context.Context\n\n\ttracer tracing.Tracer\n\tlogger logging.Logger\n\n\tuploadWait sync.WaitGroup\n\n\ttaskUUID    string\n\tdescription map[string]string\n}\n\n// New returns a new Runtime for the given workflow spec and options.\nfunc New(spec *backend_types.Config, backend backend_types.Backend, opts ...Option) *Runtime {\n\tr := new(Runtime)\n\tr.err = utils.NewProtected[error](nil)\n\tr.description = map[string]string{}\n\tr.spec = spec\n\tr.engine = backend\n\tr.ctx = context.Background()\n\tr.taskUUID = ulid.Make().String()\n\tr.tracer = tracing.NoOpTracer\n\tfor _, opt := range opts {\n\t\topt(r)\n\t}\n\treturn r\n}\n\n// makeLogger returns a logger enriched with all runtime description fields.\nfunc (r *Runtime) makeLogger() zerolog.Logger {\n\tlogCtx := log.With()\n\tfor key, val := range r.description {\n\t\tlogCtx = logCtx.Str(key, val)\n\t}\n\treturn logCtx.Logger()\n}\n"
  },
  {
    "path": "pipeline/runtime/runtime_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n\ttracer_mocks \"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks\"\n)\n\n//\n// Step builder helpers.\n//\n\nfunc cmdStep(name string, opts ...func(*backend_types.Step)) *backend_types.Step {\n\ts := &backend_types.Step{\n\t\tName:        name,\n\t\tUUID:        name + \"-uuid\",\n\t\tType:        backend_types.StepTypeCommands,\n\t\tOnSuccess:   true,\n\t\tOnFailure:   false,\n\t\tEnvironment: map[string]string{},\n\t\tCommands:    []string{\"echo \" + name},\n\t}\n\tfor _, o := range opts {\n\t\to(s)\n\t}\n\treturn s\n}\n\nfunc withExitCode(code int) func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Environment[dummy.EnvKeyStepExitCode] = fmt.Sprintf(\"%d\", code)\n\t}\n}\n\nfunc withIgnoreFailure() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) { s.Failure = string(metadata.FailureIgnore) }\n}\n\nfunc withOnFailure() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) { s.OnSuccess = false; s.OnFailure = true }\n}\n\nfunc withDetached() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Detached = true\n\t\ts.Environment[dummy.EnvKeyStepSleep] = \"100ms\"\n\t}\n}\n\n// withUnboundedDetached models a detached step that runs until the workflow tears it down.\nfunc withUnboundedDetached() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Type = backend_types.StepTypeService\n\t\ts.Detached = true\n\t\ts.Environment[dummy.EnvKeyStepSleep] = \"3m\"\n\t}\n}\n\nfunc withService() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Type = backend_types.StepTypeService\n\t\ts.Detached = true\n\t\ts.Environment[dummy.EnvKeyStepSleep] = \"100ms\"\n\t}\n}\n\n// withUnboundedService models a real-world service that runs until the workflow tears it down.\nfunc withUnboundedService() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Type = backend_types.StepTypeService\n\t\ts.Detached = true\n\t\ts.Environment[dummy.EnvKeyStepSleep] = \"3m\"\n\t}\n}\n\nfunc withPlugin() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Type = backend_types.StepTypePlugin\n\t\ts.Environment[dummy.EnvKeyStepType] = \"plugin\"\n\t}\n}\n\nfunc withOOM() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Environment[dummy.EnvKeyStepOOMKilled] = \"true\"\n\t\ts.Environment[dummy.EnvKeyStepExitCode] = \"137\"\n\t}\n}\n\nfunc withStartFail() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Environment[dummy.EnvKeyStepStartFail] = \"true\"\n\t}\n}\n\nfunc withSleep(d string) func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) {\n\t\ts.Environment[dummy.EnvKeyStepSleep] = d\n\t}\n}\n\n//\n// Trace assertion helpers.\n//\n\nfunc findFirstTraceByName(traces []state.State, name string) *state.State {\n\tfor i := range traces {\n\t\tif traces[i].CurrStep != nil && traces[i].CurrStep.Name == name {\n\t\t\treturn &traces[i]\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc findLastTraceByName(traces []state.State, name string) *state.State {\n\tfor i := len(traces) - 1; i >= 0; i-- {\n\t\tif traces[i].CurrStep != nil && traces[i].CurrStep.Name == name {\n\t\t\treturn &traces[i]\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc findStartedTrace(traces []state.State, name string) *state.State {\n\tfor i := range traces {\n\t\tif traces[i].CurrStep != nil && traces[i].CurrStep.Name == name && !traces[i].CurrStepState.Exited {\n\t\t\treturn &traces[i]\n\t\t}\n\t}\n\treturn nil\n}\n\n//\n// Realistic workflow simulations.\n//\n\nfunc TestWorkflowCloneBuildDeploy(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"clone\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n\n\ttraces := getTracerStates(tracer)\n\tassert.Len(t, traces, 6)\n\tfor i := 0; i < 6; i += 2 {\n\t\tassert.False(t, traces[i].CurrStepState.Exited, \"trace %d should be step-started\", i)\n\t\tassert.True(t, traces[i+1].CurrStepState.Exited, \"trace %d should be step-completed\", i+1)\n\t\tassert.Equal(t, 0, traces[i+1].CurrStepState.ExitCode)\n\t}\n\n\tfor _, name := range []string{\"clone\", \"build\", \"deploy\"} {\n\t\tlast := findLastTraceByName(traces, name)\n\t\trequire.NotNil(t, last, \"%s should have a final trace\", name)\n\t\tassert.True(t, last.CurrStepState.Exited, \"%s last trace should be exited\", name)\n\t\tassert.Equal(t, 0, last.CurrStepState.ExitCode, \"%s should exit with code 0\", name)\n\t\tassert.False(t, last.CurrStepState.OOMKilled, \"%s should not be OOM killed\", name)\n\t}\n}\n\nfunc TestWorkflowWithServiceStep(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"db\", withService()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"test\", withSleep(\"250ms\"))}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\trequire.NoError(t, r.Run(t.Context()))\n\ttraces := getTracerStates(tracer)\n\n\t// Each step should emit exactly one \"started\" and one \"exited\" trace:\n\t// db (service/detached), build, test — 3 * 2 = 6 traces total.\n\trequire.Len(t, traces, 6)\n\n\t// Per-step invariants: started trace is the zero state, exited trace is\n\t// Exited=true with a monotonic Started timestamp.\n\tfor _, name := range []string{\"db\", \"build\", \"test\"} {\n\t\tstarted := findFirstTraceByName(traces, name)\n\t\trequire.NotNil(t, started, \"%s should have a started trace\", name)\n\t\tassert.EqualValues(t, backend_types.State{}, started.CurrStepState,\n\t\t\t\"%s started trace should be zero-valued\", name)\n\n\t\tlast := findLastTraceByName(traces, name)\n\t\trequire.NotNil(t, last, \"%s should have an exited trace\", name)\n\t\tassert.True(t, last.CurrStepState.Exited, \"%s should be exited\", name)\n\t\tassert.Equal(t, 0, last.CurrStepState.ExitCode, \"%s should exit 0\", name)\n\t\tassert.Greater(t, last.CurrStepState.Started, int64(0),\n\t\t\t\"%s should have a non-zero Started timestamp\", name)\n\t}\n\n\t// Per-step ordering: started trace precedes exited trace for the same step.\n\tfor _, name := range []string{\"db\", \"build\", \"test\"} {\n\t\tstartedIdx := indexOfTrace(traces, func(s state.State) bool {\n\t\t\treturn s.CurrStep != nil && s.CurrStep.Name == name && !s.CurrStepState.Exited\n\t\t})\n\t\texitedIdx := indexOfTrace(traces, func(s state.State) bool {\n\t\t\treturn s.CurrStep != nil && s.CurrStep.Name == name && s.CurrStepState.Exited\n\t\t})\n\t\tassert.Less(t, startedIdx, exitedIdx, \"%s started must precede %s exited\", name, name)\n\t}\n\n\t// The contract of a service/detached step: it does not block the next\n\t// stage. Verify that stage 2's `test` step started before db (in stage 1)\n\t// reported its exit — i.e. test was running in parallel with db, not\n\t// queued behind it.\n\tdbExitIdx := indexOfTrace(traces, func(s state.State) bool {\n\t\treturn s == *findLastTraceByName(traces, \"db\")\n\t})\n\ttestStartedIdx := indexOfTrace(traces, func(s state.State) bool {\n\t\treturn s == *findFirstTraceByName(traces, \"test\")\n\t})\n\tassert.Less(t, testStartedIdx, dbExitIdx,\n\t\t\"test (next stage) must start before db (service) exits — otherwise db blocked stage 2\")\n\n\t// Runtime-injected env vars should be present on the test step's exit trace.\n\ttestExit := findLastTraceByName(traces, \"test\")\n\trequire.NotNil(t, testExit)\n\tassert.NotEmpty(t, testExit.CurrStep.Environment[\"CI_PIPELINE_STARTED\"])\n\tassert.NotEmpty(t, testExit.CurrStep.Environment[\"CI_STEP_STARTED\"])\n\tassert.Greater(t, testExit.Workflow.Started, int64(0))\n\n\t// Strip runtime-injected env for a structural comparison of the step itself.\n\tdelete(testExit.CurrStep.Environment, \"CI_STEP_STARTED\")\n\tdelete(testExit.CurrStep.Environment, dummy.EnvKeyStepSleep)\n\tassert.EqualValues(t, state.State{\n\t\tWorkflow: state.Workflow{Started: testExit.Workflow.Started},\n\t\tCurrStep: &backend_types.Step{\n\t\t\tName:      \"test\",\n\t\t\tUUID:      \"test-uuid\",\n\t\t\tType:      \"commands\",\n\t\t\tOnSuccess: true,\n\t\t\tEnvironment: map[string]string{\n\t\t\t\t\"CI_PIPELINE_STARTED\": fmt.Sprintf(\"%d\", r.started),\n\t\t\t\t\"CI_PIPELINE_STATUS\":  \"success\",\n\t\t\t\t\"CI_STEP_NAME\":        \"test\",\n\t\t\t\t\"CI_STEP_TYPE\":        \"commands\",\n\t\t\t},\n\t\t\tCommands: []string{\"echo test\"},\n\t\t},\n\t\tCurrStepState: backend_types.State{\n\t\t\tStarted: testExit.CurrStepState.Started,\n\t\t\tExited:  true,\n\t\t},\n\t}, *testExit)\n}\n\nfunc TestWorkflowDetachedStepDoesNotBlockWorkflow(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"background-worker\", withDetached()),\n\t\t\t\t\tcmdStep(\"main-build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n}\n\nfunc TestWorkflowBuildFailSkipsSubsequentStages(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"clone\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(1))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tvar exitErr *pipeline_errors.ExitError\n\trequire.True(t, errors.As(err, &exitErr))\n\tassert.Equal(t, 1, exitErr.Code)\n\n\ttraces := getTracerStates(tracer)\n\n\tbuildTrace := findLastTraceByName(traces, \"build\")\n\trequire.NotNil(t, buildTrace, \"build step should fail\")\n\tassert.EqualValues(t, 1, buildTrace.CurrStepState.ExitCode)\n\tassert.True(t, buildTrace.CurrStepState.Exited, \"build should have started\")\n\n\tbuildTrace = findLastTraceByName(traces, \"build\")\n\trequire.NotNil(t, buildTrace, \"build step should fail\")\n\tassert.EqualValues(t, 1, buildTrace.CurrStepState.ExitCode)\n\n\tdeployTrace := findLastTraceByName(traces, \"deploy\")\n\trequire.NotNil(t, deployTrace, \"deploy step should still be traced\")\n\tassert.True(t, deployTrace.CurrStepState.Skipped)\n}\n\nfunc TestWorkflowOnFailureStepRuns(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(2))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"notify-failure\", withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\ttraces := getTracerStates(tracer)\n\n\tassert.Error(t, err)\n\tassert.NotNil(t, findStartedTrace(traces, \"notify-failure\"), \"OnFailure step should have started\")\n\n\tlast := findLastTraceByName(traces, \"notify-failure\")\n\trequire.NotNil(t, last)\n\tassert.Greater(t, last.CurrStepState.Started, int64(0), \"step should have started\")\n\tassert.EqualValues(t, backend_types.State{Started: last.CurrStepState.Started, Exited: true}, last.CurrStepState)\n}\n\nfunc TestWorkflowOnFailureStepSkippedOnSuccess(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"cleanup-on-fail\", withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\trequire.NoError(t, r.Run(t.Context()))\n\n\ttraces := getTracerStates(tracer)\n\n\tfirstCleanupTrace := findFirstTraceByName(traces, \"cleanup-on-fail\")\n\tlastCleanupTrace := findLastTraceByName(traces, \"cleanup-on-fail\")\n\tassert.Equal(t, firstCleanupTrace, lastCleanupTrace, \"we expect on skipped steps to only have one trace\")\n\tassert.True(t, lastCleanupTrace.CurrStepState.Skipped, \"cleanup-on-fail should be skipped after no failure happened\")\n}\n\nfunc TestWorkflowFailureIgnore(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"lint\", withExitCode(1), withIgnoreFailure()),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()), \"pipeline should succeed when failing step has failure=ignore\")\n\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"build\"), \"build step should run after ignored failure\")\n\n\tlast := findLastTraceByName(getTracerStates(tracer), \"build\")\n\trequire.NotNil(t, last)\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.Equal(t, 0, last.CurrStepState.ExitCode)\n}\n\nfunc TestWorkflowFailureIgnoreDoesNotSetWorkflowError(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"flaky-test\", withExitCode(1), withIgnoreFailure()),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n\n\ttraces := getTracerStates(tracer)\n\tfirstDeployTrace := findFirstTraceByName(traces, \"deploy\")\n\tlastDeployTrace := findLastTraceByName(traces, \"deploy\")\n\tassert.NotEqualValues(t, firstDeployTrace, lastDeployTrace, \"we expect two traces\")\n\tassert.False(t, lastDeployTrace.CurrStepState.Skipped, \"deploy should not be skipped after failure=ignore step\")\n}\n\nfunc TestWorkflowPluginStep(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"clone\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"publish\", withPlugin())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n\n\tlastPluginTrace := findLastTraceByName(getTracerStates(tracer), \"publish\")\n\tif assert.NotNil(t, lastPluginTrace) {\n\t\tdelete(lastPluginTrace.CurrStep.Environment, \"CI_PIPELINE_STARTED\")\n\t\tdelete(lastPluginTrace.CurrStep.Environment, \"CI_STEP_STARTED\")\n\n\t\tassert.EqualValues(t, map[string]string{\n\t\t\t\"CI_PIPELINE_STATUS\":             \"success\",\n\t\t\t\"CI_STEP_NAME\":                   \"publish\",\n\t\t\t\"CI_STEP_TYPE\":                   \"plugin\",\n\t\t\t\"DRONE_BUILD_STATUS\":             \"success\",\n\t\t\t\"DRONE_REPO_SCM\":                 \"git\",\n\t\t\t\"EXPECT_TYPE\":                    \"plugin\",\n\t\t\t\"PULLREQUEST_DRONE_PULL_REQUEST\": \"0\",\n\t\t}, lastPluginTrace.CurrStep.Environment)\n\t}\n}\n\nfunc TestWorkflowOOMKilledStep(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withOOM())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tvar oomErr *pipeline_errors.OomError\n\tassert.True(t, errors.As(err, &oomErr))\n\n\tlast := findLastTraceByName(getTracerStates(tracer), \"build\")\n\trequire.NotNil(t, last)\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.True(t, last.CurrStepState.OOMKilled)\n\tassert.Equal(t, 137, last.CurrStepState.ExitCode)\n}\n\nfunc TestWorkflowParallelStepsInStage(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"clone\")}},\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"test-unit\"),\n\t\t\t\t\tcmdStep(\"test-integration\"),\n\t\t\t\t\tcmdStep(\"test-e2e\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n\n\tassert.Len(t, getTracerStates(tracer), 10)\n}\n\nfunc TestWorkflowParallelStepOneFailsOthersComplete(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"test-fast\"),\n\t\t\t\t\tcmdStep(\"test-slow\", withExitCode(1)),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.Error(t, r.Run(t.Context()))\n\n\tassert.Len(t, getTracerStates(tracer), 4, \"both parallel steps should complete and be traced\")\n\n\tlastFast := findLastTraceByName(getTracerStates(tracer), \"test-fast\")\n\trequire.NotNil(t, lastFast)\n\tassert.True(t, lastFast.CurrStepState.Exited)\n\tassert.Equal(t, 0, lastFast.CurrStepState.ExitCode, \"test-fast should succeed\")\n\n\tlastSlow := findLastTraceByName(getTracerStates(tracer), \"test-slow\")\n\trequire.NotNil(t, lastSlow)\n\tassert.True(t, lastSlow.CurrStepState.Exited)\n\tassert.Equal(t, 1, lastSlow.CurrStepState.ExitCode, \"test-slow should fail with code 1\")\n}\n\nfunc TestWorkflowStepStartFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\", withStartFail())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.Error(t, r.Run(t.Context()))\n\n\tdeployTrace := findFirstTraceByName(getTracerStates(tracer), \"build\")\n\trequire.NotNil(t, deployTrace)\n\tassert.EqualValues(t, backend_types.State{}, deployTrace.CurrStepState)\n}\n\nfunc TestWorkflowContextCancelDuringExecution(t *testing.T) {\n\tt.Parallel()\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tvar stageCount int\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Run(func(args mock.Arguments) {\n\t\ts, _ := args.Get(0).(*state.State)\n\t\tif s.CurrStepState.Exited && !s.CurrStepState.Skipped {\n\t\t\tstageCount++\n\t\t\tif stageCount >= 1 {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t}\n\t}).Return(nil).Maybe()\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n}\n\nfunc TestWorkflowSetupFailure(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithTaskUUID(dummy.WorkflowSetupFailUUID),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"expected fail to setup workflow\")\n}\n\nfunc TestWorkflowServiceWithParallelBuildAndOnFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"redis\", withService()),\n\t\t\t\t\tcmdStep(\"clone\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t\tcmdStep(\"lint\", withExitCode(1)),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"notify\", withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.Error(t, r.Run(t.Context()))\n\n\ttraces := getTracerStates(tracer)\n\n\tassert.NotNil(t, findStartedTrace(traces, \"notify\"), \"notify (OnFailure) should have started\")\n\tnotifyTrace := findLastTraceByName(traces, \"notify\")\n\trequire.NotNil(t, notifyTrace)\n\tassert.True(t, notifyTrace.CurrStepState.Exited, \"notify should exited\")\n\tassert.EqualValues(t, 0, notifyTrace.CurrStepState.ExitCode, \"notify should be successful\")\n\n\tlastBuild := findLastTraceByName(traces, \"lint\")\n\trequire.NotNil(t, lastBuild)\n\tassert.True(t, lastBuild.CurrStepState.Exited)\n\tassert.Equal(t, 1, lastBuild.CurrStepState.ExitCode, \"lint should have failed\")\n\n\tdeployTrace := findFirstTraceByName(traces, \"deploy\")\n\trequire.NotNil(t, deployTrace)\n\tassert.True(t, deployTrace.CurrStepState.Skipped, \"deploy should be skipped after lint failure\")\n}\n\nfunc TestWorkflowIgnoredFailureFollowedByOnFailureStep(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"lint\", withExitCode(1), withIgnoreFailure()),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"error-notify\", withOnFailure())}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\ttraces := getTracerStates(tracer)\n\n\tnotifyTrace := findFirstTraceByName(traces, \"error-notify\")\n\trequire.NotNil(t, notifyTrace)\n\tassert.True(t, notifyTrace.CurrStepState.Skipped, \"OnFailure step should be skipped when prior failure was ignored\")\n\n\tassert.NotNil(t, findStartedTrace(traces, \"build\"), \"build should run after ignored failure\")\n}\n\nfunc TestWorkflowEmptyStages(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{Stages: []*backend_types.Stage{}},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\tassert.Empty(t, getTracerStates(tracer))\n}\n\n//\n// outcome: failure\n//\n\nfunc TestPluginStepFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"publish\", withPlugin(), withExitCode(1))}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tvar exitErr *pipeline_errors.ExitError\n\trequire.True(t, errors.As(err, &exitErr))\n\tassert.Equal(t, 1, exitErr.Code)\n\n\tlast := findLastTraceByName(getTracerStates(tracer), \"publish\")\n\trequire.NotNil(t, last)\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.Equal(t, 1, last.CurrStepState.ExitCode)\n}\n\nfunc TestDetachedStepFailure(t *testing.T) {\n\tt.Parallel()\n\t// A detached step that exits non-zero; since it is detached the runtime\n\t// only waits for setup, so the pipeline itself should still succeed.\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"background\", withDetached(), withExitCode(1)),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\t// Detached step errors are not propagated to the pipeline result.\n\tassert.NoError(t, r.Run(t.Context()))\n}\n\nfunc TestServiceStepFailure(t *testing.T) {\n\tt.Parallel()\n\t// A service that exits non-zero; same semantics as detached — the pipeline\n\t// should still complete because services are fire-and-forget from the\n\t// runtime's perspective.\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"db\", withService(), withExitCode(1)),\n\t\t\t\t\tcmdStep(\"test\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()))\n}\n\n//\n// outcome: start failure\n//\n\nfunc TestPluginStepStartFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"publish\", withPlugin(), withStartFail())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n}\n\nfunc TestDetachedStepStartFailure(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"background\", withDetached(), withStartFail()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\t// A detached step that fails to start should surface the error, since the\n\t// runtime waits for setup to complete before continuing.\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n}\n\nfunc TestServiceStepStartFailure(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"db\", withService(), withStartFail()),\n\t\t\t\t\tcmdStep(\"test\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n}\n\n//\n// Run condition: OnFailure for plugin / detached / service.\n//\n\nfunc TestPluginOnFailureStepRuns(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(1))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"notify\", withPlugin(), withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"notify\"),\n\t\t\"plugin OnFailure step should have started\")\n\n\tlast := findLastTraceByName(getTracerStates(tracer), \"notify\")\n\trequire.NotNil(t, last)\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.Equal(t, 0, last.CurrStepState.ExitCode)\n}\n\nfunc TestPluginOnFailureStepSkippedOnSuccess(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"notify\", withPlugin(), withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\ttrace := findLastTraceByName(getTracerStates(tracer), \"notify\")\n\ttrace.CurrStepState.Started = 0\n\tassert.EqualValues(t, backend_types.State{Skipped: true}, trace.CurrStepState,\n\t\t\"plugin OnFailure step should not run when pipeline succeeds\")\n}\n\nfunc TestDetachedOnFailureStepRuns(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(1))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"cleanup\", withDetached(), withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"cleanup\"),\n\t\t\"detached OnFailure step should have started\")\n}\n\nfunc TestDetachedOnFailureStepSkippedOnSuccess(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"cleanup\", withDetached(), withOnFailure())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\ttrace := findLastTraceByName(getTracerStates(tracer), \"cleanup\")\n\ttrace.CurrStepState.Started = 0\n\tassert.EqualValues(t, backend_types.State{Skipped: true}, trace.CurrStepState,\n\t\t\"detached OnFailure step should not run when pipeline succeeds\")\n}\n\n//\n// Run condition: OnSuccess=true + OnFailure=true (always-run).\n//\n\nfunc withAlwaysRun() func(*backend_types.Step) {\n\treturn func(s *backend_types.Step) { s.OnSuccess = true; s.OnFailure = true }\n}\n\nfunc TestAlwaysRunStepRunsOnSuccess(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"report\", withAlwaysRun())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\tlast := findLastTraceByName(getTracerStates(tracer), \"report\")\n\trequire.NotNil(t, last, \"always-run step should be traced\")\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.Equal(t, 0, last.CurrStepState.ExitCode)\n}\n\nfunc TestAlwaysRunStepRunsOnFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(1))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"report\", withAlwaysRun())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"report\"),\n\t\t\"always-run step should start even when pipeline is failing\")\n\n\tlast := findLastTraceByName(getTracerStates(tracer), \"report\")\n\trequire.NotNil(t, last)\n\tassert.True(t, last.CurrStepState.Exited)\n\tassert.Equal(t, 0, last.CurrStepState.ExitCode)\n}\n\nfunc TestAlwaysRunPluginRunsOnFailure(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\", withExitCode(1))}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"report\", withPlugin(), withAlwaysRun())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"report\"),\n\t\t\"always-run plugin step should start even when pipeline is failing\")\n}\n\n//\n// Failure handling: failure=ignore for plugin.\n//\n\nfunc TestPluginFailureIgnore(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"lint\", withPlugin(), withExitCode(1), withIgnoreFailure()),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err, \"pipeline should succeed when failing plugin has failure=ignore\")\n\tassert.NotNil(t, findStartedTrace(getTracerStates(tracer), \"build\"),\n\t\t\"build step should run after ignored plugin failure\")\n}\n\nfunc TestDetachedFailureIgnore(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"watcher\", withDetached(), withExitCode(1), withIgnoreFailure()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n}\n\n//\n// Cancellation.\n//\n\nfunc TestWorkflowContextCancelWithPluginStep(t *testing.T) {\n\tt.Parallel()\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tvar stageCount int\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Run(func(args mock.Arguments) {\n\t\ts, _ := args.Get(0).(*state.State)\n\t\tif s.CurrStepState.Exited {\n\t\t\tstageCount++\n\t\t\tif stageCount >= 1 {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t}\n\t}).Return(nil).Maybe()\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"build\")}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"publish\", withPlugin())}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n}\n\nfunc TestWorkflowContextCancelWithDetachedStep(t *testing.T) {\n\tt.Parallel()\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tvar stageCount int\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Run(func(args mock.Arguments) {\n\t\ts, _ := args.Get(0).(*state.State)\n\t\tif s.CurrStepState.Exited {\n\t\t\tstageCount++\n\t\t\tif stageCount >= 1 {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t}\n\t}).Return(nil).Maybe()\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"background\", withDetached()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n}\n\nfunc TestWorkflowContextCancelWithServiceStep(t *testing.T) {\n\tt.Parallel()\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tvar stageCount int\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Run(func(args mock.Arguments) {\n\t\ts, _ := args.Get(0).(*state.State)\n\t\tif s.CurrStepState.Exited {\n\t\t\tstageCount++\n\t\t\tif stageCount >= 1 {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t}\n\t}).Return(nil).Maybe()\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"db\", withService()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n}\n\n// TestWorkflowCancelDuringStepSleep verifies that canceling the workflow context\n// while a step is sleeping (via SLEEP env) causes the runtime to return ErrCancel\n// promptly — without waiting the full sleep duration — and that subsequent stages\n// are never executed.\n//\n// The tracer callback cancels the context the moment the first stage (\"prepare\")\n// completes. The \"slow\" step uses a short sleep so that even if WaitStep enters\n// the sleep select, the context cancellation unblocks it quickly.\n//\n// Note: we do not assert on the slow step's exit code here because Run() may\n// return (via ctx.Done()) before the stage goroutine's WaitStep completes,\n// causing DestroyWorkflow to clean up state that WaitStep still needs. The\n// exit-code-130 behavior of a canceled sleep is verified at the backend unit\n// level in TestWaitStepCanceledBySleep.\nfunc TestWorkflowCancelDuringStepSleep(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithCancelCause(t.Context())\n\n\tvar prepareExited int\n\ttracer := tracer_mocks.NewMockTracer(t)\n\ttracer.On(\"Trace\", mock.Anything).Run(func(args mock.Arguments) {\n\t\ts, _ := args.Get(0).(*state.State)\n\t\tif s == nil || s.CurrStep == nil {\n\t\t\treturn\n\t\t}\n\t\t// Cancel as soon as the first stage (\"prepare\") finishes.\n\t\tif s.CurrStep.Name == \"prepare\" && s.CurrStepState.Exited {\n\t\t\tprepareExited++\n\t\t\tif prepareExited >= 1 {\n\t\t\t\tcancel(nil)\n\t\t\t}\n\t\t}\n\t}).Return(nil).Maybe()\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"prepare\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\t// Short sleep so the test doesn't hang if WaitStep enters the timer.\n\t\t\t\t\tcmdStep(\"slow\", func(s *backend_types.Step) {\n\t\t\t\t\t\ts.Environment[dummy.EnvKeyStepSleep] = \"100ms\"\n\t\t\t\t\t}),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"never-reached\"),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel, \"canceled workflow must return ErrCancel\")\n\n\t// Give the orphaned stage goroutine a moment to finish tracing (best effort).\n\ttime.Sleep(200 * time.Millisecond)\n\n\tassert.Nil(t, findFirstTraceByName(getTracerStates(tracer), \"never-reached\"),\n\t\t\"never-reached must not have been traced\")\n}\n\n// TestWorkflowFailingServiceDoesNotFailWorkflow pins down the intentional design:\n// a service/detached step that fails in the background has its failure logged\n// and traced, but it must NOT propagate to the workflow error. Subsequent\n// stages must still run, and Run() must return nil.\n//\n// This is the explicit contract in runDetachedStep:\n// \"Any error that occurs after setup is logged but not propagated — it cannot\n//\n//\tinfluence the pipeline outcome at that point.\"\nfunc TestWorkflowFailingServiceDoesNotFailWorkflow(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\t// Service runs ~100ms (from withService), then exits non-zero.\n\t\t\t\t\tcmdStep(\"db\", withService(), withExitCode(1)),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\", withSleep(\"120ms\"))}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\t// Contract 1: workflow succeeds even though the service failed.\n\tassert.NoError(t, r.Run(t.Context()),\n\t\t\"service failure must not fail the workflow (detached errors are not propagated)\")\n\n\ttraces := getTracerStates(tracer)\n\n\t// Contract 2: the service's failure IS visible in traces. This is the\n\t// observability guarantee — the failure is logged and recorded even though\n\t// it doesn't kill the workflow.\n\tdbExit := findLastTraceByName(traces, \"db\")\n\trequire.NotNil(t, dbExit, \"db must have an exit trace\")\n\tassert.True(t, dbExit.CurrStepState.Exited, \"db should be marked exited\")\n\tassert.Equal(t, 1, dbExit.CurrStepState.ExitCode, \"db exit code must be preserved in trace\")\n\n\t// Contract 3: deploy must run normally — NOT skipped — because the service\n\t// failure didn't set r.err.\n\tdeployExit := findLastTraceByName(traces, \"deploy\")\n\trequire.NotNil(t, deployExit, \"deploy must be traced\")\n\tassert.False(t, deployExit.CurrStepState.Skipped, \"deploy must run when only a service failed\")\n\tassert.True(t, deployExit.CurrStepState.Exited, \"deploy should complete normally\")\n\tassert.Equal(t, 0, deployExit.CurrStepState.ExitCode)\n\n\t// Contract 4: uploadWait at the end of Run() guarantees the detached trace\n\t// has been emitted BEFORE Run() returns. This is non-timing-dependent:\n\t// if Run() returned, the exit trace for every detached step must exist.\n\t// This is what the uploadWait plumbing in this PR is actually for.\n\tassert.NotNil(t, findLastTraceByName(traces, \"db\"),\n\t\t\"detached step exit trace must be emitted before Run() returns (uploadWait contract)\")\n}\n\n// TestWorkflowFailingDetachedStepDoesNotFailWorkflow is the non-service\n// counterpart: Detached=true, Type=commands (a background worker). Same\n// contract — failures don't propagate.\nfunc TestWorkflowFailingDetachedStepDoesNotFailWorkflow(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\t// Detached (non-service) worker, ~100ms (from withDetached), exits code 2.\n\t\t\t\t\tcmdStep(\"background-worker\", withDetached(), withExitCode(2)),\n\t\t\t\t\tcmdStep(\"main-build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"deploy\", withSleep(\"120ms\"))}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.NoError(t, r.Run(t.Context()),\n\t\t\"detached worker failure must not fail the workflow\")\n\n\ttraces := getTracerStates(tracer)\n\n\tworkerExit := findLastTraceByName(traces, \"background-worker\")\n\trequire.NotNil(t, workerExit, \"background-worker must have an exit trace\")\n\tassert.True(t, workerExit.CurrStepState.Exited)\n\tassert.Equal(t, 2, workerExit.CurrStepState.ExitCode,\n\t\t\"exit code from detached step must be preserved in trace\")\n\n\tdeployExit := findLastTraceByName(traces, \"deploy\")\n\trequire.NotNil(t, deployExit, \"deploy must be traced\")\n\tassert.False(t, deployExit.CurrStepState.Skipped,\n\t\t\"deploy must run when only a detached worker failed\")\n\tassert.True(t, deployExit.CurrStepState.Exited)\n\tassert.Equal(t, 0, deployExit.CurrStepState.ExitCode)\n}\n\n// TestWorkflowUnboundedServiceDoesNotHang asserts that when all normal steps\n// have finished, a long-running service does NOT keep the workflow blocked\n// forever. The runtime must tear the service down on its own (the whole point\n// of declaring a step as a service is that it runs alongside the build, not\n// that the build waits for it).\n//\n// Regression for https://github.com/woodpecker-ci/woodpecker/commit/4dd3be7f96\n// which moved the upload waitgroup from per-upload (logger/tracer) to\n// per-detached-goroutine. The detached goroutine wraps WaitStep, which on\n// services blocks until the workflow context is canceled — so the workflow\n// hangs waiting for its own service to exit.\nfunc TestWorkflowUnboundedServiceDoesNotHang(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"db\", withUnboundedService()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"test\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\t// Use a deadline well below the dummy backend's testServiceTimeout (1s) so\n\t// that if this test \"passes\" it's because the runtime tore the service down,\n\t// not because dummy's safety timeout fired.\n\tdone := make(chan error, 1)\n\tgo func() { done <- r.Run(t.Context()) }()\n\n\tselect {\n\tcase err := <-done:\n\t\tassert.NoError(t, err)\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"workflow hung: runtime did not tear down the unbounded service after normal steps finished\")\n\t}\n}\n\n// TestWorkflowUnboundedDetachedDoesNotHang is the same as the service test but\n// for plain detached steps (Detached=true, Type=commands). The bug is the same\n// — a long-running detached step also pins the upload waitgroup.\nfunc TestWorkflowUnboundedDetachedDoesNotHang(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{\n\t\t\t\t\tcmdStep(\"background-worker\", withUnboundedDetached()),\n\t\t\t\t\tcmdStep(\"build\"),\n\t\t\t\t}},\n\t\t\t\t{Steps: []*backend_types.Step{cmdStep(\"test\")}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tdone := make(chan error, 1)\n\tgo func() { done <- r.Run(t.Context()) }()\n\n\tselect {\n\tcase err := <-done:\n\t\tassert.NoError(t, err)\n\tcase <-time.After(500 * time.Millisecond):\n\t\tt.Fatal(\"workflow hung: runtime did not tear down the unbounded detached step after normal steps finished\")\n\t}\n}\n"
  },
  {
    "path": "pipeline/runtime/shutdown.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"time\"\n)\n\nconst shutdownTimeout = time.Second * 5\n\nvar (\n\tshutdownCtx     context.Context\n\tshutdownCtxLock sync.Mutex\n)\n\n// GetShutdownCtx returns a context that is valid for shutdownTimeout after the\n// first call. It is used as a fallback cleanup context when the runner context\n// is already canceled.\nfunc GetShutdownCtx() context.Context {\n\tshutdownCtxLock.Lock()\n\tdefer shutdownCtxLock.Unlock()\n\tif shutdownCtx == nil {\n\t\tshutdownCtx, _ = context.WithTimeout(context.Background(), shutdownTimeout) //nolint:govet\n\t}\n\treturn shutdownCtx\n}\n"
  },
  {
    "path": "pipeline/runtime/step.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n)\n\n// executeStep is the single entry point called per step from runStage.\n// It checks whether the step should be skipped, emits a \"started\" trace,\n// sets up drone-compat env vars, then hands off to blocking or detached execution.\nfunc (r *Runtime) executeStep(runnerCtx context.Context, step *backend_types.Step) error {\n\tlogger := r.makeLogger()\n\tlogger.Debug().Str(\"step\", step.Name).Msg(\"prepare\")\n\n\tif r.shouldSkipStep(step) {\n\t\t// Trace the skip so the server marks the step as skipped immediately,\n\t\t// rather than leaving it in \"pending\" until workflow Done.\n\t\treturn r.traceStep(&backend_types.State{Skipped: true}, nil, step)\n\t}\n\n\t// Emit a \"step started\" trace before doing any real work.\n\tif err := r.traceStep(nil, nil, step); err != nil {\n\t\treturn err\n\t}\n\n\t// Set runtime specific step environment\n\tif err := r.setStepEnv(step); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Debug().Str(\"step\", step.Name).Msg(\"executing\")\n\n\tif step.Detached {\n\t\treturn r.runDetachedStep(runnerCtx, step)\n\t}\n\treturn r.runBlockingStep(runnerCtx, step)\n}\n\n// shouldSkipStep returns true when the step should not run based on the current\n// pipeline error state and the step's OnSuccess / OnFailure flags.\n// It logs the reason for skipping before returning.\nfunc (r *Runtime) shouldSkipStep(step *backend_types.Step) bool {\n\tlogger := r.makeLogger()\n\tcurrentErr := r.err.Get()\n\n\tif currentErr != nil && !step.OnFailure {\n\t\tlogger.Debug().\n\t\t\tStr(\"step\", step.Name).\n\t\t\tErr(currentErr).\n\t\t\tMsgf(\"skipped due to OnFailure=%t\", step.OnFailure)\n\t\treturn true\n\t}\n\n\tif currentErr == nil && !step.OnSuccess {\n\t\tlogger.Debug().\n\t\t\tStr(\"step\", step.Name).\n\t\t\tMsgf(\"skipped due to OnSuccess=%t\", step.OnSuccess)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// setStepEnv sets runtime specific step environment variables.\n// It also adds the drone plugin compatibility layer.\nfunc (r *Runtime) setStepEnv(step *backend_types.Step) error {\n\tif step.Environment == nil {\n\t\treturn fmt.Errorf(\"step %q (%q) has no environment variables initialized\", step.Name, step.UUID)\n\t}\n\n\t// Add compatibility environment variables for drone-ci plugins.\n\tif step.Type == backend_types.StepTypePlugin {\n\t\tmetadata.SetDroneEnviron(step.Environment)\n\t}\n\n\tif r.err.Get() != nil {\n\t\tstep.Environment[\"CI_PIPELINE_STATUS\"] = \"failure\"\n\t} else {\n\t\tstep.Environment[\"CI_PIPELINE_STATUS\"] = \"success\"\n\t}\n\tstep.Environment[\"CI_PIPELINE_STARTED\"] = strconv.FormatInt(r.started, 10)\n\tstep.Environment[\"CI_STEP_STARTED\"] = strconv.FormatInt(time.Now().Unix(), 10)\n\tstep.Environment[\"CI_STEP_TYPE\"] = string(step.Type)\n\tstep.Environment[\"CI_STEP_NAME\"] = step.Name\n\n\treturn nil\n}\n\n// startStep starts the step container and spawns a goroutine to stream its logs.\n// It returns:\n//   - waitForLogs: must be called before WaitStep — it blocks until the log stream\n//     is fully drained. Some backends (e.g. local) close the log stream when\n//     WaitStep is called, so draining first is required.\n//   - startTime: unix timestamp recorded right after the container started, used\n//     later to fill waitState.Started.\n//\n// If StartStep or TailStep fail, startStep returns a non-nil error and the caller\n// must not call waitForLogs.\nfunc (r *Runtime) startStep(step *backend_types.Step) (func(), int64, error) {\n\tif err := r.engine.StartStep(r.ctx, step, r.taskUUID); err != nil {\n\t\treturn nil, 0, err\n\t}\n\tstartTime := time.Now().Unix()\n\n\trc, err := r.engine.TailStep(r.ctx, step, r.taskUUID)\n\tif err != nil {\n\t\treturn nil, 0, err\n\t}\n\n\tvar wg sync.WaitGroup\n\twg.Go(func() {\n\t\tlogger := r.makeLogger()\n\t\tif err := r.logger(step, rc); err != nil {\n\t\t\tlogger.Error().Err(err).Str(\"step\", step.Name).Msg(\"step log streaming failed\")\n\t\t}\n\t\t_ = rc.Close()\n\t})\n\n\treturn wg.Wait, startTime, nil\n}\n\n// completeStep drains the log stream, waits for the process to exit, destroys\n// the container, and maps exit conditions (OOM kill, non-zero exit code, context\n// cancellation) to typed errors.\n//\n// The runnerCtx is intentionally used for DestroyStep so that container cleanup can\n// still reach the backend even after the workflow context (r.ctx) is canceled.\nfunc (r *Runtime) completeStep(runnerCtx context.Context, step *backend_types.Step, waitForLogs func(), startTime int64) (*backend_types.State, error) {\n\t// Drain the log stream before waiting on the process exit.\n\twaitForLogs()\n\n\twaitState, err := r.engine.WaitStep(r.ctx, step, r.taskUUID) //nolint:contextcheck\n\tif err != nil {\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\tif waitState == nil {\n\t\t\t\twaitState = &backend_types.State{}\n\t\t\t}\n\t\t\twaitState.Error = pipeline_errors.ErrCancel\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Use runnerCtx here: the workflow context may already be canceled but we\n\t// still need to reach the backend to stop/remove the container.\n\tif err := r.engine.DestroyStep(runnerCtx, step, r.taskUUID); err != nil {\n\t\treturn nil, err\n\t}\n\n\twaitState.Started = startTime\n\n\t// Re-check context cancellation: the wait may have raced with cancellation.\n\tif ctxErr := r.ctx.Err(); ctxErr != nil && errors.Is(ctxErr, context.Canceled) {\n\t\twaitState.Error = pipeline_errors.ErrCancel\n\t}\n\n\tif waitState.OOMKilled {\n\t\treturn waitState, &pipeline_errors.OomError{\n\t\t\tUUID: step.UUID,\n\t\t\tCode: waitState.ExitCode,\n\t\t}\n\t}\n\tif waitState.ExitCode != 0 {\n\t\treturn waitState, &pipeline_errors.ExitError{\n\t\t\tUUID: step.UUID,\n\t\t\tCode: waitState.ExitCode,\n\t\t}\n\t}\n\n\treturn waitState, nil\n}\n\n// runBlockingStep starts the step and blocks until it fully completes.\n// The error is traced and returned to runStage, which feeds it into the\n// stage error group.\nfunc (r *Runtime) runBlockingStep(runnerCtx context.Context, step *backend_types.Step) error {\n\tlogger := r.makeLogger()\n\n\twaitForLogs, startTime, err := r.startStep(step)\n\tif err != nil {\n\t\t// The step never ran — trace the start failure and surface it.\n\t\treturn r.traceStep(nil, err, step)\n\t}\n\n\tprocessState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime)\n\tlogger.Debug().Str(\"step\", step.Name).Msg(\"complete\")\n\n\tif errors.Is(err, context.Canceled) {\n\t\terr = pipeline_errors.ErrCancel\n\t}\n\n\terr = r.traceStep(processState, err, step)\n\tif err != nil && metadata.Failure(step.Failure) == metadata.FailureIgnore {\n\t\treturn nil\n\t}\n\treturn err\n}\n\n// runDetachedStep starts the step and returns as soon as the container is running\n// and log streaming is set up. The rest of the step lifecycle runs in the background.\n//\n// Any error that occurs after setup is logged but not propagated — it cannot\n// influence the pipeline outcome at that point.\nfunc (r *Runtime) runDetachedStep(runnerCtx context.Context, step *backend_types.Step) error {\n\twaitForLogs, startTime, err := r.startStep(step)\n\tif err != nil {\n\t\t// Setup failed before the container was running — treat it like a\n\t\t// blocking failure so the pipeline is aware.\n\t\treturn r.traceStep(nil, err, step)\n\t}\n\n\t// Container is up and logging is streaming — hand off to background.\n\tr.uploadWait.Add(1)\n\tgo func() {\n\t\tdefer r.uploadWait.Done()\n\n\t\tlogger := r.makeLogger()\n\n\t\tprocessState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime)\n\t\tlogger.Debug().Str(\"step\", step.Name).Msg(\"complete\")\n\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\terr = pipeline_errors.ErrCancel\n\t\t}\n\t\tif err != nil {\n\t\t\tlogger.Error().Err(err).Str(\"step\", step.Name).Msg(\"detached step failed while running\")\n\t\t}\n\n\t\tif traceErr := r.traceStep(processState, err, step); traceErr != nil {\n\t\t\tlogger.Error().Err(traceErr).Str(\"step\", step.Name).Msg(\"failed to trace detached step result\")\n\t\t}\n\t}()\n\n\treturn nil\n}\n\n// traceStep reports the current state of a step to the tracer.\n//\n//   - processState == nil, err == nil  →  step is being marked as started\n//   - processState == nil, err != nil  →  step failed to start\n//   - processState != nil              →  step has finished (err may or may not be set)\n//\n// Always returns err unchanged so callers can write: return r.traceStep(state, err, step).\nfunc (r *Runtime) traceStep(processState *backend_types.State, err error, step *backend_types.Step) error {\n\ts := new(state.State)\n\ts.Workflow.Started = r.started\n\ts.CurrStep = step\n\ts.Workflow.Error = r.err.Get()\n\n\tswitch {\n\tcase processState == nil && err != nil:\n\t\t// Step failed to start — create an dummy exited process state.\n\t\ts.CurrStepState = backend_types.State{\n\t\t\tError:     err,\n\t\t\tExited:    true,\n\t\t\tOOMKilled: false,\n\t\t}\n\tcase processState != nil:\n\t\ts.CurrStepState = *processState\n\t\t// processState == nil && err == nil: step just started, leave s.CurrStepState zero-valued.\n\t}\n\n\tif traceErr := r.tracer.Trace(s); traceErr != nil {\n\t\tlogger := r.makeLogger()\n\t\tlogger.Error().Err(traceErr).Msg(\"could not trace step state change\")\n\t}\n\n\treturn err\n}\n"
  },
  {
    "path": "pipeline/runtime/step_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging\"\n\ttracer_mocks \"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks\"\n)\n\nconst testWorkflowID = \"WID_test\"\n\n// newDummyRuntime creates a Runtime backed by the dummy engine with a pre-setup\n// workflow so individual step methods can be tested in isolation.\nfunc newDummyRuntime(t *testing.T, tracer *tracer_mocks.MockTracer) *Runtime {\n\tt.Helper()\n\tengine := dummy.New()\n\tr := New(&backend_types.Config{}, engine,\n\t\tWithTracer(tracer),\n\t\tWithTaskUUID(testWorkflowID),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\trequire.NoError(t, engine.SetupWorkflow(t.Context(), nil, testWorkflowID))\n\treturn r\n}\n\nfunc dummyStep(name string) *backend_types.Step {\n\treturn &backend_types.Step{\n\t\tName:        name,\n\t\tUUID:        name + \"-uuid\",\n\t\tType:        backend_types.StepTypeCommands,\n\t\tOnSuccess:   true,\n\t\tOnFailure:   false,\n\t\tEnvironment: map[string]string{},\n\t\tCommands:    []string{\"echo hello\"},\n\t}\n}\n\nfunc TestShouldSkipStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"NoErrorOnSuccessTrue\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := &backend_types.Step{Name: \"s\", OnSuccess: true, OnFailure: false}\n\n\t\tassert.False(t, r.shouldSkipStep(step))\n\t})\n\n\tt.Run(\"NoErrorOnSuccessFalse\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := &backend_types.Step{Name: \"s\", OnSuccess: false, OnFailure: true}\n\n\t\tassert.True(t, r.shouldSkipStep(step))\n\t})\n\n\tt.Run(\"ErrorOnFailureTrue\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tr.err.Set(errors.New(\"previous failure\"))\n\t\tstep := &backend_types.Step{Name: \"s\", OnSuccess: false, OnFailure: true}\n\n\t\tassert.False(t, r.shouldSkipStep(step))\n\t})\n\n\tt.Run(\"ErrorOnFailureFalse\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tr.err.Set(errors.New(\"previous failure\"))\n\t\tstep := &backend_types.Step{Name: \"s\", OnSuccess: true, OnFailure: false}\n\n\t\tassert.True(t, r.shouldSkipStep(step))\n\t})\n}\n\nfunc TestTraceStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"StepStarted\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\n\t\tr := newDummyRuntime(t, tracer)\n\t\tr.started = 1000\n\t\tstep := dummyStep(\"s1\")\n\n\t\terr := r.traceStep(nil, nil, step)\n\n\t\tassert.NoError(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.Equal(t, int64(1000), calls[0].Workflow.Started)\n\t\tassert.Equal(t, step, calls[0].CurrStep)\n\t\tassert.False(t, calls[0].CurrStepState.Exited)\n\t})\n\n\tt.Run(\"StepFailedToStart\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"s1\")\n\t\tstartErr := errors.New(\"image pull failed\")\n\n\t\terr := r.traceStep(nil, startErr, step)\n\n\t\tassert.ErrorIs(t, err, startErr)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.True(t, calls[0].CurrStepState.Exited)\n\t\tassert.Equal(t, startErr, calls[0].CurrStepState.Error)\n\t})\n\n\tt.Run(\"StepFinished\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"s1\")\n\t\tps := &backend_types.State{Exited: true, ExitCode: 0, Started: 42}\n\n\t\terr := r.traceStep(ps, nil, step)\n\n\t\tassert.NoError(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.True(t, calls[0].CurrStepState.Exited)\n\t\tassert.Equal(t, 0, calls[0].CurrStepState.ExitCode)\n\t\tassert.Equal(t, int64(42), calls[0].CurrStepState.Started)\n\t})\n\n\tt.Run(\"StepSkipped\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"s1\")\n\t\tps := &backend_types.State{Exited: true, Skipped: true}\n\n\t\terr := r.traceStep(ps, nil, step)\n\n\t\tassert.NoError(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.True(t, calls[0].CurrStepState.Skipped)\n\t\tassert.True(t, calls[0].CurrStepState.Exited)\n\t})\n\n\tt.Run(\"PipelineErrorPropagated\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\n\t\tr := newDummyRuntime(t, tracer)\n\t\tr.err.Set(errors.New(\"earlier failure\"))\n\n\t\t_ = r.traceStep(nil, nil, dummyStep(\"s1\"))\n\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.EqualError(t, calls[0].Workflow.Error, \"earlier failure\")\n\t})\n}\n\n// The startStep uses dummy for success + start/tail failures and mockery mock for logger test.\nfunc TestStartStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Success\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := dummyStep(\"s1\")\n\n\t\twaitForLogs, startTime, err := r.startStep(step)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, waitForLogs)\n\t\tassert.Greater(t, startTime, int64(0))\n\t\twaitForLogs()\n\t})\n\n\tt.Run(\"StartStepError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := dummyStep(\"fail\")\n\t\tstep.Environment[dummy.EnvKeyStepStartFail] = \"true\"\n\n\t\t_, _, err := r.startStep(step)\n\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"TailStepError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := dummyStep(\"tail-fail\")\n\t\tstep.Environment[dummy.EnvKeyStepTailFail] = \"true\"\n\t\tr.logger = logging.Logger(func(_ *backend_types.Step, _ io.ReadCloser) error { return nil })\n\n\t\t_, _, err := r.startStep(step)\n\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"WithLogger\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tvar logCalled int32\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"StartStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tengine.On(\"TailStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(io.NopCloser(strings.NewReader(\"log line\")), nil)\n\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)),\n\t\t\tWithLogger(logging.Logger(func(_ *backend_types.Step, rc io.ReadCloser) error {\n\t\t\t\tatomic.AddInt32(&logCalled, 1)\n\t\t\t\t_, _ = io.ReadAll(rc)\n\t\t\t\treturn nil\n\t\t\t})))\n\t\tstep := dummyStep(\"s1\")\n\n\t\twaitForLogs, _, err := r.startStep(step)\n\t\trequire.NoError(t, err)\n\n\t\twaitForLogs()\n\t\tassert.Equal(t, int32(1), atomic.LoadInt32(&logCalled))\n\t})\n\n\tt.Run(\"LoggerError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tlogErr := errors.New(\"log stream broken\")\n\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"StartStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tengine.On(\"TailStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(io.NopCloser(strings.NewReader(\"data\")), nil)\n\n\t\tr := New(&backend_types.Config{}, engine,\n\t\t\tWithTracer(newTestTracer(t)),\n\t\t\tWithLogger(logging.Logger(func(_ *backend_types.Step, rc io.ReadCloser) error {\n\t\t\t\t_, _ = io.ReadAll(rc)\n\t\t\t\treturn logErr // triggers the error-log branch in the goroutine\n\t\t\t})),\n\t\t)\n\n\t\twaitForLogs, _, err := r.startStep(dummyStep(\"s1\"))\n\t\trequire.NoError(t, err) // startStep itself succeeds\n\n\t\t// waitForLogs blocks until the goroutine finishes; the branch is hit inside.\n\t\twaitForLogs()\n\t})\n}\n\n// The completeStep uses mockery mock for fine-grained control over\n// WaitStep/DestroyStep return values that dummy cannot provide.\nfunc TestCompleteStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Success\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.NoError(t, err)\n\t\tassert.True(t, ws.Exited)\n\t\tassert.Equal(t, 0, ws.ExitCode)\n\t})\n\n\tt.Run(\"NonZeroExitCode\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 1}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tvar exitErr *pipeline_errors.ExitError\n\t\tassert.True(t, errors.As(err, &exitErr))\n\t\tassert.Equal(t, 1, exitErr.Code)\n\t\tassert.Equal(t, 1, ws.ExitCode)\n\t})\n\n\tt.Run(\"OOMKilled\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, OOMKilled: true, ExitCode: 137}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tvar oomErr *pipeline_errors.OomError\n\t\tassert.True(t, errors.As(err, &oomErr))\n\t\tassert.True(t, ws.OOMKilled)\n\t})\n\n\tt.Run(\"ContextCanceledNilState\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(nil, context.Canceled)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.NoError(t, err)\n\t\trequire.NotNil(t, ws, \"nil guard must allocate a new State\")\n\t\tassert.Equal(t, pipeline_errors.ErrCancel, ws.Error)\n\t})\n\n\tt.Run(\"ContextCanceledWithState\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, context.Canceled)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, pipeline_errors.ErrCancel, ws.Error)\n\t})\n\n\tt.Run(\"WaitStepNonCancelError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(nil, errors.New(\"engine exploded\"))\n\t\t// DestroyStep should NOT be called — early return.\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.EqualError(t, err, \"engine exploded\")\n\t\tassert.Nil(t, ws)\n\t})\n\n\tt.Run(\"DestroyStepError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(errors.New(\"cleanup failed\"))\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.EqualError(t, err, \"cleanup failed\")\n\t\tassert.Nil(t, ws)\n\t})\n\n\tt.Run(\"SetsStartTime\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, 9999)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, int64(9999), ws.Started)\n\t})\n\n\tt.Run(\"CtxCanceledAfterDestroyStep\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\t// WaitStep succeeds (no context.Canceled from the engine),\n\t\t// but r.ctx is already canceled — the re-check at the bottom catches it.\n\t\tcanceledCtx, cancel := context.WithCancelCause(context.Background())\n\t\tcancel(nil) // pre-cancel\n\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\t\tr := New(&backend_types.Config{},\n\t\t\tengine,\n\t\t\tWithTracer(newTestTracer(t)),\n\t\t\tWithLogger(newTestLogger(t)),\n\t\t\tWithContext(canceledCtx), // r.ctx is canceled\n\t\t)\n\n\t\tws, err := r.completeStep(t.Context(), dummyStep(\"s1\"), func() {}, time.Now().Unix())\n\n\t\tassert.NoError(t, err)\n\t\trequire.NotNil(t, ws)\n\t\tassert.Equal(t, pipeline_errors.ErrCancel, ws.Error,\n\t\t\t\"re-check should set ErrCancel when r.ctx is already canceled\")\n\t})\n}\n\n// The executeStep uses dummy for the full step lifecycle.\nfunc TestExecuteStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"SkippedStepTraced\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := &backend_types.Step{\n\t\t\tName: \"skip-me\", UUID: \"skip-uuid\",\n\t\t\tType: backend_types.StepTypeCommands, Environment: map[string]string{},\n\t\t\tOnSuccess: false, OnFailure: true,\n\t\t}\n\n\t\terr := r.executeStep(t.Context(), step)\n\n\t\tassert.NoError(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.True(t, calls[0].CurrStepState.Skipped)\n\t})\n\n\tt.Run(\"BlockingStepSuccess\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"build\")\n\n\t\terr := r.executeStep(t.Context(), step)\n\n\t\tassert.NoError(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 2)\n\t\tassert.False(t, calls[0].CurrStepState.Exited, \"first trace should be step-started\")\n\t\tassert.True(t, calls[1].CurrStepState.Exited, \"second trace should be step-completed\")\n\t})\n\n\tt.Run(\"BlockingStepFailure\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"fail\")\n\t\tstep.Environment[dummy.EnvKeyStepExitCode] = \"1\"\n\n\t\terr := r.executeStep(t.Context(), step)\n\n\t\tassert.Error(t, err)\n\t\tvar exitErr *pipeline_errors.ExitError\n\t\tassert.True(t, errors.As(err, &exitErr))\n\t\tassert.Equal(t, 1, exitErr.Code)\n\t})\n\n\t// Use an atomic counter instead of getTracerStates inside Eventually to avoid\n\t// a data race: the detached-step goroutine writes to mock.Calls concurrently\n\t// with the Eventually polling goroutine reading it.\n\tt.Run(\"DetachedStep\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tvar traced int32\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\ttracer.On(\"Trace\", mock.Anything).\n\t\t\tRun(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }).\n\t\t\tReturn(nil).Maybe()\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"svc\")\n\t\tstep.Detached = true\n\t\tstep.Type = backend_types.StepTypeService\n\t\tstep.Environment[dummy.EnvKeyStepSleep] = \"1ms\"\n\n\t\terr := r.executeStep(t.Context(), step)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Eventually(t, func() bool {\n\t\t\treturn atomic.LoadInt32(&traced) >= 2\n\t\t}, time.Second, 10*time.Millisecond)\n\t})\n}\n\nfunc TestRunBlockingStep(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Success\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\n\t\terr := r.runBlockingStep(t.Context(), dummyStep(\"s1\"))\n\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"FailureIgnore\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := dummyStep(\"s1\")\n\t\tstep.Failure = string(metadata.FailureIgnore)\n\t\tstep.Environment[dummy.EnvKeyStepExitCode] = \"1\"\n\n\t\terr := r.runBlockingStep(t.Context(), step)\n\n\t\tassert.NoError(t, err, \"error should be suppressed when Failure==FailureIgnore\")\n\t})\n\n\tt.Run(\"StartFailure\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"s1\")\n\t\tstep.Environment[dummy.EnvKeyStepStartFail] = \"true\"\n\n\t\terr := r.runBlockingStep(t.Context(), step)\n\n\t\tassert.Error(t, err)\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.True(t, calls[0].CurrStepState.Exited)\n\t})\n\n\tt.Run(\"DestroyStepErrorMappedToErrCancel\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"StartStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(context.Canceled)\n\t\tengine.On(\"TailStep\", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader(\"\")), nil)\n\n\t\ttracer := newTestTracer(t)\n\t\tr := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)))\n\n\t\terr := r.runBlockingStep(t.Context(), dummyStep(\"s1\"))\n\n\t\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n\t})\n}\n\nfunc TestRunDetachedStep(t *testing.T) {\n\tt.Parallel()\n\n\t// Use an atomic counter instead of getTracerStates inside Eventually to avoid\n\t// a data race: the detached-step goroutine writes to mock.Calls concurrently\n\t// with the Eventually polling goroutine reading it.\n\tt.Run(\"ReturnsImmediately\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tvar traced int32\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\ttracer.On(\"Trace\", mock.Anything).\n\t\t\tRun(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }).\n\t\t\tReturn(nil).Maybe()\n\t\tr := newDummyRuntime(t, tracer)\n\t\tstep := dummyStep(\"svc\")\n\t\tstep.Environment[dummy.EnvKeyStepSleep] = \"1ms\"\n\n\t\terr := r.runDetachedStep(t.Context(), step)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Eventually(t, func() bool {\n\t\t\treturn atomic.LoadInt32(&traced) >= 1\n\t\t}, time.Second, 10*time.Millisecond)\n\t})\n\n\tt.Run(\"StartFailure\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tr := newDummyRuntime(t, newTestTracer(t))\n\t\tstep := dummyStep(\"svc\")\n\t\tstep.Environment[dummy.EnvKeyStepStartFail] = \"true\"\n\n\t\terr := r.runDetachedStep(t.Context(), step)\n\n\t\tassert.Error(t, err)\n\t})\n\n\t// Branch 1: context.Canceled from WaitStep → mapped to ErrCancel in the goroutine.\n\t// Branch 2: non-nil error from completeStep → error log branch.\n\t// Both are covered by a WaitStep that returns context.Canceled.\n\t//\n\t// Use an atomic counter instead of getTracerStates inside Eventually to avoid\n\t// a data race: the detached-step goroutine writes to mock.Calls concurrently\n\t// with the Eventually polling goroutine reading it.\n\tt.Run(\"BackgroundContextCanceled\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tvar traced int32\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\ttracer.On(\"Trace\", mock.Anything).\n\t\t\tRun(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }).\n\t\t\tReturn(nil).Maybe()\n\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"StartStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tengine.On(\"TailStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(io.NopCloser(strings.NewReader(\"\")), nil)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(nil, context.Canceled)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\t\tr := New(&backend_types.Config{},\n\t\t\tengine,\n\t\t\tWithTracer(tracer),\n\t\t\tWithLogger(newTestLogger(t)),\n\t\t)\n\t\tstep := dummyStep(\"svc\")\n\n\t\terr := r.runDetachedStep(t.Context(), step)\n\n\t\tassert.NoError(t, err) // returns immediately\n\t\t// Wait for the goroutine to finish and emit its trace.\n\t\tassert.Eventually(t, func() bool {\n\t\t\treturn atomic.LoadInt32(&traced) >= 1\n\t\t}, time.Second, 10*time.Millisecond)\n\t})\n\n\t// Branch 3: traceStep itself fails inside the goroutine → trace-error log branch.\n\tt.Run(\"BackgroundTracerError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttraceErr := errors.New(\"trace failed in background\")\n\n\t\tengine := mocks.NewMockBackend(t)\n\t\tengine.On(\"StartStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\t\tengine.On(\"TailStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(io.NopCloser(strings.NewReader(\"\")), nil)\n\t\tengine.On(\"WaitStep\", mock.Anything, mock.Anything, mock.Anything).\n\t\t\tReturn(&backend_types.State{Exited: true, ExitCode: 0}, nil)\n\t\tengine.On(\"DestroyStep\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\t\tvar traced int32\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\ttracer.On(\"Trace\", mock.Anything).\n\t\t\tRun(func(_ mock.Arguments) { atomic.AddInt32(&traced, 1) }).\n\t\t\tReturn(traceErr) // every Trace call fails\n\n\t\tr := New(&backend_types.Config{},\n\t\t\tengine,\n\t\t\tWithTracer(tracer),\n\t\t\tWithLogger(newTestLogger(t)),\n\t\t)\n\n\t\terr := r.runDetachedStep(t.Context(), dummyStep(\"svc\"))\n\n\t\tassert.NoError(t, err)\n\t\tassert.Eventually(t, func() bool {\n\t\t\treturn atomic.LoadInt32(&traced) >= 1\n\t\t}, time.Second, 10*time.Millisecond)\n\t})\n}\n"
  },
  {
    "path": "pipeline/runtime/workflow.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/sync/errgroup\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n)\n\n// Run starts the workflow, executes all stages sequentially, and tears down the\n// workflow on exit. The runnerCtx must outlive workflow cancellation so that cleanup\n// can still reach the backend (e.g. stopping Docker containers).\nfunc (r *Runtime) Run(runnerCtx context.Context) error {\n\tif err := r.validateConfig(); err != nil {\n\t\treturn err\n\t}\n\n\tlogger := r.makeLogger()\n\tr.logStages()\n\n\tdestroyWorkflowFunc := sync.OnceFunc(func() {\n\t\tctx := runnerCtx //nolint:contextcheck\n\t\tif ctx.Err() != nil {\n\t\t\t// runnerCtx itself is done — fall back to a short-lived shutdown context.\n\t\t\tctx = GetShutdownCtx()\n\t\t}\n\t\tif err := r.engine.DestroyWorkflow(ctx, r.spec, r.taskUUID); err != nil {\n\t\t\tlogger.Error().Err(err).Msg(\"could not destroy workflow\")\n\t\t}\n\t})\n\n\t// we make sure cleanup always happens\n\tdefer destroyWorkflowFunc()\n\n\tr.started = time.Now().Unix()\n\n\tif err := r.engine.SetupWorkflow(r.ctx, r.spec, r.taskUUID); err != nil { //nolint:contextcheck\n\t\tr.traceWorkflowSetupError(err)\n\t\treturn err\n\t}\n\n\tfor _, stage := range r.spec.Stages {\n\t\tstageChan := r.runStage(runnerCtx, stage.Steps)\n\t\tselect {\n\t\tcase <-r.ctx.Done():\n\t\t\t<-stageChan\n\t\t\treturn pipeline_errors.ErrCancel\n\t\tcase err := <-stageChan:\n\t\t\tif err != nil {\n\t\t\t\tr.err.Set(err)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Now we can shutdown the workflow\n\tdestroyWorkflowFunc()\n\n\t// Ensure all logs/traces are uploaded before finishing\n\tlogger.Debug().Msg(\"waiting for logs and traces upload\")\n\tr.uploadWait.Wait()\n\tlogger.Debug().Msg(\"logs and traces uploaded\")\n\n\treturn r.err.Get()\n}\n\n// The validateConfig checks if a dev made a mistake,\n// this should be values a user has no control over.\nfunc (r *Runtime) validateConfig() error {\n\tif r.tracer == nil {\n\t\treturn fmt.Errorf(\"runtime misconfiguration: tracer must not be nil\")\n\t}\n\tif r.logger == nil {\n\t\treturn fmt.Errorf(\"runtime misconfiguration: logger must not be nil\")\n\t}\n\tif r.spec == nil {\n\t\treturn fmt.Errorf(\"runtime misconfiguration: backend configuration is missing\")\n\t}\n\treturn nil\n}\n\n// logStages logs the ordered list of stages and their steps at debug level.\nfunc (r *Runtime) logStages() {\n\tlogger := r.makeLogger()\n\tlogger.Debug().Msgf(\"executing %d stages, in order of:\", len(r.spec.Stages))\n\tfor stagePos, stage := range r.spec.Stages {\n\t\tstepNames := make([]string, 0, len(stage.Steps))\n\t\tfor _, step := range stage.Steps {\n\t\t\tstepNames = append(stepNames, step.Name)\n\t\t}\n\t\tlogger.Debug().\n\t\t\tInt(\"StagePos\", stagePos).\n\t\t\tStr(\"Steps\", strings.Join(stepNames, \",\")).\n\t\t\tMsg(\"stage\")\n\t}\n}\n\n// traceWorkflowSetupError traces an ErrInvalidWorkflowSetup to the tracer.\nfunc (r *Runtime) traceWorkflowSetupError(err error) {\n\tvar stepErr *pipeline_errors.ErrInvalidWorkflowSetup\n\tif !errors.As(err, &stepErr) {\n\t\treturn\n\t}\n\n\ts := new(state.State)\n\ts.CurrStep = stepErr.Step\n\ts.Workflow.Error = stepErr.Err\n\ts.CurrStepState = backend_types.State{\n\t\tError:    stepErr.Err,\n\t\tExited:   true,\n\t\tExitCode: 1,\n\t}\n\n\tif traceErr := r.tracer.Trace(s); traceErr != nil {\n\t\tlogger := r.makeLogger()\n\t\tlogger.Error().Err(traceErr).Msg(\"failed to trace workflow setup error\")\n\t}\n}\n\n// runStage executes all steps of a stage in parallel.\n// It returns a channel that emits the combined error (if any) once all steps finish.\nfunc (r *Runtime) runStage(runnerCtx context.Context, steps []*backend_types.Step) <-chan error {\n\tvar g errgroup.Group\n\tdone := make(chan error)\n\n\tfor _, step := range steps {\n\t\tg.Go(func() error {\n\t\t\treturn r.executeStep(runnerCtx, step)\n\t\t})\n\t}\n\n\tgo func() {\n\t\tdone <- g.Wait()\n\t\tclose(done)\n\t}()\n\n\treturn done\n}\n"
  },
  {
    "path": "pipeline/runtime/workflow_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build test\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync/atomic\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\ttracer_mocks \"go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks\"\n)\n\nfunc TestRunNilTracer(t *testing.T) {\n\tt.Parallel()\n\tr := New(&backend_types.Config{}, dummy.New(), WithLogger(newTestLogger(t)), WithTracer(nil))\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"tracer must not be nil\")\n}\n\nfunc TestRunSuccess(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{{\n\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\tName: \"build\", UUID: \"u1\",\n\t\t\t\t\tType: backend_types.StepTypeCommands, OnSuccess: true,\n\t\t\t\t\tEnvironment: map[string]string{}, Commands: []string{\"echo hello\"},\n\t\t\t\t}},\n\t\t\t}},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\tcalls := getTracerStates(tracer)\n\trequire.Len(t, calls, 2)\n}\n\nfunc TestRunMultipleStages(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{Steps: []*backend_types.Step{{\n\t\t\t\t\tName: \"stage1\", UUID: \"u1\",\n\t\t\t\t\tType: backend_types.StepTypeCommands, OnSuccess: true,\n\t\t\t\t\tEnvironment: map[string]string{}, Commands: []string{\"echo 1\"},\n\t\t\t\t}}},\n\t\t\t\t{Steps: []*backend_types.Step{{\n\t\t\t\t\tName: \"stage2\", UUID: \"u2\",\n\t\t\t\t\tType: backend_types.StepTypeCommands, OnSuccess: true,\n\t\t\t\t\tEnvironment: map[string]string{}, Commands: []string{\"echo 2\"},\n\t\t\t\t}}},\n\t\t\t},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.NoError(t, err)\n\tcalls := getTracerStates(tracer)\n\trequire.Len(t, calls, 4)\n}\n\nfunc TestRunStepError(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{{\n\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\tName: \"fail\", UUID: \"u1\",\n\t\t\t\t\tType: backend_types.StepTypeCommands, OnSuccess: true,\n\t\t\t\t\tEnvironment: map[string]string{dummy.EnvKeyStepExitCode: \"1\"},\n\t\t\t\t\tCommands:    []string{\"exit 1\"},\n\t\t\t\t}},\n\t\t\t}},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(tracer),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tvar exitErr *pipeline_errors.ExitError\n\tassert.True(t, errors.As(err, &exitErr))\n\tassert.Equal(t, 1, exitErr.Code)\n}\n\nfunc TestRunContextCanceled(t *testing.T) {\n\tt.Parallel()\n\tctx, cancel := context.WithCancelCause(t.Context())\n\tcancel(nil)\n\n\tr := New(\n\t\t&backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{{\n\t\t\t\tSteps: []*backend_types.Step{{\n\t\t\t\t\tName: \"s1\", UUID: \"u1\",\n\t\t\t\t\tType: backend_types.StepTypeCommands, OnSuccess: true,\n\t\t\t\t\tEnvironment: map[string]string{}, Commands: []string{\"echo hello\"},\n\t\t\t\t}},\n\t\t\t}},\n\t\t},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithContext(ctx),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.ErrorIs(t, err, pipeline_errors.ErrCancel)\n}\n\nfunc TestRunSetupWorkflowError(t *testing.T) {\n\tt.Parallel()\n\tr := New(\n\t\t&backend_types.Config{},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithTaskUUID(dummy.WorkflowSetupFailUUID),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n}\n\nfunc TestRunSetupWorkflowInvalidSetupError(t *testing.T) {\n\tt.Parallel()\n\ttracer := newTestTracer(t)\n\tstep := &backend_types.Step{Name: \"clone\", UUID: \"clone-uuid\"}\n\tsetupErr := &pipeline_errors.ErrInvalidWorkflowSetup{\n\t\tErr:  errors.New(\"bad image\"),\n\t\tStep: step,\n\t}\n\tengine := mocks.NewMockBackend(t)\n\tengine.On(\"SetupWorkflow\", mock.Anything, mock.Anything, mock.Anything).Return(setupErr)\n\tengine.On(\"DestroyWorkflow\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\tr := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)))\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tcalls := getTracerStates(tracer)\n\trequire.Len(t, calls, 1)\n\tassert.Equal(t, step, calls[0].CurrStep)\n\tassert.True(t, calls[0].CurrStepState.Exited)\n\tassert.Equal(t, 1, calls[0].CurrStepState.ExitCode)\n}\n\nfunc TestRunDestroyWorkflowAlwaysCalled(t *testing.T) {\n\tt.Parallel()\n\tvar destroyed int32\n\tengine := mocks.NewMockBackend(t)\n\tengine.On(\"SetupWorkflow\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\tengine.On(\"DestroyWorkflow\", mock.Anything, mock.Anything, mock.Anything).\n\t\tRun(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)\n\n\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t_ = r.Run(t.Context())\n\n\tassert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))\n}\n\nfunc TestRunDestroyWorkflowCalledOnSetupError(t *testing.T) {\n\tt.Parallel()\n\tvar destroyed int32\n\tengine := mocks.NewMockBackend(t)\n\tengine.On(\"SetupWorkflow\", mock.Anything, mock.Anything, mock.Anything).\n\t\tReturn(errors.New(\"setup boom\"))\n\tengine.On(\"DestroyWorkflow\", mock.Anything, mock.Anything, mock.Anything).\n\t\tRun(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)\n\n\tr := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))\n\n\t_ = r.Run(t.Context())\n\n\tassert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))\n}\n\nfunc TestTraceWorkflowSetupError(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"MatchingError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))\n\t\tstep := &backend_types.Step{Name: \"setup\", UUID: \"su\"}\n\t\terr := &pipeline_errors.ErrInvalidWorkflowSetup{Err: errors.New(\"bad\"), Step: step}\n\n\t\tr.traceWorkflowSetupError(err)\n\n\t\tcalls := getTracerStates(tracer)\n\t\trequire.Len(t, calls, 1)\n\t\tassert.Equal(t, step, calls[0].CurrStep)\n\t\tassert.True(t, calls[0].CurrStepState.Exited)\n\t\tassert.Equal(t, 1, calls[0].CurrStepState.ExitCode)\n\t})\n\n\tt.Run(\"NonMatchingError\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\t// Trace should NOT be called — no .On() setup means test panics if called.\n\t\tr := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))\n\n\t\tr.traceWorkflowSetupError(errors.New(\"generic error\"))\n\t})\n\n\tt.Run(\"TracerFailure\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := tracer_mocks.NewMockTracer(t)\n\t\ttracer.On(\"Trace\", mock.Anything).Return(errors.New(\"trace failed\"))\n\t\tr := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))\n\t\tstep := &backend_types.Step{Name: \"setup\", UUID: \"su\"}\n\n\t\t// Should not panic — the error is logged, not returned.\n\t\tr.traceWorkflowSetupError(&pipeline_errors.ErrInvalidWorkflowSetup{\n\t\t\tErr: errors.New(\"bad\"), Step: step,\n\t\t})\n\t})\n}\n\nfunc TestRunStage(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"ParallelExecution\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\n\t\tsteps := []*backend_types.Step{\n\t\t\t{Name: \"a\", UUID: \"ua\", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{\"echo a\"}},\n\t\t\t{Name: \"b\", UUID: \"ub\", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{\"echo b\"}},\n\t\t\t{Name: \"c\", UUID: \"uc\", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{\"echo c\"}},\n\t\t}\n\n\t\terr := <-r.runStage(t.Context(), steps)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Len(t, getTracerStates(tracer), 6)\n\t})\n\n\tt.Run(\"OneStepFails\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\ttracer := newTestTracer(t)\n\t\tr := newDummyRuntime(t, tracer)\n\n\t\tsteps := []*backend_types.Step{\n\t\t\t{Name: \"good\", UUID: \"ug\", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{\"echo ok\"}},\n\t\t\t{Name: \"bad\", UUID: \"ub\", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{dummy.EnvKeyStepExitCode: \"1\"}, Commands: []string{\"exit 1\"}},\n\t\t}\n\n\t\terr := <-r.runStage(t.Context(), steps)\n\n\t\tassert.Error(t, err)\n\t})\n}\n\nfunc TestNewDefaults(t *testing.T) {\n\tt.Parallel()\n\tspec := &backend_types.Config{}\n\n\tr := New(spec, dummy.New())\n\n\tassert.Equal(t, spec, r.spec)\n\tassert.NotEmpty(t, r.taskUUID)\n\tassert.NotNil(t, r.ctx)\n\tassert.NotNil(t, r.tracer)\n\tassert.NotNil(t, r.engine)\n\tassert.NoError(t, r.err.Get())\n}\n\nfunc TestWithOptions(t *testing.T) {\n\tt.Parallel()\n\tengine := dummy.New()\n\ttracer := newTestTracer(t)\n\tctx := context.Background()\n\tdesc := map[string]string{\"repo\": \"test\"}\n\n\tr := New(&backend_types.Config{},\n\t\tengine,\n\t\tWithTracer(tracer),\n\t\tWithContext(ctx),\n\t\tWithDescription(desc),\n\t\tWithTaskUUID(\"custom-uuid\"),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\tassert.Equal(t, engine, r.engine)\n\tassert.Equal(t, tracer, r.tracer)\n\tassert.Equal(t, ctx, r.ctx)\n\tassert.Equal(t, \"custom-uuid\", r.taskUUID)\n\tassert.Equal(t, \"test\", r.description[\"repo\"])\n}\n\nfunc TestGetShutdownCtx(t *testing.T) {\n\tctx := GetShutdownCtx()\n\tassert.NotNil(t, ctx)\n\n\tctx2 := GetShutdownCtx()\n\tassert.Equal(t, ctx, ctx2)\n}\n\n// Gap A: logger == nil guard.\nfunc TestRunNilLogger(t *testing.T) {\n\tt.Parallel()\n\tr := New(&backend_types.Config{},\n\t\tdummy.New(),\n\t\tWithTracer(newTestTracer(t)),\n\t\t// WithLogger intentionally omitted\n\t)\n\n\terr := r.Run(t.Context())\n\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"logger must not be nil\")\n}\n\n// Gap B: runnerCtx is already done inside the defer → GetShutdownCtx() fallback.\nfunc TestRunDestroyWorkflowFallsBackToShutdownCtx(t *testing.T) {\n\tt.Parallel()\n\tengine := mocks.NewMockBackend(t)\n\tengine.On(\"SetupWorkflow\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\tvar destroyCtx context.Context\n\tengine.On(\"DestroyWorkflow\", mock.Anything, mock.Anything, mock.Anything).\n\t\tRun(func(args mock.Arguments) {\n\t\t\tdestroyCtx, _ = args.Get(0).(context.Context)\n\t\t}).Return(nil)\n\n\t// Pass a pre-canceled runnerCtx so ctx.Err() != nil in the defer.\n\trunnerCtx, cancel := context.WithCancelCause(context.Background())\n\tcancel(nil)\n\n\tr := New(&backend_types.Config{},\n\t\tengine,\n\t\tWithTracer(newTestTracer(t)),\n\t\tWithLogger(newTestLogger(t)),\n\t)\n\n\t_ = r.Run(runnerCtx)\n\n\trequire.NotNil(t, destroyCtx)\n\t// The shutdown context is not the canceled runnerCtx — it must still be valid\n\t// (or at least not the same canceled one).\n\tassert.NotEqual(t, runnerCtx, destroyCtx,\n\t\t\"DestroyWorkflow should receive the shutdown fallback context, not the canceled runnerCtx\")\n}\n"
  },
  {
    "path": "pipeline/shared/replace_secrets.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage shared\n\nimport \"strings\"\n\n// NewSecretsReplacer creates a new strings.Replacer to replace sensitive\n// strings with asterisks. It takes a slice of secrets strings as input\n// and returns a populated strings.Replacer that will replace those\n// secrets with asterisks. Each secret string is split on newlines to\n// handle multi-line secrets.\nfunc NewSecretsReplacer(secrets []string) *strings.Replacer {\n\tvar oldNew []string\n\n\t// Strings shorter than minStringLength are not considered secrets.\n\t// Do not sanitize them.\n\tconst minStringLength = 3\n\n\tfor _, old := range secrets {\n\t\told = strings.TrimSpace(old)\n\t\tif len(old) <= minStringLength {\n\t\t\tcontinue\n\t\t}\n\t\t// since replacer is executed on each line we have to split multi-line-secrets\n\t\tfor _, part := range strings.Split(old, \"\\n\") {\n\t\t\tif len(part) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\toldNew = append(oldNew, part)\n\t\t\toldNew = append(oldNew, \"********\")\n\t\t}\n\t}\n\n\treturn strings.NewReplacer(oldNew...)\n}\n"
  },
  {
    "path": "pipeline/shared/replace_secrets_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage shared\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestNewSecretsReplacer(t *testing.T) {\n\ttc := []struct {\n\t\tname    string\n\t\tlog     string\n\t\tsecrets []string\n\t\texpect  string\n\t}{{\n\t\tname:    \"dont replace secrets with less than 3 chars\",\n\t\tlog:     \"start log\\ndone\",\n\t\tsecrets: []string{\"\", \"d\", \"art\"},\n\t\texpect:  \"start log\\ndone\",\n\t}, {\n\t\tname:    \"single line passwords\",\n\t\tlog:     `this IS secret: password`,\n\t\tsecrets: []string{\"password\", \" IS \"},\n\t\texpect:  `this IS secret: ********`,\n\t}, {\n\t\tname:    \"secret with one newline\",\n\t\tlog:     \"start log\\ndone\\nnow\\nan\\nmulti line secret!! ;)\",\n\t\tsecrets: []string{\"an\\nmulti line secret!!\"},\n\t\texpect:  \"start log\\ndone\\nnow\\n********\\n******** ;)\",\n\t}, {\n\t\tname:    \"secret with multiple lines with no match\",\n\t\tlog:     \"start log\\ndone\\nnow\\nan\\nmulti line secret!! ;)\",\n\t\tsecrets: []string{\"Test\\nwith\\n\\ntwo new lines\"},\n\t\texpect:  \"start log\\ndone\\nnow\\nan\\nmulti line secret!! ;)\",\n\t}, {\n\t\tname:    \"secret with multiple lines with match\",\n\t\tlog:     \"start log\\ndone\\nnow\\nan\\nmulti line secret!! ;)\\nwith\\ntwo\\n\\nnewlines\",\n\t\tsecrets: []string{\"an\\nmulti line secret!!\", \"two\\n\\nnewlines\"},\n\t\texpect:  \"start log\\ndone\\nnow\\n********\\n******** ;)\\nwith\\n********\\n\\n********\",\n\t}}\n\n\tfor _, c := range tc {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\trep := NewSecretsReplacer(c.secrets)\n\t\t\tresult := rep.Replace(c.log)\n\t\t\tassert.EqualValues(t, c.expect, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pipeline/state/state.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage state\n\nimport (\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\n// State is used to signal the current workflow and step state.\n// Only steps using the trace func report back what's going on.\n// And the workflow is updated alongside it.\ntype State struct {\n\t// Global state of the currently running Workflow.\n\tWorkflow Workflow\n\t// Current step that updates the step and workflow state\n\tCurrStep *backend_types.Step `json:\"step\"`\n\t// Current step state\n\tCurrStepState backend_types.State\n}\n\ntype Workflow struct {\n\t// Workflow start time\n\tStarted int64 `json:\"time\"`\n\t// Current workflow error state\n\tError error `json:\"error\"`\n}\n"
  },
  {
    "path": "pipeline/tracing/mocks/mock_Tracer.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n)\n\n// NewMockTracer creates a new instance of MockTracer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockTracer(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockTracer {\n\tmock := &MockTracer{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockTracer is an autogenerated mock type for the Tracer type\ntype MockTracer struct {\n\tmock.Mock\n}\n\ntype MockTracer_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockTracer) EXPECT() *MockTracer_Expecter {\n\treturn &MockTracer_Expecter{mock: &_m.Mock}\n}\n\n// Trace provides a mock function for the type MockTracer\nfunc (_mock *MockTracer) Trace(state1 *state.State) error {\n\tret := _mock.Called(state1)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Trace\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*state.State) error); ok {\n\t\tr0 = returnFunc(state1)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockTracer_Trace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trace'\ntype MockTracer_Trace_Call struct {\n\t*mock.Call\n}\n\n// Trace is a helper method to define mock.On call\n//   - state1 *state.State\nfunc (_e *MockTracer_Expecter) Trace(state1 interface{}) *MockTracer_Trace_Call {\n\treturn &MockTracer_Trace_Call{Call: _e.mock.On(\"Trace\", state1)}\n}\n\nfunc (_c *MockTracer_Trace_Call) Run(run func(state1 *state.State)) *MockTracer_Trace_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *state.State\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*state.State)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockTracer_Trace_Call) Return(err error) *MockTracer_Trace_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockTracer_Trace_Call) RunAndReturn(run func(state1 *state.State) error) *MockTracer_Trace_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "pipeline/tracing/tracer.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tracing\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/state\"\n)\n\n// Tracer handles process tracing.\ntype Tracer interface {\n\tTrace(*state.State) error\n}\n\n// TraceFunc type is an adapter to allow the use of ordinary\n// functions as a Tracer.\ntype TraceFunc func(*state.State) error\n\n// Trace calls f(state).\nfunc (f TraceFunc) Trace(state *state.State) error {\n\treturn f(state)\n}\n\n// NoOpTracer provides a tracer that does nothing.\nvar NoOpTracer = TraceFunc(func(*state.State) error { return nil })\n"
  },
  {
    "path": "pipeline/utils/copy_line_by_line.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n)\n\nfunc writeChunks(dst io.Writer, data []byte, size int) error {\n\tif len(data) <= size {\n\t\t_, err := dst.Write(data)\n\t\treturn err\n\t}\n\n\tfor len(data) > size {\n\t\tif _, err := dst.Write(data[:size]); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdata = data[size:]\n\t}\n\n\tif len(data) > 0 {\n\t\t_, err := dst.Write(data)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc CopyLineByLine(dst io.Writer, src io.Reader, maxSize int) error {\n\tr := bufio.NewReader(src)\n\t// buffer to cache\n\tvar buf []byte\n\t// buffer to read\n\treadBuf := make([]byte, maxSize)\n\n\tfor {\n\t\tn, err := r.Read(readBuf)\n\n\t\t// handle the data first\n\t\tif n > 0 {\n\t\t\t// if it has data, cache into the buffer\n\t\t\tbuf = append(buf, readBuf[:n]...)\n\n\t\tprocessBuffer:\n\t\t\tfor len(buf) > 0 {\n\t\t\t\t// find the index to anchor the new line\n\t\t\t\tidx := bytes.IndexByte(buf, '\\n')\n\t\t\t\tswitch {\n\t\t\t\tcase idx >= 0:\n\t\t\t\t\t// found the new line, write to the dst\n\t\t\t\t\tlineEnd := idx + 1\n\t\t\t\t\tif lineEnd > maxSize {\n\t\t\t\t\t\tif wErr := writeChunks(dst, buf[:lineEnd], maxSize); wErr != nil {\n\t\t\t\t\t\t\treturn wErr\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif _, wErr := dst.Write(buf[:lineEnd]); wErr != nil {\n\t\t\t\t\t\t\treturn wErr\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t// remove the line written from the buffer\n\t\t\t\t\tbuf = buf[lineEnd:]\n\t\t\t\tcase len(buf) >= maxSize:\n\t\t\t\t\tif _, wErr := dst.Write(buf[:maxSize]); wErr != nil {\n\t\t\t\t\t\treturn wErr\n\t\t\t\t\t}\n\t\t\t\t\tbuf = buf[maxSize:]\n\t\t\t\tdefault:\n\t\t\t\t\t// no newline found and buffer not full, read more data\n\t\t\t\t\tbreak processBuffer\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// and then if it is EOF, write the remaining data and break the loop\n\t\tif errors.Is(err, io.EOF) {\n\t\t\tif len(buf) == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif _, wErr := dst.Write(buf); wErr != nil {\n\t\t\t\treturn wErr\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pipeline/utils/copy_line_by_line_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils_test\n\nimport (\n\t\"io\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/utils\"\n)\n\ntype testWriter struct {\n\t*sync.Mutex\n\twrites []string\n}\n\nfunc (b *testWriter) Write(p []byte) (n int, err error) {\n\tb.Lock()\n\tdefer b.Unlock()\n\tb.writes = append(b.writes, string(p))\n\treturn len(p), nil\n}\n\nfunc (b *testWriter) Close() error {\n\treturn nil\n}\n\nfunc (b *testWriter) GetWrites() []string {\n\tb.Lock()\n\tdefer b.Unlock()\n\tw := make([]string, len(b.writes))\n\tcopy(w, b.writes)\n\treturn w\n}\n\nfunc TestCopyLineByLine(t *testing.T) {\n\tr, w := io.Pipe()\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\terr := utils.CopyLineByLine(testWriter, r, 1024)\n\t\tassert.NoError(t, err)\n\t\tclose(done)\n\t}()\n\n\t// write 4 bytes without newline\n\tif _, err := w.Write([]byte(\"1234\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// wait until no writes have occurred (should be immediate)\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 0\n\t}, time.Second, 5*time.Millisecond, \"expected 0 writes after first write\")\n\n\t// write more bytes with newlines\n\tif _, err := w.Write([]byte(\"5\\n678\\n90\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// wait until two writes have occurred\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 2\n\t}, time.Second, 5*time.Millisecond, \"expected 2 writes after second write\")\n\n\twrites := testWriter.GetWrites()\n\twrittenData := strings.Join(writes, \"-\")\n\tassert.Equal(t, \"12345\\n-678\\n\", writtenData, \"unexpected writtenData: %s\", writtenData)\n\n\t// closing the writer should flush the remaining data\n\tw.Close()\n\n\t// wait for the goroutine to finish\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timeout waiting for goroutine to finish\")\n\t}\n\n\t// the written data contains all the data we wrote\n\twrittenData = strings.Join(testWriter.GetWrites(), \"-\")\n\tassert.Equal(t, \"12345\\n-678\\n-90\", writtenData, \"unexpected writtenData: %s\", writtenData)\n}\n\nfunc TestCopyLineByLineSizeLimit(t *testing.T) {\n\tr, w := io.Pipe()\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\twg := sync.WaitGroup{}\n\twg.Add(1)\n\n\tgo func() {\n\t\tdefer wg.Done()\n\t\terr := utils.CopyLineByLine(testWriter, r, 4)\n\t\tassert.NoError(t, err)\n\t}()\n\n\t// wait for the goroutine to start\n\ttime.Sleep(time.Millisecond)\n\n\t// write 4 bytes without newline\n\tif _, err := w.Write([]byte(\"12345\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\twrites := testWriter.GetWrites()\n\tassert.Lenf(t, testWriter.GetWrites(), 1, \"expected 1 writes, got: %v\", writes)\n\n\t// write more bytes\n\tif _, err := w.Write([]byte(\"67\\n89\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// wait for writer to write\n\ttime.Sleep(time.Millisecond)\n\n\twrites = testWriter.GetWrites()\n\tassert.Lenf(t, testWriter.GetWrites(), 2, \"expected 2 writes, got: %v\", writes)\n\n\twrites = testWriter.GetWrites()\n\twrittenData := strings.Join(writes, \"-\")\n\tassert.Equal(t, \"1234-567\\n\", writtenData, \"unexpected writtenData: %s\", writtenData)\n\n\t// closing the writer should flush the remaining data\n\tw.Close()\n\n\twg.Wait()\n}\n\nfunc TestStringReader(t *testing.T) {\n\tr := io.NopCloser(strings.NewReader(\"123\\n4567\\n890\"))\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\terr := utils.CopyLineByLine(testWriter, r, 1024)\n\tassert.NoError(t, err)\n\n\twrites := testWriter.GetWrites()\n\tassert.Lenf(t, writes, 3, \"expected 3 writes, got: %v\", writes)\n}\n\nfunc TestCopyLineByLineNewlineCharacter(t *testing.T) {\n\tr, w := io.Pipe()\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\tdone := make(chan struct{})\n\n\tgo func() {\n\t\terr := utils.CopyLineByLine(testWriter, r, 4)\n\t\tassert.NoError(t, err)\n\t\tclose(done)\n\t}()\n\n\t// write one newline character before the maximum size of the buffer\n\tif _, err := w.Write([]byte(\"123\\n45678\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// wait until 2 writes have occurred\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 2\n\t}, time.Second, 5*time.Millisecond, \"expected 2 writes after first write\")\n\n\twrites := testWriter.GetWrites()\n\twrittenData := strings.Join(writes, \"-\")\n\tassert.Equal(t, \"123\\n-4567\", writtenData)\n\n\t// write one newline character at the beginning before the maximum size of the buffer\n\tif _, err := w.Write([]byte(\"\\n123\\n45678\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// wait until 5 writes have occurred (2 from before + 3 new ones)\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 5\n\t}, time.Second, 5*time.Millisecond, \"expected 5 writes total after second write\")\n\n\twrites = testWriter.GetWrites()\n\twrittenData = strings.Join(writes, \"-\")\n\tassert.Equal(t, \"123\\n-4567-8\\n-123\\n-4567\", writtenData)\n\n\t// Close the writer first to signal EOF\n\tw.Close()\n\n\t// wait for the goroutine to finish\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timeout waiting for goroutine to finish\")\n\t}\n\n\t// Verify final flush (should have \"8\" remaining)\n\twrites = testWriter.GetWrites()\n\twrittenData = strings.Join(writes, \"-\")\n\tassert.Equal(t, \"123\\n-4567-8\\n-123\\n-4567-8\", writtenData)\n}\n\n// TestCopyLineByLineLongLine is for the long line testing to trigger the writeChunks function.\nfunc TestCopyLineByLineLongLine(t *testing.T) {\n\tr, w := io.Pipe()\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\tdone := make(chan struct{})\n\n\t// max size = 10\n\tmaxSize := 10\n\n\tgo func() {\n\t\terr := utils.CopyLineByLine(testWriter, r, maxSize)\n\t\tassert.NoError(t, err)\n\t\tclose(done)\n\t}()\n\n\t// wait for the goroutine to start\n\ttime.Sleep(time.Millisecond)\n\n\t// will trigger the writeChunks function\n\tif _, err := w.Write([]byte(\"this is a very long line\\n\")); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// wait for the writer to write\n\ttime.Sleep(time.Millisecond)\n\n\t// verify the number of writes is equal to 3\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 3\n\t}, time.Second, 5*time.Millisecond, \"expected 3 writes after first write\")\n\n\t// verify all data was written correctly\n\twrittenData := \"\"\n\tassert.Eventually(t, func() bool {\n\t\twrittenData = strings.Join(testWriter.GetWrites(), \"-\")\n\t\treturn writtenData == \"this is a -very long -line\\n\"\n\t}, time.Second, 5*time.Millisecond, \"unexpected writtenData: %s\", writtenData)\n\n\t// closing the writer should flush the remaining data\n\tw.Close()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timeout waiting for goroutine to finish\")\n\t}\n}\n\n// TestCopyLineByLineWriteChunks is for the writeChunks function testing.\nfunc TestCopyLineByLineWriteChunks(t *testing.T) {\n\tr, w := io.Pipe()\n\n\ttestWriter := &testWriter{\n\t\tMutex:  &sync.Mutex{},\n\t\twrites: make([]string, 0),\n\t}\n\n\tdone := make(chan struct{})\n\n\t// max size = 8\n\tmaxSize := 8\n\n\tgo func() {\n\t\terr := utils.CopyLineByLine(testWriter, r, maxSize)\n\t\tassert.NoError(t, err)\n\t\tclose(done)\n\t}()\n\n\t// first line: 20 chars + newline = 21 bytes (will be chunked: 8 + 8 + 5)\n\t// second line: 5 chars + newline = 6 bytes (normal write, no chunking)\n\t// third line: 16 chars + newline = 17 bytes (will be chunked: 8 + 9)\n\tinput := \"12345678901234567890\\n\" +\n\t\t\"short\\n\" +\n\t\t\"abcdefghijklmnop\\n\"\n\n\tif _, err := w.Write([]byte(input)); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\t// verify the number of writes is equal to 7\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == 7\n\t}, time.Second, 5*time.Millisecond, \"expected 7 writes after first write\")\n\n\t// verify all data was written correctly\n\twrittenData := \"\"\n\tassert.Eventually(t, func() bool {\n\t\twrittenData = strings.Join(testWriter.GetWrites(), \"\")\n\t\treturn writtenData == input\n\t}, time.Second, 5*time.Millisecond, \"unexpected writtenData: %s\", writtenData)\n\n\t// verify the number of writes\n\texpectedWrites := 7\n\tassert.Eventually(t, func() bool {\n\t\treturn len(testWriter.GetWrites()) == expectedWrites\n\t}, time.Second, 5*time.Millisecond, \"expected %d writes, got %d: %v\", expectedWrites, len(testWriter.GetWrites()), testWriter.GetWrites())\n\n\twrites := testWriter.GetWrites()\n\t// verify first line chunks\n\tassert.Equal(t, \"12345678\", writes[0], \"first chunk of first line\")\n\tassert.Equal(t, \"90123456\", writes[1], \"second chunk of first line\")\n\tassert.Equal(t, \"7890\\n\", writes[2], \"third chunk of first line\")\n\n\t// verify second line (not chunked)\n\tassert.Equal(t, \"short\\n\", writes[3], \"second line should not be chunked\")\n\n\t// verify third line chunks\n\tassert.Equal(t, \"abcdefgh\", writes[4], \"first chunk of third line\")\n\tassert.Equal(t, \"ijklmnop\", writes[5], \"second chunk of third line\")\n\tassert.Equal(t, \"\\n\", writes[6], \"third chunk of third line (just newline)\")\n\n\t// closing the writer should flush the remaining data\n\tw.Close()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(time.Second):\n\t\tt.Fatal(\"timeout waiting for goroutine to finish\")\n\t}\n}\n"
  },
  {
    "path": "release-config.ts",
    "content": "export default {\n  commentOnReleasedPullRequests: false,\n  skipLabels: ['skip-release', 'skip-changelog', 'regression', 'backport-done'],\n};\n"
  },
  {
    "path": "rpc/log_entry.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"fmt\"\n)\n\n// Identifies the type of line in the logs.\nconst (\n\tLogEntryStdout int = iota\n\tLogEntryStderr\n\tLogEntryExitCode\n\tLogEntryMetadata\n\tLogEntryProgress\n)\n\n// LogEntry is a line of console output.\ntype LogEntry struct {\n\tStepUUID string `json:\"step_uuid,omitempty\"`\n\tTime     int64  `json:\"time,omitempty\"`\n\tType     int    `json:\"type,omitempty\"`\n\tLine     int    `json:\"line,omitempty\"`\n\tData     []byte `json:\"data,omitempty\"`\n}\n\nfunc (l *LogEntry) String() string {\n\tswitch l.Type {\n\tcase LogEntryExitCode:\n\t\treturn fmt.Sprintf(\"[%s] exit code %s\", l.StepUUID, l.Data)\n\tdefault:\n\t\treturn fmt.Sprintf(\"[%s:L%v:%vs] %s\", l.StepUUID, l.Line, l.Time, l.Data)\n\t}\n}\n"
  },
  {
    "path": "rpc/log_entry_test.go",
    "content": "// Copyright 2019 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestLogEntry(t *testing.T) {\n\tline := LogEntry{\n\t\tStepUUID: \"e9ea76a5-44a1-4059-9c4a-6956c478b26d\",\n\t\tTime:     60,\n\t\tLine:     1,\n\t\tData:     []byte(\"starting redis server\"),\n\t}\n\tassert.Equal(t, \"[e9ea76a5-44a1-4059-9c4a-6956c478b26d:L1:60s] starting redis server\", line.String())\n}\n"
  },
  {
    "path": "rpc/mocks/mock_Peer.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n)\n\n// NewMockPeer creates a new instance of MockPeer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockPeer(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockPeer {\n\tmock := &MockPeer{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockPeer is an autogenerated mock type for the Peer type\ntype MockPeer struct {\n\tmock.Mock\n}\n\ntype MockPeer_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockPeer) EXPECT() *MockPeer_Expecter {\n\treturn &MockPeer_Expecter{mock: &_m.Mock}\n}\n\n// Done provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Done(c context.Context, workflowID string, state rpc.WorkflowState) error {\n\tret := _mock.Called(c, workflowID, state)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Done\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.WorkflowState) error); ok {\n\t\tr0 = returnFunc(c, workflowID, state)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done'\ntype MockPeer_Done_Call struct {\n\t*mock.Call\n}\n\n// Done is a helper method to define mock.On call\n//   - c context.Context\n//   - workflowID string\n//   - state rpc.WorkflowState\nfunc (_e *MockPeer_Expecter) Done(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Done_Call {\n\treturn &MockPeer_Done_Call{Call: _e.mock.On(\"Done\", c, workflowID, state)}\n}\n\nfunc (_c *MockPeer_Done_Call) Run(run func(c context.Context, workflowID string, state rpc.WorkflowState)) *MockPeer_Done_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 rpc.WorkflowState\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(rpc.WorkflowState)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Done_Call) Return(err error) *MockPeer_Done_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Done_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.WorkflowState) error) *MockPeer_Done_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// EnqueueLog provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) EnqueueLog(logEntry *rpc.LogEntry) {\n\t_mock.Called(logEntry)\n\treturn\n}\n\n// MockPeer_EnqueueLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnqueueLog'\ntype MockPeer_EnqueueLog_Call struct {\n\t*mock.Call\n}\n\n// EnqueueLog is a helper method to define mock.On call\n//   - logEntry *rpc.LogEntry\nfunc (_e *MockPeer_Expecter) EnqueueLog(logEntry interface{}) *MockPeer_EnqueueLog_Call {\n\treturn &MockPeer_EnqueueLog_Call{Call: _e.mock.On(\"EnqueueLog\", logEntry)}\n}\n\nfunc (_c *MockPeer_EnqueueLog_Call) Run(run func(logEntry *rpc.LogEntry)) *MockPeer_EnqueueLog_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *rpc.LogEntry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*rpc.LogEntry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_EnqueueLog_Call) Return() *MockPeer_EnqueueLog_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockPeer_EnqueueLog_Call) RunAndReturn(run func(logEntry *rpc.LogEntry)) *MockPeer_EnqueueLog_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// Extend provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Extend(c context.Context, workflowID string) error {\n\tret := _mock.Called(c, workflowID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Extend\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {\n\t\tr0 = returnFunc(c, workflowID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_Extend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extend'\ntype MockPeer_Extend_Call struct {\n\t*mock.Call\n}\n\n// Extend is a helper method to define mock.On call\n//   - c context.Context\n//   - workflowID string\nfunc (_e *MockPeer_Expecter) Extend(c interface{}, workflowID interface{}) *MockPeer_Extend_Call {\n\treturn &MockPeer_Extend_Call{Call: _e.mock.On(\"Extend\", c, workflowID)}\n}\n\nfunc (_c *MockPeer_Extend_Call) Run(run func(c context.Context, workflowID string)) *MockPeer_Extend_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Extend_Call) Return(err error) *MockPeer_Extend_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Extend_Call) RunAndReturn(run func(c context.Context, workflowID string) error) *MockPeer_Extend_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Init provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Init(c context.Context, workflowID string, state rpc.WorkflowState) error {\n\tret := _mock.Called(c, workflowID, state)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Init\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.WorkflowState) error); ok {\n\t\tr0 = returnFunc(c, workflowID, state)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init'\ntype MockPeer_Init_Call struct {\n\t*mock.Call\n}\n\n// Init is a helper method to define mock.On call\n//   - c context.Context\n//   - workflowID string\n//   - state rpc.WorkflowState\nfunc (_e *MockPeer_Expecter) Init(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Init_Call {\n\treturn &MockPeer_Init_Call{Call: _e.mock.On(\"Init\", c, workflowID, state)}\n}\n\nfunc (_c *MockPeer_Init_Call) Run(run func(c context.Context, workflowID string, state rpc.WorkflowState)) *MockPeer_Init_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 rpc.WorkflowState\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(rpc.WorkflowState)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Init_Call) Return(err error) *MockPeer_Init_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Init_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.WorkflowState) error) *MockPeer_Init_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// IsConnected provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) IsConnected() bool {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for IsConnected\")\n\t}\n\n\tvar r0 bool\n\tif returnFunc, ok := ret.Get(0).(func() bool); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\treturn r0\n}\n\n// MockPeer_IsConnected_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsConnected'\ntype MockPeer_IsConnected_Call struct {\n\t*mock.Call\n}\n\n// IsConnected is a helper method to define mock.On call\nfunc (_e *MockPeer_Expecter) IsConnected() *MockPeer_IsConnected_Call {\n\treturn &MockPeer_IsConnected_Call{Call: _e.mock.On(\"IsConnected\")}\n}\n\nfunc (_c *MockPeer_IsConnected_Call) Run(run func()) *MockPeer_IsConnected_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_IsConnected_Call) Return(b bool) *MockPeer_IsConnected_Call {\n\t_c.Call.Return(b)\n\treturn _c\n}\n\nfunc (_c *MockPeer_IsConnected_Call) RunAndReturn(run func() bool) *MockPeer_IsConnected_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Next provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Next(c context.Context, f rpc.Filter) (*rpc.Workflow, error) {\n\tret := _mock.Called(c, f)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Next\")\n\t}\n\n\tvar r0 *rpc.Workflow\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, rpc.Filter) (*rpc.Workflow, error)); ok {\n\t\treturn returnFunc(c, f)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, rpc.Filter) *rpc.Workflow); ok {\n\t\tr0 = returnFunc(c, f)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*rpc.Workflow)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, rpc.Filter) error); ok {\n\t\tr1 = returnFunc(c, f)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockPeer_Next_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Next'\ntype MockPeer_Next_Call struct {\n\t*mock.Call\n}\n\n// Next is a helper method to define mock.On call\n//   - c context.Context\n//   - f rpc.Filter\nfunc (_e *MockPeer_Expecter) Next(c interface{}, f interface{}) *MockPeer_Next_Call {\n\treturn &MockPeer_Next_Call{Call: _e.mock.On(\"Next\", c, f)}\n}\n\nfunc (_c *MockPeer_Next_Call) Run(run func(c context.Context, f rpc.Filter)) *MockPeer_Next_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 rpc.Filter\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(rpc.Filter)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Next_Call) Return(workflow *rpc.Workflow, err error) *MockPeer_Next_Call {\n\t_c.Call.Return(workflow, err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Next_Call) RunAndReturn(run func(c context.Context, f rpc.Filter) (*rpc.Workflow, error)) *MockPeer_Next_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegisterAgent provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) {\n\tret := _mock.Called(ctx, info)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegisterAgent\")\n\t}\n\n\tvar r0 int64\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, rpc.AgentInfo) (int64, error)); ok {\n\t\treturn returnFunc(ctx, info)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, rpc.AgentInfo) int64); ok {\n\t\tr0 = returnFunc(ctx, info)\n\t} else {\n\t\tr0 = ret.Get(0).(int64)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, rpc.AgentInfo) error); ok {\n\t\tr1 = returnFunc(ctx, info)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockPeer_RegisterAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterAgent'\ntype MockPeer_RegisterAgent_Call struct {\n\t*mock.Call\n}\n\n// RegisterAgent is a helper method to define mock.On call\n//   - ctx context.Context\n//   - info rpc.AgentInfo\nfunc (_e *MockPeer_Expecter) RegisterAgent(ctx interface{}, info interface{}) *MockPeer_RegisterAgent_Call {\n\treturn &MockPeer_RegisterAgent_Call{Call: _e.mock.On(\"RegisterAgent\", ctx, info)}\n}\n\nfunc (_c *MockPeer_RegisterAgent_Call) Run(run func(ctx context.Context, info rpc.AgentInfo)) *MockPeer_RegisterAgent_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 rpc.AgentInfo\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(rpc.AgentInfo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_RegisterAgent_Call) Return(n int64, err error) *MockPeer_RegisterAgent_Call {\n\t_c.Call.Return(n, err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_RegisterAgent_Call) RunAndReturn(run func(ctx context.Context, info rpc.AgentInfo) (int64, error)) *MockPeer_RegisterAgent_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ReportHealth provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) ReportHealth(c context.Context) error {\n\tret := _mock.Called(c)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ReportHealth\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {\n\t\tr0 = returnFunc(c)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_ReportHealth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReportHealth'\ntype MockPeer_ReportHealth_Call struct {\n\t*mock.Call\n}\n\n// ReportHealth is a helper method to define mock.On call\n//   - c context.Context\nfunc (_e *MockPeer_Expecter) ReportHealth(c interface{}) *MockPeer_ReportHealth_Call {\n\treturn &MockPeer_ReportHealth_Call{Call: _e.mock.On(\"ReportHealth\", c)}\n}\n\nfunc (_c *MockPeer_ReportHealth_Call) Run(run func(c context.Context)) *MockPeer_ReportHealth_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_ReportHealth_Call) Return(err error) *MockPeer_ReportHealth_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_ReportHealth_Call) RunAndReturn(run func(c context.Context) error) *MockPeer_ReportHealth_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UnregisterAgent provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) UnregisterAgent(ctx context.Context) error {\n\tret := _mock.Called(ctx)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UnregisterAgent\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {\n\t\tr0 = returnFunc(ctx)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_UnregisterAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterAgent'\ntype MockPeer_UnregisterAgent_Call struct {\n\t*mock.Call\n}\n\n// UnregisterAgent is a helper method to define mock.On call\n//   - ctx context.Context\nfunc (_e *MockPeer_Expecter) UnregisterAgent(ctx interface{}) *MockPeer_UnregisterAgent_Call {\n\treturn &MockPeer_UnregisterAgent_Call{Call: _e.mock.On(\"UnregisterAgent\", ctx)}\n}\n\nfunc (_c *MockPeer_UnregisterAgent_Call) Run(run func(ctx context.Context)) *MockPeer_UnregisterAgent_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_UnregisterAgent_Call) Return(err error) *MockPeer_UnregisterAgent_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_UnregisterAgent_Call) RunAndReturn(run func(ctx context.Context) error) *MockPeer_UnregisterAgent_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Update provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Update(c context.Context, workflowID string, state rpc.StepState) error {\n\tret := _mock.Called(c, workflowID, state)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Update\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.StepState) error); ok {\n\t\tr0 = returnFunc(c, workflowID, state)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockPeer_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'\ntype MockPeer_Update_Call struct {\n\t*mock.Call\n}\n\n// Update is a helper method to define mock.On call\n//   - c context.Context\n//   - workflowID string\n//   - state rpc.StepState\nfunc (_e *MockPeer_Expecter) Update(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Update_Call {\n\treturn &MockPeer_Update_Call{Call: _e.mock.On(\"Update\", c, workflowID, state)}\n}\n\nfunc (_c *MockPeer_Update_Call) Run(run func(c context.Context, workflowID string, state rpc.StepState)) *MockPeer_Update_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 rpc.StepState\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(rpc.StepState)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Update_Call) Return(err error) *MockPeer_Update_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Update_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.StepState) error) *MockPeer_Update_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Version provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Version(c context.Context) (*rpc.Version, error) {\n\tret := _mock.Called(c)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Version\")\n\t}\n\n\tvar r0 *rpc.Version\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) (*rpc.Version, error)); ok {\n\t\treturn returnFunc(c)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) *rpc.Version); ok {\n\t\tr0 = returnFunc(c)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*rpc.Version)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {\n\t\tr1 = returnFunc(c)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockPeer_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version'\ntype MockPeer_Version_Call struct {\n\t*mock.Call\n}\n\n// Version is a helper method to define mock.On call\n//   - c context.Context\nfunc (_e *MockPeer_Expecter) Version(c interface{}) *MockPeer_Version_Call {\n\treturn &MockPeer_Version_Call{Call: _e.mock.On(\"Version\", c)}\n}\n\nfunc (_c *MockPeer_Version_Call) Run(run func(c context.Context)) *MockPeer_Version_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Version_Call) Return(version *rpc.Version, err error) *MockPeer_Version_Call {\n\t_c.Call.Return(version, err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Version_Call) RunAndReturn(run func(c context.Context) (*rpc.Version, error)) *MockPeer_Version_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Wait provides a mock function for the type MockPeer\nfunc (_mock *MockPeer) Wait(c context.Context, workflowID string) (bool, error) {\n\tret := _mock.Called(c, workflowID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Wait\")\n\t}\n\n\tvar r0 bool\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {\n\t\treturn returnFunc(c, workflowID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {\n\t\tr0 = returnFunc(c, workflowID)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {\n\t\tr1 = returnFunc(c, workflowID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockPeer_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait'\ntype MockPeer_Wait_Call struct {\n\t*mock.Call\n}\n\n// Wait is a helper method to define mock.On call\n//   - c context.Context\n//   - workflowID string\nfunc (_e *MockPeer_Expecter) Wait(c interface{}, workflowID interface{}) *MockPeer_Wait_Call {\n\treturn &MockPeer_Wait_Call{Call: _e.mock.On(\"Wait\", c, workflowID)}\n}\n\nfunc (_c *MockPeer_Wait_Call) Run(run func(c context.Context, workflowID string)) *MockPeer_Wait_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockPeer_Wait_Call) Return(canceled bool, err error) *MockPeer_Wait_Call {\n\t_c.Call.Return(canceled, err)\n\treturn _c\n}\n\nfunc (_c *MockPeer_Wait_Call) RunAndReturn(run func(c context.Context, workflowID string) (bool, error)) *MockPeer_Wait_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "rpc/peer.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport \"context\"\n\n// Peer defines the bidirectional communication interface between Woodpecker agents and servers.\n//\n// # Architecture and Implementations\n//\n// The Peer interface is implemented differently on each side of the communication:\n//\n//   - Agent side: Implemented by agent/rpc/client_grpc.go's client struct, which wraps\n//     a gRPC client connection to make RPC calls to the server.\n//\n//   - Server side: Implemented by server/rpc/rpc.go's RPC struct, which contains the\n//     business logic and is wrapped by server/rpc/server.go's WoodpeckerServer struct\n//     to handle incoming gRPC requests.\n//\n// # Thread Safety and Concurrency\n//\n//   - Implementations must be safe for concurrent calls across different workflows\n//   - The same Peer instance may be called concurrently from multiple goroutines\n//   - Each workflow is identified by a unique workflowID string\n//   - Implementations must properly isolate workflow state using workflowID\n//\n// # Error Handling Conventions\n//\n//   - Methods return errors for communication failures, validation errors, or server-side issues\n//   - Errors should not be used for business logic\n//   - Network/transport errors should be retried by the caller when appropriate\n//   - Nil error indicates successful operation\n//   - Context cancellation should return nil or context.Canceled, not a custom error\n//   - Business logic errors (e.g., workflow not found) return specific error types\n//\n// # Intended Execution Flow\n//\n//  1. Agent Lifecycle:\n//     - Version() checks compatibility with server\n//     - RegisterAgent() announces agent availability\n//     - ReportHealth() periodically confirms agent is alive\n//     - UnregisterAgent() gracefully disconnects agent\n//\n//  2. Workflow Execution (may happen concurrently for multiple workflows):\n//     - Next() blocks until server assigns a workflow\n//     - Init() signals workflow execution has started\n//     - Wait() (in background goroutine) monitors for cancellation signals\n//     - Update() reports step state changes as workflow progresses\n//     - EnqueueLog() streams log output from steps\n//     - Extend() extends workflow timeout if needed so queue does not reschedule it as retry\n//     - Done() signals workflow has completed\n//\n//  3. Cancellation Flow:\n//     - Server can cancel workflow by releasing Wait() with canceled=true\n//     - Agent detects cancellation from Wait() return value\n//     - Agent stops workflow execution and calls Done() with canceled state\ntype Peer interface {\n\t// Version returns the server and gRPC protocol version information.\n\t//\n\t// This is typically called once during agent initialization to verify\n\t// compatibility between agent and server versions.\n\t//\n\t// Returns:\n\t//   - Version with server version string and gRPC protocol version number\n\t//   - Error if communication fails or server is unreachable\n\tVersion(c context.Context) (*Version, error)\n\n\t// Next blocks until the server provides the next workflow to execute from the queue.\n\t//\n\t// This is the primary work-polling mechanism. Agents call this repeatedly in a loop,\n\t// and it blocks until either:\n\t//   1. A workflow matching the filter becomes available\n\t//   2. The context is canceled (agent shutdown, network timeout, etc.)\n\t//\n\t// The filter allows agents to specify capabilities via labels (e.g., platform,\n\t// backend type) so the server only assigns compatible workflows.\n\t//\n\t// Context Handling:\n\t//   - This is a long-polling operation that may block for extended periods\n\t//   - Implementations MUST check context regularly (not just at entry)\n\t//   - When context is canceled, must return nil workflow and nil error\n\t//   - Server may send keep-alive signals or periodically return nil to allow reconnection\n\t//\n\t// Returns:\n\t//   - Workflow object with ID, Config, and Workflow.Timeout if work is available\n\t//   - nil, nil if context is canceled or no work available (retry expected)\n\t//   - nil, error if a non-retryable error occurs\n\tNext(c context.Context, f Filter) (*Workflow, error)\n\n\t// Wait blocks until the workflow with the given ID completes or is canceled by the server.\n\t//\n\t// This is used by agents to monitor for server-side cancellation signals. Typically\n\t// called in a background goroutine immediately after Init(), running concurrently\n\t// with workflow execution.\n\t//\n\t// The method serves two purposes:\n\t//   1. Signals when server wants to cancel workflow (canceled=true)\n\t//   2. Unblocks when workflow completes normally on agent (canceled=false)\n\t//\n\t// Context Handling:\n\t//   - This is a long-running blocking operation for the workflow duration\n\t//   - Context cancellation indicates shutdown, not workflow cancellation\n\t//   - When context is canceled, should return (false, nil) or (false, ctx.Err())\n\t//   - Must not confuse context cancellation with workflow cancellation signal\n\t//\n\t// Cancellation Flow:\n\t//   - Server releases Wait() with canceled=true → agent should stop workflow\n\t//   - Agent completes workflow normally → Done() is called → server releases Wait() with canceled=false\n\t//   - Agent context canceled → Wait() returns immediately, workflow may continue on agent\n\t//\n\t// Returns:\n\t//   - canceled=true, err=nil: Server initiated cancellation, agent should stop workflow\n\t//   - canceled=false, err=nil: Workflow completed normally (Wait unblocked by Done call)\n\t//   - canceled=false, err!=nil: Communication error, agent should retry or handle error\n\tWait(c context.Context, workflowID string) (canceled bool, err error)\n\n\t// Init signals to the server that the workflow has been initialized and execution has started.\n\t//\n\t// This is called once per workflow immediately after the agent accepts it from Next()\n\t// and before starting step execution. It allows the server to track workflow start time\n\t// and update workflow status to \"running\".\n\t//\n\t// The WorkflowState should have:\n\t//   - Started: Unix timestamp when execution began\n\t//   - Finished: 0 (not finished yet)\n\t//   - Error: empty string (no error yet)\n\t//   - Canceled: false (not canceled yet)\n\t//\n\t// Returns:\n\t//   - nil on success\n\t//   - error if communication fails or server rejects the state\n\tInit(c context.Context, workflowID string, state WorkflowState) error\n\n\t// Done signals to the server that the workflow has completed execution.\n\t//\n\t// This is called once per workflow after all steps have finished (or workflow was canceled).\n\t// It provides the final workflow state including completion time, any errors, and\n\t// cancellation status.\n\t//\n\t// The WorkflowState should have:\n\t//   - Started: Unix timestamp when execution began (same as Init)\n\t//   - Finished: Unix timestamp when execution completed\n\t//   - Error: Error message if workflow failed, empty if successful\n\t//   - Canceled: true if workflow was canceled, false otherwise\n\t//\n\t// After Done() is called:\n\t//   - Server updates final workflow status in database\n\t//   - Server releases any Wait() calls for this workflow\n\t//   - Server removes workflow from active queue\n\t//   - Server notifies forge of workflow completion\n\t//\n\t// Context Handling:\n\t//   - MUST attempt to complete even if workflow context is canceled\n\t//   - Often called with a shutdown/cleanup context rather than workflow context\n\t//   - Critical for proper cleanup - should retry on transient failures\n\t//\n\t// Returns:\n\t//   - nil on success\n\t//   - error if communication fails or server rejects the state\n\tDone(c context.Context, workflowID string, state WorkflowState) error\n\n\t// Extend extends the timeout for the workflow with the given ID in the task queue.\n\t//\n\t// Agents must call Extend() regularly (e.g., every constant.TaskTimeout / 3) to signal\n\t// that the workflow is still actively executing and prevent premature timeout.\n\t//\n\t// If agents don't call Extend periodically, the workflow will be rescheduled to a new\n\t// agent after the timeout period expires (specified in constant.TaskTimeout).\n\t//\n\t// This acts as a heartbeat mechanism to detect stuck workflow executions. If an agent\n\t// dies or becomes unresponsive, the server will eventually timeout the workflow and\n\t// reassign it.\n\t//\n\t// IMPORTANT: Don't confuse this with Workflow.Timeout returned by Next() - they serve\n\t// different purposes!\n\t//\n\t// Returns:\n\t//   - nil on success (timeout was extended)\n\t//   - error if communication fails or workflow is not found\n\tExtend(c context.Context, workflowID string) error\n\n\t// Update reports step state changes to the server as the workflow progresses.\n\t//\n\t// This is called multiple times per step:\n\t//   1. When step starts (Exited=false, Finished=0)\n\t//   2. When step completes (Exited=true, Finished and ExitCode set)\n\t//   3. Potentially on progress updates if step has long-running operations\n\t//\n\t// The server uses these updates to:\n\t//   - Track step execution progress\n\t//   - Update UI with real-time status\n\t//   - Store step results in database\n\t//   - Calculate workflow completion\n\t//\n\t// Context Handling:\n\t//   - Failures should be logged but not block workflow execution\n\t//\n\t// Returns:\n\t//   - nil on success\n\t//   - error if communication fails or server rejects the state\n\tUpdate(c context.Context, workflowID string, state StepState) error\n\n\t// EnqueueLog queues a log entry for delayed batch sending to the server.\n\t//\n\t// Log entries are produced continuously during step execution and need to be\n\t// transmitted efficiently. This method adds logs to an internal queue that\n\t// batches and sends them periodically to reduce network overhead.\n\t//\n\t// The implementation should:\n\t//   - Queue the log entry in a memory buffer\n\t//   - Batch multiple entries together\n\t//   - Send batches periodically (e.g., every second) or when buffer fills\n\t//   - Handle backpressure if server is slow or network is congested\n\t//\n\t// Unlike other methods, EnqueueLog:\n\t//   - Does NOT take a context parameter (fire-and-forget)\n\t//   - Does NOT return an error (never blocks the caller)\n\t//   - Does NOT guarantee immediate transmission\n\t//\n\t// Thread Safety:\n\t//   - MUST be safe to call concurrently from multiple goroutines\n\t//   - May be called concurrently from different steps/workflows\n\t//   - Internal queue must be properly synchronized\n\tEnqueueLog(logEntry *LogEntry)\n\n\t// RegisterAgent announces this agent to the server and returns an agent ID.\n\t//\n\t// This is called once during agent startup to:\n\t//   - Create an agent record in the server database\n\t//   - Obtain a unique agent ID for subsequent requests\n\t//   - Declare agent capabilities (platform, backend, capacity, labels)\n\t//   - Enable server-side agent tracking and monitoring\n\t//\n\t// The AgentInfo should specify:\n\t//   - Version: Agent version string (e.g., \"v2.0.0\")\n\t//   - Platform: OS/architecture (e.g., \"linux/amd64\")\n\t//   - Backend: Execution backend (e.g., \"docker\", \"kubernetes\")\n\t//   - Capacity: Maximum concurrent workflows (e.g., 2)\n\t//   - CustomLabels: Additional key-value labels for filtering\n\t//\n\t// Context Handling:\n\t//   - Context cancellation indicates agent is aborting startup\n\t//   - Should not retry indefinitely - fail fast on persistent errors\n\t//\n\t// Returns:\n\t//   - agentID: Unique identifier for this agent (use in subsequent calls)\n\t//   - error: If registration fails\n\tRegisterAgent(ctx context.Context, info AgentInfo) (int64, error)\n\n\t// UnregisterAgent removes this agent from the server's registry.\n\t//\n\t// This is called during graceful agent shutdown to:\n\t//   - Mark agent as offline in server database\n\t//   - Allow server to stop assigning workflows to this agent\n\t//   - Clean up any agent-specific server resources\n\t//   - Provide clean shutdown signal to monitoring systems\n\t//\n\t// After UnregisterAgent:\n\t//   - Agent should stop calling Next() for new work\n\t//   - Agent should complete any in-progress workflows\n\t//   - Agent may call Done() to finish existing workflows\n\t//   - Agent should close network connections\n\t//\n\t// Context Handling:\n\t//   - MUST attempt to complete even during forced shutdown\n\t//   - Often called with a shutdown context (limited time)\n\t//   - Failure is logged but should not prevent agent exit\n\t//\n\t// Returns:\n\t//   - nil on success\n\t//   - error if communication fails\n\tUnregisterAgent(ctx context.Context) error\n\n\t// ReportHealth sends a periodic health status update to the server.\n\t//\n\t// This is called regularly (e.g., every 30 seconds) during agent operation to:\n\t//   - Prove agent is still alive and responsive\n\t//   - Allow server to detect dead or stuck agents\n\t//   - Update agent's \"last seen\" timestamp in database\n\t//   - Provide application-level keepalive beyond network keep-alive signals\n\t//\n\t// Health reporting helps the server:\n\t//   - Mark unresponsive agents as offline\n\t//   - Redistribute work from dead agents\n\t//   - Display accurate agent status in UI\n\t//   - Trigger alerts for infrastructure issues\n\t//\n\t// Returns:\n\t//   - nil on success\n\t//   - error if communication fails\n\tReportHealth(c context.Context) error\n\n\t// IsConnected returns true if the gRPC connection to the server is in Ready state.\n\t//\n\t// This can be used to check if the server is reachable before attempting\n\t// operations that require a connection (like UnregisterAgent).\n\t//\n\t// Returns:\n\t//   - true if connection is Ready\n\t//   - false otherwise (Idle, Connecting, Shutdown, or TransientFailure)\n\tIsConnected() bool\n}\n"
  },
  {
    "path": "rpc/proto/generate.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proto\n\n//go:generate protoc --go_out=paths=source_relative:. woodpecker.proto\n//go:generate protoc --go-grpc_out=paths=source_relative:. woodpecker.proto\n"
  },
  {
    "path": "rpc/proto/version.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proto\n\n// Version is the version of the woodpecker.proto file,\n// IMPORTANT: increased by 1 each time it get changed.\nconst Version int32 = 16\n"
  },
  {
    "path": "rpc/proto/woodpecker.pb.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.36.11\n// \tprotoc        v6.33.1\n// source: woodpecker.proto\n\npackage proto\n\nimport (\n\tprotoreflect \"google.golang.org/protobuf/reflect/protoreflect\"\n\tprotoimpl \"google.golang.org/protobuf/runtime/protoimpl\"\n\treflect \"reflect\"\n\tsync \"sync\"\n\tunsafe \"unsafe\"\n)\n\nconst (\n\t// Verify that this generated code is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)\n\t// Verify that runtime/protoimpl is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)\n)\n\ntype StepState struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tStepUuid      string                 `protobuf:\"bytes,1,opt,name=step_uuid,json=stepUuid,proto3\" json:\"step_uuid,omitempty\"`\n\tStarted       int64                  `protobuf:\"varint,2,opt,name=started,proto3\" json:\"started,omitempty\"`\n\tFinished      int64                  `protobuf:\"varint,3,opt,name=finished,proto3\" json:\"finished,omitempty\"`\n\tExited        bool                   `protobuf:\"varint,4,opt,name=exited,proto3\" json:\"exited,omitempty\"`\n\tExitCode      int32                  `protobuf:\"varint,5,opt,name=exit_code,json=exitCode,proto3\" json:\"exit_code,omitempty\"`\n\tError         string                 `protobuf:\"bytes,6,opt,name=error,proto3\" json:\"error,omitempty\"`\n\tCanceled      bool                   `protobuf:\"varint,7,opt,name=canceled,proto3\" json:\"canceled,omitempty\"`\n\tSkipped       bool                   `protobuf:\"varint,8,opt,name=skipped,proto3\" json:\"skipped,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *StepState) Reset() {\n\t*x = StepState{}\n\tmi := &file_woodpecker_proto_msgTypes[0]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *StepState) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*StepState) ProtoMessage() {}\n\nfunc (x *StepState) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[0]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use StepState.ProtoReflect.Descriptor instead.\nfunc (*StepState) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{0}\n}\n\nfunc (x *StepState) GetStepUuid() string {\n\tif x != nil {\n\t\treturn x.StepUuid\n\t}\n\treturn \"\"\n}\n\nfunc (x *StepState) GetStarted() int64 {\n\tif x != nil {\n\t\treturn x.Started\n\t}\n\treturn 0\n}\n\nfunc (x *StepState) GetFinished() int64 {\n\tif x != nil {\n\t\treturn x.Finished\n\t}\n\treturn 0\n}\n\nfunc (x *StepState) GetExited() bool {\n\tif x != nil {\n\t\treturn x.Exited\n\t}\n\treturn false\n}\n\nfunc (x *StepState) GetExitCode() int32 {\n\tif x != nil {\n\t\treturn x.ExitCode\n\t}\n\treturn 0\n}\n\nfunc (x *StepState) GetError() string {\n\tif x != nil {\n\t\treturn x.Error\n\t}\n\treturn \"\"\n}\n\nfunc (x *StepState) GetCanceled() bool {\n\tif x != nil {\n\t\treturn x.Canceled\n\t}\n\treturn false\n}\n\nfunc (x *StepState) GetSkipped() bool {\n\tif x != nil {\n\t\treturn x.Skipped\n\t}\n\treturn false\n}\n\ntype WorkflowState struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tStarted       int64                  `protobuf:\"varint,1,opt,name=started,proto3\" json:\"started,omitempty\"`\n\tFinished      int64                  `protobuf:\"varint,2,opt,name=finished,proto3\" json:\"finished,omitempty\"`\n\tError         string                 `protobuf:\"bytes,3,opt,name=error,proto3\" json:\"error,omitempty\"`\n\tCanceled      bool                   `protobuf:\"varint,4,opt,name=canceled,proto3\" json:\"canceled,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *WorkflowState) Reset() {\n\t*x = WorkflowState{}\n\tmi := &file_woodpecker_proto_msgTypes[1]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *WorkflowState) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*WorkflowState) ProtoMessage() {}\n\nfunc (x *WorkflowState) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[1]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use WorkflowState.ProtoReflect.Descriptor instead.\nfunc (*WorkflowState) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{1}\n}\n\nfunc (x *WorkflowState) GetStarted() int64 {\n\tif x != nil {\n\t\treturn x.Started\n\t}\n\treturn 0\n}\n\nfunc (x *WorkflowState) GetFinished() int64 {\n\tif x != nil {\n\t\treturn x.Finished\n\t}\n\treturn 0\n}\n\nfunc (x *WorkflowState) GetError() string {\n\tif x != nil {\n\t\treturn x.Error\n\t}\n\treturn \"\"\n}\n\nfunc (x *WorkflowState) GetCanceled() bool {\n\tif x != nil {\n\t\treturn x.Canceled\n\t}\n\treturn false\n}\n\ntype LogEntry struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tStepUuid      string                 `protobuf:\"bytes,1,opt,name=step_uuid,json=stepUuid,proto3\" json:\"step_uuid,omitempty\"`\n\tTime          int64                  `protobuf:\"varint,2,opt,name=time,proto3\" json:\"time,omitempty\"`\n\tLine          int32                  `protobuf:\"varint,3,opt,name=line,proto3\" json:\"line,omitempty\"`\n\tType          int32                  `protobuf:\"varint,4,opt,name=type,proto3\" json:\"type,omitempty\"` // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress\n\tData          []byte                 `protobuf:\"bytes,5,opt,name=data,proto3\" json:\"data,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *LogEntry) Reset() {\n\t*x = LogEntry{}\n\tmi := &file_woodpecker_proto_msgTypes[2]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *LogEntry) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*LogEntry) ProtoMessage() {}\n\nfunc (x *LogEntry) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[2]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead.\nfunc (*LogEntry) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{2}\n}\n\nfunc (x *LogEntry) GetStepUuid() string {\n\tif x != nil {\n\t\treturn x.StepUuid\n\t}\n\treturn \"\"\n}\n\nfunc (x *LogEntry) GetTime() int64 {\n\tif x != nil {\n\t\treturn x.Time\n\t}\n\treturn 0\n}\n\nfunc (x *LogEntry) GetLine() int32 {\n\tif x != nil {\n\t\treturn x.Line\n\t}\n\treturn 0\n}\n\nfunc (x *LogEntry) GetType() int32 {\n\tif x != nil {\n\t\treturn x.Type\n\t}\n\treturn 0\n}\n\nfunc (x *LogEntry) GetData() []byte {\n\tif x != nil {\n\t\treturn x.Data\n\t}\n\treturn nil\n}\n\ntype Filter struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tLabels        map[string]string      `protobuf:\"bytes,1,rep,name=labels,proto3\" json:\"labels,omitempty\" protobuf_key:\"bytes,1,opt,name=key\" protobuf_val:\"bytes,2,opt,name=value\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *Filter) Reset() {\n\t*x = Filter{}\n\tmi := &file_woodpecker_proto_msgTypes[3]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *Filter) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Filter) ProtoMessage() {}\n\nfunc (x *Filter) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[3]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Filter.ProtoReflect.Descriptor instead.\nfunc (*Filter) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{3}\n}\n\nfunc (x *Filter) GetLabels() map[string]string {\n\tif x != nil {\n\t\treturn x.Labels\n\t}\n\treturn nil\n}\n\ntype Workflow struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tTimeout       int64                  `protobuf:\"varint,2,opt,name=timeout,proto3\" json:\"timeout,omitempty\"`\n\tPayload       []byte                 `protobuf:\"bytes,3,opt,name=payload,proto3\" json:\"payload,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *Workflow) Reset() {\n\t*x = Workflow{}\n\tmi := &file_woodpecker_proto_msgTypes[4]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *Workflow) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Workflow) ProtoMessage() {}\n\nfunc (x *Workflow) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[4]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Workflow.ProtoReflect.Descriptor instead.\nfunc (*Workflow) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{4}\n}\n\nfunc (x *Workflow) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *Workflow) GetTimeout() int64 {\n\tif x != nil {\n\t\treturn x.Timeout\n\t}\n\treturn 0\n}\n\nfunc (x *Workflow) GetPayload() []byte {\n\tif x != nil {\n\t\treturn x.Payload\n\t}\n\treturn nil\n}\n\ntype NextRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tFilter        *Filter                `protobuf:\"bytes,1,opt,name=filter,proto3\" json:\"filter,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *NextRequest) Reset() {\n\t*x = NextRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[5]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *NextRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*NextRequest) ProtoMessage() {}\n\nfunc (x *NextRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[5]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use NextRequest.ProtoReflect.Descriptor instead.\nfunc (*NextRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{5}\n}\n\nfunc (x *NextRequest) GetFilter() *Filter {\n\tif x != nil {\n\t\treturn x.Filter\n\t}\n\treturn nil\n}\n\ntype InitRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tState         *WorkflowState         `protobuf:\"bytes,2,opt,name=state,proto3\" json:\"state,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *InitRequest) Reset() {\n\t*x = InitRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[6]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *InitRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*InitRequest) ProtoMessage() {}\n\nfunc (x *InitRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[6]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use InitRequest.ProtoReflect.Descriptor instead.\nfunc (*InitRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{6}\n}\n\nfunc (x *InitRequest) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *InitRequest) GetState() *WorkflowState {\n\tif x != nil {\n\t\treturn x.State\n\t}\n\treturn nil\n}\n\ntype WaitRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *WaitRequest) Reset() {\n\t*x = WaitRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[7]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *WaitRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*WaitRequest) ProtoMessage() {}\n\nfunc (x *WaitRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[7]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use WaitRequest.ProtoReflect.Descriptor instead.\nfunc (*WaitRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{7}\n}\n\nfunc (x *WaitRequest) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\ntype DoneRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tState         *WorkflowState         `protobuf:\"bytes,2,opt,name=state,proto3\" json:\"state,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *DoneRequest) Reset() {\n\t*x = DoneRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[8]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *DoneRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*DoneRequest) ProtoMessage() {}\n\nfunc (x *DoneRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[8]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use DoneRequest.ProtoReflect.Descriptor instead.\nfunc (*DoneRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{8}\n}\n\nfunc (x *DoneRequest) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *DoneRequest) GetState() *WorkflowState {\n\tif x != nil {\n\t\treturn x.State\n\t}\n\treturn nil\n}\n\ntype ExtendRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *ExtendRequest) Reset() {\n\t*x = ExtendRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[9]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *ExtendRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ExtendRequest) ProtoMessage() {}\n\nfunc (x *ExtendRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[9]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ExtendRequest.ProtoReflect.Descriptor instead.\nfunc (*ExtendRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{9}\n}\n\nfunc (x *ExtendRequest) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\ntype UpdateRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tId            string                 `protobuf:\"bytes,1,opt,name=id,proto3\" json:\"id,omitempty\"`\n\tState         *StepState             `protobuf:\"bytes,2,opt,name=state,proto3\" json:\"state,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *UpdateRequest) Reset() {\n\t*x = UpdateRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[10]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *UpdateRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*UpdateRequest) ProtoMessage() {}\n\nfunc (x *UpdateRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[10]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead.\nfunc (*UpdateRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{10}\n}\n\nfunc (x *UpdateRequest) GetId() string {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn \"\"\n}\n\nfunc (x *UpdateRequest) GetState() *StepState {\n\tif x != nil {\n\t\treturn x.State\n\t}\n\treturn nil\n}\n\ntype LogRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tLogEntries    []*LogEntry            `protobuf:\"bytes,1,rep,name=logEntries,proto3\" json:\"logEntries,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *LogRequest) Reset() {\n\t*x = LogRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[11]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *LogRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*LogRequest) ProtoMessage() {}\n\nfunc (x *LogRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[11]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use LogRequest.ProtoReflect.Descriptor instead.\nfunc (*LogRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{11}\n}\n\nfunc (x *LogRequest) GetLogEntries() []*LogEntry {\n\tif x != nil {\n\t\treturn x.LogEntries\n\t}\n\treturn nil\n}\n\ntype Empty struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *Empty) Reset() {\n\t*x = Empty{}\n\tmi := &file_woodpecker_proto_msgTypes[12]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *Empty) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*Empty) ProtoMessage() {}\n\nfunc (x *Empty) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[12]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use Empty.ProtoReflect.Descriptor instead.\nfunc (*Empty) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{12}\n}\n\ntype ReportHealthRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tStatus        string                 `protobuf:\"bytes,1,opt,name=status,proto3\" json:\"status,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *ReportHealthRequest) Reset() {\n\t*x = ReportHealthRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[13]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *ReportHealthRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*ReportHealthRequest) ProtoMessage() {}\n\nfunc (x *ReportHealthRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[13]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use ReportHealthRequest.ProtoReflect.Descriptor instead.\nfunc (*ReportHealthRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{13}\n}\n\nfunc (x *ReportHealthRequest) GetStatus() string {\n\tif x != nil {\n\t\treturn x.Status\n\t}\n\treturn \"\"\n}\n\ntype AgentInfo struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tPlatform      string                 `protobuf:\"bytes,1,opt,name=platform,proto3\" json:\"platform,omitempty\"`\n\tCapacity      int32                  `protobuf:\"varint,2,opt,name=capacity,proto3\" json:\"capacity,omitempty\"`\n\tBackend       string                 `protobuf:\"bytes,3,opt,name=backend,proto3\" json:\"backend,omitempty\"`\n\tVersion       string                 `protobuf:\"bytes,4,opt,name=version,proto3\" json:\"version,omitempty\"`\n\tCustomLabels  map[string]string      `protobuf:\"bytes,5,rep,name=customLabels,proto3\" json:\"customLabels,omitempty\" protobuf_key:\"bytes,1,opt,name=key\" protobuf_val:\"bytes,2,opt,name=value\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *AgentInfo) Reset() {\n\t*x = AgentInfo{}\n\tmi := &file_woodpecker_proto_msgTypes[14]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *AgentInfo) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AgentInfo) ProtoMessage() {}\n\nfunc (x *AgentInfo) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[14]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AgentInfo.ProtoReflect.Descriptor instead.\nfunc (*AgentInfo) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{14}\n}\n\nfunc (x *AgentInfo) GetPlatform() string {\n\tif x != nil {\n\t\treturn x.Platform\n\t}\n\treturn \"\"\n}\n\nfunc (x *AgentInfo) GetCapacity() int32 {\n\tif x != nil {\n\t\treturn x.Capacity\n\t}\n\treturn 0\n}\n\nfunc (x *AgentInfo) GetBackend() string {\n\tif x != nil {\n\t\treturn x.Backend\n\t}\n\treturn \"\"\n}\n\nfunc (x *AgentInfo) GetVersion() string {\n\tif x != nil {\n\t\treturn x.Version\n\t}\n\treturn \"\"\n}\n\nfunc (x *AgentInfo) GetCustomLabels() map[string]string {\n\tif x != nil {\n\t\treturn x.CustomLabels\n\t}\n\treturn nil\n}\n\ntype RegisterAgentRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tInfo          *AgentInfo             `protobuf:\"bytes,1,opt,name=info,proto3\" json:\"info,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *RegisterAgentRequest) Reset() {\n\t*x = RegisterAgentRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[15]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *RegisterAgentRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*RegisterAgentRequest) ProtoMessage() {}\n\nfunc (x *RegisterAgentRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[15]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use RegisterAgentRequest.ProtoReflect.Descriptor instead.\nfunc (*RegisterAgentRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{15}\n}\n\nfunc (x *RegisterAgentRequest) GetInfo() *AgentInfo {\n\tif x != nil {\n\t\treturn x.Info\n\t}\n\treturn nil\n}\n\ntype VersionResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tGrpcVersion   int32                  `protobuf:\"varint,1,opt,name=grpc_version,json=grpcVersion,proto3\" json:\"grpc_version,omitempty\"`\n\tServerVersion string                 `protobuf:\"bytes,2,opt,name=server_version,json=serverVersion,proto3\" json:\"server_version,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *VersionResponse) Reset() {\n\t*x = VersionResponse{}\n\tmi := &file_woodpecker_proto_msgTypes[16]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *VersionResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*VersionResponse) ProtoMessage() {}\n\nfunc (x *VersionResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[16]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use VersionResponse.ProtoReflect.Descriptor instead.\nfunc (*VersionResponse) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{16}\n}\n\nfunc (x *VersionResponse) GetGrpcVersion() int32 {\n\tif x != nil {\n\t\treturn x.GrpcVersion\n\t}\n\treturn 0\n}\n\nfunc (x *VersionResponse) GetServerVersion() string {\n\tif x != nil {\n\t\treturn x.ServerVersion\n\t}\n\treturn \"\"\n}\n\ntype NextResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tWorkflow      *Workflow              `protobuf:\"bytes,1,opt,name=workflow,proto3\" json:\"workflow,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *NextResponse) Reset() {\n\t*x = NextResponse{}\n\tmi := &file_woodpecker_proto_msgTypes[17]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *NextResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*NextResponse) ProtoMessage() {}\n\nfunc (x *NextResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[17]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use NextResponse.ProtoReflect.Descriptor instead.\nfunc (*NextResponse) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{17}\n}\n\nfunc (x *NextResponse) GetWorkflow() *Workflow {\n\tif x != nil {\n\t\treturn x.Workflow\n\t}\n\treturn nil\n}\n\ntype RegisterAgentResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tAgentId       int64                  `protobuf:\"varint,1,opt,name=agent_id,json=agentId,proto3\" json:\"agent_id,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *RegisterAgentResponse) Reset() {\n\t*x = RegisterAgentResponse{}\n\tmi := &file_woodpecker_proto_msgTypes[18]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *RegisterAgentResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*RegisterAgentResponse) ProtoMessage() {}\n\nfunc (x *RegisterAgentResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[18]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use RegisterAgentResponse.ProtoReflect.Descriptor instead.\nfunc (*RegisterAgentResponse) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{18}\n}\n\nfunc (x *RegisterAgentResponse) GetAgentId() int64 {\n\tif x != nil {\n\t\treturn x.AgentId\n\t}\n\treturn 0\n}\n\ntype WaitResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tCanceled      bool                   `protobuf:\"varint,1,opt,name=canceled,proto3\" json:\"canceled,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *WaitResponse) Reset() {\n\t*x = WaitResponse{}\n\tmi := &file_woodpecker_proto_msgTypes[19]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *WaitResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*WaitResponse) ProtoMessage() {}\n\nfunc (x *WaitResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[19]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use WaitResponse.ProtoReflect.Descriptor instead.\nfunc (*WaitResponse) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{19}\n}\n\nfunc (x *WaitResponse) GetCanceled() bool {\n\tif x != nil {\n\t\treturn x.Canceled\n\t}\n\treturn false\n}\n\ntype AuthRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tAgentToken    string                 `protobuf:\"bytes,1,opt,name=agent_token,json=agentToken,proto3\" json:\"agent_token,omitempty\"`\n\tAgentId       int64                  `protobuf:\"varint,2,opt,name=agent_id,json=agentId,proto3\" json:\"agent_id,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *AuthRequest) Reset() {\n\t*x = AuthRequest{}\n\tmi := &file_woodpecker_proto_msgTypes[20]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *AuthRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AuthRequest) ProtoMessage() {}\n\nfunc (x *AuthRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[20]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AuthRequest.ProtoReflect.Descriptor instead.\nfunc (*AuthRequest) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{20}\n}\n\nfunc (x *AuthRequest) GetAgentToken() string {\n\tif x != nil {\n\t\treturn x.AgentToken\n\t}\n\treturn \"\"\n}\n\nfunc (x *AuthRequest) GetAgentId() int64 {\n\tif x != nil {\n\t\treturn x.AgentId\n\t}\n\treturn 0\n}\n\ntype AuthResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tStatus        string                 `protobuf:\"bytes,1,opt,name=status,proto3\" json:\"status,omitempty\"`\n\tAgentId       int64                  `protobuf:\"varint,2,opt,name=agent_id,json=agentId,proto3\" json:\"agent_id,omitempty\"`\n\tAccessToken   string                 `protobuf:\"bytes,3,opt,name=access_token,json=accessToken,proto3\" json:\"access_token,omitempty\"`\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *AuthResponse) Reset() {\n\t*x = AuthResponse{}\n\tmi := &file_woodpecker_proto_msgTypes[21]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *AuthResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*AuthResponse) ProtoMessage() {}\n\nfunc (x *AuthResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_woodpecker_proto_msgTypes[21]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use AuthResponse.ProtoReflect.Descriptor instead.\nfunc (*AuthResponse) Descriptor() ([]byte, []int) {\n\treturn file_woodpecker_proto_rawDescGZIP(), []int{21}\n}\n\nfunc (x *AuthResponse) GetStatus() string {\n\tif x != nil {\n\t\treturn x.Status\n\t}\n\treturn \"\"\n}\n\nfunc (x *AuthResponse) GetAgentId() int64 {\n\tif x != nil {\n\t\treturn x.AgentId\n\t}\n\treturn 0\n}\n\nfunc (x *AuthResponse) GetAccessToken() string {\n\tif x != nil {\n\t\treturn x.AccessToken\n\t}\n\treturn \"\"\n}\n\nvar File_woodpecker_proto protoreflect.FileDescriptor\n\nconst file_woodpecker_proto_rawDesc = \"\" +\n\t\"\\n\" +\n\t\"\\x10woodpecker.proto\\x12\\x05proto\\\"\\xdf\\x01\\n\" +\n\t\"\\tStepState\\x12\\x1b\\n\" +\n\t\"\\tstep_uuid\\x18\\x01 \\x01(\\tR\\bstepUuid\\x12\\x18\\n\" +\n\t\"\\astarted\\x18\\x02 \\x01(\\x03R\\astarted\\x12\\x1a\\n\" +\n\t\"\\bfinished\\x18\\x03 \\x01(\\x03R\\bfinished\\x12\\x16\\n\" +\n\t\"\\x06exited\\x18\\x04 \\x01(\\bR\\x06exited\\x12\\x1b\\n\" +\n\t\"\\texit_code\\x18\\x05 \\x01(\\x05R\\bexitCode\\x12\\x14\\n\" +\n\t\"\\x05error\\x18\\x06 \\x01(\\tR\\x05error\\x12\\x1a\\n\" +\n\t\"\\bcanceled\\x18\\a \\x01(\\bR\\bcanceled\\x12\\x18\\n\" +\n\t\"\\askipped\\x18\\b \\x01(\\bR\\askipped\\\"w\\n\" +\n\t\"\\rWorkflowState\\x12\\x18\\n\" +\n\t\"\\astarted\\x18\\x01 \\x01(\\x03R\\astarted\\x12\\x1a\\n\" +\n\t\"\\bfinished\\x18\\x02 \\x01(\\x03R\\bfinished\\x12\\x14\\n\" +\n\t\"\\x05error\\x18\\x03 \\x01(\\tR\\x05error\\x12\\x1a\\n\" +\n\t\"\\bcanceled\\x18\\x04 \\x01(\\bR\\bcanceled\\\"w\\n\" +\n\t\"\\bLogEntry\\x12\\x1b\\n\" +\n\t\"\\tstep_uuid\\x18\\x01 \\x01(\\tR\\bstepUuid\\x12\\x12\\n\" +\n\t\"\\x04time\\x18\\x02 \\x01(\\x03R\\x04time\\x12\\x12\\n\" +\n\t\"\\x04line\\x18\\x03 \\x01(\\x05R\\x04line\\x12\\x12\\n\" +\n\t\"\\x04type\\x18\\x04 \\x01(\\x05R\\x04type\\x12\\x12\\n\" +\n\t\"\\x04data\\x18\\x05 \\x01(\\fR\\x04data\\\"v\\n\" +\n\t\"\\x06Filter\\x121\\n\" +\n\t\"\\x06labels\\x18\\x01 \\x03(\\v2\\x19.proto.Filter.LabelsEntryR\\x06labels\\x1a9\\n\" +\n\t\"\\vLabelsEntry\\x12\\x10\\n\" +\n\t\"\\x03key\\x18\\x01 \\x01(\\tR\\x03key\\x12\\x14\\n\" +\n\t\"\\x05value\\x18\\x02 \\x01(\\tR\\x05value:\\x028\\x01\\\"N\\n\" +\n\t\"\\bWorkflow\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\x12\\x18\\n\" +\n\t\"\\atimeout\\x18\\x02 \\x01(\\x03R\\atimeout\\x12\\x18\\n\" +\n\t\"\\apayload\\x18\\x03 \\x01(\\fR\\apayload\\\"4\\n\" +\n\t\"\\vNextRequest\\x12%\\n\" +\n\t\"\\x06filter\\x18\\x01 \\x01(\\v2\\r.proto.FilterR\\x06filter\\\"I\\n\" +\n\t\"\\vInitRequest\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\x12*\\n\" +\n\t\"\\x05state\\x18\\x02 \\x01(\\v2\\x14.proto.WorkflowStateR\\x05state\\\"\\x1d\\n\" +\n\t\"\\vWaitRequest\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\\"I\\n\" +\n\t\"\\vDoneRequest\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\x12*\\n\" +\n\t\"\\x05state\\x18\\x02 \\x01(\\v2\\x14.proto.WorkflowStateR\\x05state\\\"\\x1f\\n\" +\n\t\"\\rExtendRequest\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\\"G\\n\" +\n\t\"\\rUpdateRequest\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x01 \\x01(\\tR\\x02id\\x12&\\n\" +\n\t\"\\x05state\\x18\\x02 \\x01(\\v2\\x10.proto.StepStateR\\x05state\\\"=\\n\" +\n\t\"\\n\" +\n\t\"LogRequest\\x12/\\n\" +\n\t\"\\n\" +\n\t\"logEntries\\x18\\x01 \\x03(\\v2\\x0f.proto.LogEntryR\\n\" +\n\t\"logEntries\\\"\\a\\n\" +\n\t\"\\x05Empty\\\"-\\n\" +\n\t\"\\x13ReportHealthRequest\\x12\\x16\\n\" +\n\t\"\\x06status\\x18\\x01 \\x01(\\tR\\x06status\\\"\\x80\\x02\\n\" +\n\t\"\\tAgentInfo\\x12\\x1a\\n\" +\n\t\"\\bplatform\\x18\\x01 \\x01(\\tR\\bplatform\\x12\\x1a\\n\" +\n\t\"\\bcapacity\\x18\\x02 \\x01(\\x05R\\bcapacity\\x12\\x18\\n\" +\n\t\"\\abackend\\x18\\x03 \\x01(\\tR\\abackend\\x12\\x18\\n\" +\n\t\"\\aversion\\x18\\x04 \\x01(\\tR\\aversion\\x12F\\n\" +\n\t\"\\fcustomLabels\\x18\\x05 \\x03(\\v2\\\".proto.AgentInfo.CustomLabelsEntryR\\fcustomLabels\\x1a?\\n\" +\n\t\"\\x11CustomLabelsEntry\\x12\\x10\\n\" +\n\t\"\\x03key\\x18\\x01 \\x01(\\tR\\x03key\\x12\\x14\\n\" +\n\t\"\\x05value\\x18\\x02 \\x01(\\tR\\x05value:\\x028\\x01\\\"<\\n\" +\n\t\"\\x14RegisterAgentRequest\\x12$\\n\" +\n\t\"\\x04info\\x18\\x01 \\x01(\\v2\\x10.proto.AgentInfoR\\x04info\\\"[\\n\" +\n\t\"\\x0fVersionResponse\\x12!\\n\" +\n\t\"\\fgrpc_version\\x18\\x01 \\x01(\\x05R\\vgrpcVersion\\x12%\\n\" +\n\t\"\\x0eserver_version\\x18\\x02 \\x01(\\tR\\rserverVersion\\\";\\n\" +\n\t\"\\fNextResponse\\x12+\\n\" +\n\t\"\\bworkflow\\x18\\x01 \\x01(\\v2\\x0f.proto.WorkflowR\\bworkflow\\\"2\\n\" +\n\t\"\\x15RegisterAgentResponse\\x12\\x19\\n\" +\n\t\"\\bagent_id\\x18\\x01 \\x01(\\x03R\\aagentId\\\"*\\n\" +\n\t\"\\fWaitResponse\\x12\\x1a\\n\" +\n\t\"\\bcanceled\\x18\\x01 \\x01(\\bR\\bcanceled\\\"I\\n\" +\n\t\"\\vAuthRequest\\x12\\x1f\\n\" +\n\t\"\\vagent_token\\x18\\x01 \\x01(\\tR\\n\" +\n\t\"agentToken\\x12\\x19\\n\" +\n\t\"\\bagent_id\\x18\\x02 \\x01(\\x03R\\aagentId\\\"d\\n\" +\n\t\"\\fAuthResponse\\x12\\x16\\n\" +\n\t\"\\x06status\\x18\\x01 \\x01(\\tR\\x06status\\x12\\x19\\n\" +\n\t\"\\bagent_id\\x18\\x02 \\x01(\\x03R\\aagentId\\x12!\\n\" +\n\t\"\\faccess_token\\x18\\x03 \\x01(\\tR\\vaccessToken2\\xc2\\x04\\n\" +\n\t\"\\n\" +\n\t\"Woodpecker\\x121\\n\" +\n\t\"\\aVersion\\x12\\f.proto.Empty\\x1a\\x16.proto.VersionResponse\\\"\\x00\\x121\\n\" +\n\t\"\\x04Next\\x12\\x12.proto.NextRequest\\x1a\\x13.proto.NextResponse\\\"\\x00\\x12*\\n\" +\n\t\"\\x04Init\\x12\\x12.proto.InitRequest\\x1a\\f.proto.Empty\\\"\\x00\\x121\\n\" +\n\t\"\\x04Wait\\x12\\x12.proto.WaitRequest\\x1a\\x13.proto.WaitResponse\\\"\\x00\\x12*\\n\" +\n\t\"\\x04Done\\x12\\x12.proto.DoneRequest\\x1a\\f.proto.Empty\\\"\\x00\\x12.\\n\" +\n\t\"\\x06Extend\\x12\\x14.proto.ExtendRequest\\x1a\\f.proto.Empty\\\"\\x00\\x12.\\n\" +\n\t\"\\x06Update\\x12\\x14.proto.UpdateRequest\\x1a\\f.proto.Empty\\\"\\x00\\x12(\\n\" +\n\t\"\\x03Log\\x12\\x11.proto.LogRequest\\x1a\\f.proto.Empty\\\"\\x00\\x12L\\n\" +\n\t\"\\rRegisterAgent\\x12\\x1b.proto.RegisterAgentRequest\\x1a\\x1c.proto.RegisterAgentResponse\\\"\\x00\\x12/\\n\" +\n\t\"\\x0fUnregisterAgent\\x12\\f.proto.Empty\\x1a\\f.proto.Empty\\\"\\x00\\x12:\\n\" +\n\t\"\\fReportHealth\\x12\\x1a.proto.ReportHealthRequest\\x1a\\f.proto.Empty\\\"\\x002C\\n\" +\n\t\"\\x0eWoodpeckerAuth\\x121\\n\" +\n\t\"\\x04Auth\\x12\\x12.proto.AuthRequest\\x1a\\x13.proto.AuthResponse\\\"\\x00B.Z,go.woodpecker-ci.org/woodpecker/v3/rpc/protob\\x06proto3\"\n\nvar (\n\tfile_woodpecker_proto_rawDescOnce sync.Once\n\tfile_woodpecker_proto_rawDescData []byte\n)\n\nfunc file_woodpecker_proto_rawDescGZIP() []byte {\n\tfile_woodpecker_proto_rawDescOnce.Do(func() {\n\t\tfile_woodpecker_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_woodpecker_proto_rawDesc), len(file_woodpecker_proto_rawDesc)))\n\t})\n\treturn file_woodpecker_proto_rawDescData\n}\n\nvar file_woodpecker_proto_msgTypes = make([]protoimpl.MessageInfo, 24)\nvar file_woodpecker_proto_goTypes = []any{\n\t(*StepState)(nil),             // 0: proto.StepState\n\t(*WorkflowState)(nil),         // 1: proto.WorkflowState\n\t(*LogEntry)(nil),              // 2: proto.LogEntry\n\t(*Filter)(nil),                // 3: proto.Filter\n\t(*Workflow)(nil),              // 4: proto.Workflow\n\t(*NextRequest)(nil),           // 5: proto.NextRequest\n\t(*InitRequest)(nil),           // 6: proto.InitRequest\n\t(*WaitRequest)(nil),           // 7: proto.WaitRequest\n\t(*DoneRequest)(nil),           // 8: proto.DoneRequest\n\t(*ExtendRequest)(nil),         // 9: proto.ExtendRequest\n\t(*UpdateRequest)(nil),         // 10: proto.UpdateRequest\n\t(*LogRequest)(nil),            // 11: proto.LogRequest\n\t(*Empty)(nil),                 // 12: proto.Empty\n\t(*ReportHealthRequest)(nil),   // 13: proto.ReportHealthRequest\n\t(*AgentInfo)(nil),             // 14: proto.AgentInfo\n\t(*RegisterAgentRequest)(nil),  // 15: proto.RegisterAgentRequest\n\t(*VersionResponse)(nil),       // 16: proto.VersionResponse\n\t(*NextResponse)(nil),          // 17: proto.NextResponse\n\t(*RegisterAgentResponse)(nil), // 18: proto.RegisterAgentResponse\n\t(*WaitResponse)(nil),          // 19: proto.WaitResponse\n\t(*AuthRequest)(nil),           // 20: proto.AuthRequest\n\t(*AuthResponse)(nil),          // 21: proto.AuthResponse\n\tnil,                           // 22: proto.Filter.LabelsEntry\n\tnil,                           // 23: proto.AgentInfo.CustomLabelsEntry\n}\nvar file_woodpecker_proto_depIdxs = []int32{\n\t22, // 0: proto.Filter.labels:type_name -> proto.Filter.LabelsEntry\n\t3,  // 1: proto.NextRequest.filter:type_name -> proto.Filter\n\t1,  // 2: proto.InitRequest.state:type_name -> proto.WorkflowState\n\t1,  // 3: proto.DoneRequest.state:type_name -> proto.WorkflowState\n\t0,  // 4: proto.UpdateRequest.state:type_name -> proto.StepState\n\t2,  // 5: proto.LogRequest.logEntries:type_name -> proto.LogEntry\n\t23, // 6: proto.AgentInfo.customLabels:type_name -> proto.AgentInfo.CustomLabelsEntry\n\t14, // 7: proto.RegisterAgentRequest.info:type_name -> proto.AgentInfo\n\t4,  // 8: proto.NextResponse.workflow:type_name -> proto.Workflow\n\t12, // 9: proto.Woodpecker.Version:input_type -> proto.Empty\n\t5,  // 10: proto.Woodpecker.Next:input_type -> proto.NextRequest\n\t6,  // 11: proto.Woodpecker.Init:input_type -> proto.InitRequest\n\t7,  // 12: proto.Woodpecker.Wait:input_type -> proto.WaitRequest\n\t8,  // 13: proto.Woodpecker.Done:input_type -> proto.DoneRequest\n\t9,  // 14: proto.Woodpecker.Extend:input_type -> proto.ExtendRequest\n\t10, // 15: proto.Woodpecker.Update:input_type -> proto.UpdateRequest\n\t11, // 16: proto.Woodpecker.Log:input_type -> proto.LogRequest\n\t15, // 17: proto.Woodpecker.RegisterAgent:input_type -> proto.RegisterAgentRequest\n\t12, // 18: proto.Woodpecker.UnregisterAgent:input_type -> proto.Empty\n\t13, // 19: proto.Woodpecker.ReportHealth:input_type -> proto.ReportHealthRequest\n\t20, // 20: proto.WoodpeckerAuth.Auth:input_type -> proto.AuthRequest\n\t16, // 21: proto.Woodpecker.Version:output_type -> proto.VersionResponse\n\t17, // 22: proto.Woodpecker.Next:output_type -> proto.NextResponse\n\t12, // 23: proto.Woodpecker.Init:output_type -> proto.Empty\n\t19, // 24: proto.Woodpecker.Wait:output_type -> proto.WaitResponse\n\t12, // 25: proto.Woodpecker.Done:output_type -> proto.Empty\n\t12, // 26: proto.Woodpecker.Extend:output_type -> proto.Empty\n\t12, // 27: proto.Woodpecker.Update:output_type -> proto.Empty\n\t12, // 28: proto.Woodpecker.Log:output_type -> proto.Empty\n\t18, // 29: proto.Woodpecker.RegisterAgent:output_type -> proto.RegisterAgentResponse\n\t12, // 30: proto.Woodpecker.UnregisterAgent:output_type -> proto.Empty\n\t12, // 31: proto.Woodpecker.ReportHealth:output_type -> proto.Empty\n\t21, // 32: proto.WoodpeckerAuth.Auth:output_type -> proto.AuthResponse\n\t21, // [21:33] is the sub-list for method output_type\n\t9,  // [9:21] is the sub-list for method input_type\n\t9,  // [9:9] is the sub-list for extension type_name\n\t9,  // [9:9] is the sub-list for extension extendee\n\t0,  // [0:9] is the sub-list for field type_name\n}\n\nfunc init() { file_woodpecker_proto_init() }\nfunc file_woodpecker_proto_init() {\n\tif File_woodpecker_proto != nil {\n\t\treturn\n\t}\n\ttype x struct{}\n\tout := protoimpl.TypeBuilder{\n\t\tFile: protoimpl.DescBuilder{\n\t\t\tGoPackagePath: reflect.TypeOf(x{}).PkgPath(),\n\t\t\tRawDescriptor: unsafe.Slice(unsafe.StringData(file_woodpecker_proto_rawDesc), len(file_woodpecker_proto_rawDesc)),\n\t\t\tNumEnums:      0,\n\t\t\tNumMessages:   24,\n\t\t\tNumExtensions: 0,\n\t\t\tNumServices:   2,\n\t\t},\n\t\tGoTypes:           file_woodpecker_proto_goTypes,\n\t\tDependencyIndexes: file_woodpecker_proto_depIdxs,\n\t\tMessageInfos:      file_woodpecker_proto_msgTypes,\n\t}.Build()\n\tFile_woodpecker_proto = out.File\n\tfile_woodpecker_proto_goTypes = nil\n\tfile_woodpecker_proto_depIdxs = nil\n}\n"
  },
  {
    "path": "rpc/proto/woodpecker.proto",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nsyntax = \"proto3\";\n\noption go_package = \"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\";\npackage proto;\n\n// !IMPORTANT!\n// Increased Version in version.go by 1 if you change something here!\n// !IMPORTANT!\n\n// Woodpecker Server Service\nservice Woodpecker {\n  rpc Version         (Empty)                returns (VersionResponse) {}\n  rpc Next            (NextRequest)          returns (NextResponse) {}\n  rpc Init            (InitRequest)          returns (Empty) {}\n  rpc Wait            (WaitRequest)          returns (WaitResponse) {}\n  rpc Done            (DoneRequest)          returns (Empty) {}\n  rpc Extend          (ExtendRequest)        returns (Empty) {}\n  rpc Update          (UpdateRequest)        returns (Empty) {}\n  rpc Log             (LogRequest)           returns (Empty) {}\n  rpc RegisterAgent   (RegisterAgentRequest) returns (RegisterAgentResponse) {}\n  rpc UnregisterAgent (Empty)                returns (Empty) {}\n  rpc ReportHealth    (ReportHealthRequest)  returns (Empty) {}\n}\n\n//\n// Basic Types\n//\n\nmessage StepState {\n  string step_uuid = 1;\n  int64  started = 2;\n  int64  finished = 3;\n  bool   exited = 4;\n  int32  exit_code = 5;\n  string error = 6;\n  bool   canceled = 7;\n  bool   skipped = 8;\n}\n\nmessage WorkflowState {\n  int64  started = 1;\n  int64  finished = 2;\n  string error = 3;\n  bool   canceled = 4;\n}\n\nmessage LogEntry {\n  string step_uuid = 1;\n  int64  time = 2;\n  int32  line = 3;\n  int32  type = 4; // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress\n  bytes  data = 5;\n}\n\nmessage Filter {\n  map<string, string> labels = 1;\n}\n\nmessage Workflow {\n  string id = 1;\n  int64 timeout = 2;\n  bytes payload = 3;\n}\n\n//\n// Request types\n//\n\nmessage NextRequest {\n  Filter filter = 1;\n}\n\nmessage InitRequest {\n  string id = 1;\n  WorkflowState state = 2;\n}\n\nmessage WaitRequest {\n  string id = 1;\n}\n\nmessage DoneRequest {\n  string id = 1;\n  WorkflowState state = 2;\n}\n\nmessage ExtendRequest {\n  string id = 1;\n}\n\nmessage UpdateRequest {\n  string id = 1;\n  StepState state = 2;\n}\n\nmessage LogRequest {\n  repeated LogEntry logEntries = 1;\n}\n\nmessage Empty {\n}\n\nmessage ReportHealthRequest {\n  string status = 1;\n}\n\nmessage AgentInfo {\n  string platform = 1;\n  int32  capacity = 2;\n  string backend  = 3;\n  string version  = 4;\n  map<string, string> customLabels = 5;\n}\n\nmessage RegisterAgentRequest {\n  AgentInfo info = 1;\n}\n\n//\n// Response types\n//\n\nmessage VersionResponse {\n  int32  grpc_version   = 1;\n  string server_version = 2;\n}\n\nmessage NextResponse {\n  Workflow workflow = 1;\n}\n\nmessage RegisterAgentResponse {\n  int64 agent_id = 1;\n}\n\nmessage WaitResponse {\n  bool canceled = 1;\n};\n\n// Woodpecker auth service is a simple service to authenticate agents and acquire a token\n\nservice WoodpeckerAuth {\n  rpc Auth          (AuthRequest)          returns (AuthResponse) {}\n}\n\nmessage AuthRequest {\n  string agent_token = 1;\n  int64  agent_id    = 2;\n}\n\nmessage AuthResponse {\n  string status        = 1;\n  int64  agent_id      = 2;\n  string access_token  = 3;\n}\n"
  },
  {
    "path": "rpc/proto/woodpecker_grpc.pb.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2011 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Code generated by protoc-gen-go-grpc. DO NOT EDIT.\n// versions:\n// - protoc-gen-go-grpc v1.6.1\n// - protoc             v6.33.1\n// source: woodpecker.proto\n\npackage proto\n\nimport (\n\tcontext \"context\"\n\tgrpc \"google.golang.org/grpc\"\n\tcodes \"google.golang.org/grpc/codes\"\n\tstatus \"google.golang.org/grpc/status\"\n)\n\n// This is a compile-time assertion to ensure that this generated file\n// is compatible with the grpc package it is being compiled against.\n// Requires gRPC-Go v1.64.0 or later.\nconst _ = grpc.SupportPackageIsVersion9\n\nconst (\n\tWoodpecker_Version_FullMethodName         = \"/proto.Woodpecker/Version\"\n\tWoodpecker_Next_FullMethodName            = \"/proto.Woodpecker/Next\"\n\tWoodpecker_Init_FullMethodName            = \"/proto.Woodpecker/Init\"\n\tWoodpecker_Wait_FullMethodName            = \"/proto.Woodpecker/Wait\"\n\tWoodpecker_Done_FullMethodName            = \"/proto.Woodpecker/Done\"\n\tWoodpecker_Extend_FullMethodName          = \"/proto.Woodpecker/Extend\"\n\tWoodpecker_Update_FullMethodName          = \"/proto.Woodpecker/Update\"\n\tWoodpecker_Log_FullMethodName             = \"/proto.Woodpecker/Log\"\n\tWoodpecker_RegisterAgent_FullMethodName   = \"/proto.Woodpecker/RegisterAgent\"\n\tWoodpecker_UnregisterAgent_FullMethodName = \"/proto.Woodpecker/UnregisterAgent\"\n\tWoodpecker_ReportHealth_FullMethodName    = \"/proto.Woodpecker/ReportHealth\"\n)\n\n// WoodpeckerClient is the client API for Woodpecker service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\n//\n// Woodpecker Server Service\ntype WoodpeckerClient interface {\n\tVersion(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VersionResponse, error)\n\tNext(ctx context.Context, in *NextRequest, opts ...grpc.CallOption) (*NextResponse, error)\n\tInit(ctx context.Context, in *InitRequest, opts ...grpc.CallOption) (*Empty, error)\n\tWait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitResponse, error)\n\tDone(ctx context.Context, in *DoneRequest, opts ...grpc.CallOption) (*Empty, error)\n\tExtend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*Empty, error)\n\tUpdate(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*Empty, error)\n\tLog(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*Empty, error)\n\tRegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error)\n\tUnregisterAgent(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error)\n\tReportHealth(ctx context.Context, in *ReportHealthRequest, opts ...grpc.CallOption) (*Empty, error)\n}\n\ntype woodpeckerClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewWoodpeckerClient(cc grpc.ClientConnInterface) WoodpeckerClient {\n\treturn &woodpeckerClient{cc}\n}\n\nfunc (c *woodpeckerClient) Version(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VersionResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(VersionResponse)\n\terr := c.cc.Invoke(ctx, Woodpecker_Version_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Next(ctx context.Context, in *NextRequest, opts ...grpc.CallOption) (*NextResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(NextResponse)\n\terr := c.cc.Invoke(ctx, Woodpecker_Next_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Init(ctx context.Context, in *InitRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_Init_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Wait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(WaitResponse)\n\terr := c.cc.Invoke(ctx, Woodpecker_Wait_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Done(ctx context.Context, in *DoneRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_Done_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Extend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_Extend_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_Update_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_Log_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) RegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(RegisterAgentResponse)\n\terr := c.cc.Invoke(ctx, Woodpecker_RegisterAgent_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) UnregisterAgent(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_UnregisterAgent_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\nfunc (c *woodpeckerClient) ReportHealth(ctx context.Context, in *ReportHealthRequest, opts ...grpc.CallOption) (*Empty, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(Empty)\n\terr := c.cc.Invoke(ctx, Woodpecker_ReportHealth_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// WoodpeckerServer is the server API for Woodpecker service.\n// All implementations must embed UnimplementedWoodpeckerServer\n// for forward compatibility.\n//\n// Woodpecker Server Service\ntype WoodpeckerServer interface {\n\tVersion(context.Context, *Empty) (*VersionResponse, error)\n\tNext(context.Context, *NextRequest) (*NextResponse, error)\n\tInit(context.Context, *InitRequest) (*Empty, error)\n\tWait(context.Context, *WaitRequest) (*WaitResponse, error)\n\tDone(context.Context, *DoneRequest) (*Empty, error)\n\tExtend(context.Context, *ExtendRequest) (*Empty, error)\n\tUpdate(context.Context, *UpdateRequest) (*Empty, error)\n\tLog(context.Context, *LogRequest) (*Empty, error)\n\tRegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error)\n\tUnregisterAgent(context.Context, *Empty) (*Empty, error)\n\tReportHealth(context.Context, *ReportHealthRequest) (*Empty, error)\n\tmustEmbedUnimplementedWoodpeckerServer()\n}\n\n// UnimplementedWoodpeckerServer must be embedded to have\n// forward compatible implementations.\n//\n// NOTE: this should be embedded by value instead of pointer to avoid a nil\n// pointer dereference when methods are called.\ntype UnimplementedWoodpeckerServer struct{}\n\nfunc (UnimplementedWoodpeckerServer) Version(context.Context, *Empty) (*VersionResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Version not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Next(context.Context, *NextRequest) (*NextResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Next not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Init(context.Context, *InitRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Init not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Wait(context.Context, *WaitRequest) (*WaitResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Wait not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Done(context.Context, *DoneRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Done not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Extend(context.Context, *ExtendRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Extend not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Update(context.Context, *UpdateRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Update not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) Log(context.Context, *LogRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Log not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) RegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method RegisterAgent not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) UnregisterAgent(context.Context, *Empty) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method UnregisterAgent not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) ReportHealth(context.Context, *ReportHealthRequest) (*Empty, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method ReportHealth not implemented\")\n}\nfunc (UnimplementedWoodpeckerServer) mustEmbedUnimplementedWoodpeckerServer() {}\nfunc (UnimplementedWoodpeckerServer) testEmbeddedByValue()                    {}\n\n// UnsafeWoodpeckerServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to WoodpeckerServer will\n// result in compilation errors.\ntype UnsafeWoodpeckerServer interface {\n\tmustEmbedUnimplementedWoodpeckerServer()\n}\n\nfunc RegisterWoodpeckerServer(s grpc.ServiceRegistrar, srv WoodpeckerServer) {\n\t// If the following call panics, it indicates UnimplementedWoodpeckerServer was\n\t// embedded by pointer and is nil.  This will cause panics if an\n\t// unimplemented method is ever invoked, so we test this at initialization\n\t// time to prevent it from happening at runtime later due to I/O.\n\tif t, ok := srv.(interface{ testEmbeddedByValue() }); ok {\n\t\tt.testEmbeddedByValue()\n\t}\n\ts.RegisterService(&Woodpecker_ServiceDesc, srv)\n}\n\nfunc _Woodpecker_Version_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(Empty)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Version(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Version_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Version(ctx, req.(*Empty))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Next_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(NextRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Next(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Next_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Next(ctx, req.(*NextRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Init_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(InitRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Init(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Init_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Init(ctx, req.(*InitRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Wait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(WaitRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Wait(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Wait_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Wait(ctx, req.(*WaitRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Done_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(DoneRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Done(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Done_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Done(ctx, req.(*DoneRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Extend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(ExtendRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Extend(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Extend_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Extend(ctx, req.(*ExtendRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Update_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(UpdateRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Update(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Update_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Update(ctx, req.(*UpdateRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_Log_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(LogRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).Log(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_Log_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).Log(ctx, req.(*LogRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_RegisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(RegisterAgentRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).RegisterAgent(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_RegisterAgent_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).RegisterAgent(ctx, req.(*RegisterAgentRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_UnregisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(Empty)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).UnregisterAgent(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_UnregisterAgent_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).UnregisterAgent(ctx, req.(*Empty))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\nfunc _Woodpecker_ReportHealth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(ReportHealthRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerServer).ReportHealth(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Woodpecker_ReportHealth_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerServer).ReportHealth(ctx, req.(*ReportHealthRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\n// Woodpecker_ServiceDesc is the grpc.ServiceDesc for Woodpecker service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar Woodpecker_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"proto.Woodpecker\",\n\tHandlerType: (*WoodpeckerServer)(nil),\n\tMethods: []grpc.MethodDesc{\n\t\t{\n\t\t\tMethodName: \"Version\",\n\t\t\tHandler:    _Woodpecker_Version_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Next\",\n\t\t\tHandler:    _Woodpecker_Next_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Init\",\n\t\t\tHandler:    _Woodpecker_Init_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Wait\",\n\t\t\tHandler:    _Woodpecker_Wait_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Done\",\n\t\t\tHandler:    _Woodpecker_Done_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Extend\",\n\t\t\tHandler:    _Woodpecker_Extend_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Update\",\n\t\t\tHandler:    _Woodpecker_Update_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"Log\",\n\t\t\tHandler:    _Woodpecker_Log_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"RegisterAgent\",\n\t\t\tHandler:    _Woodpecker_RegisterAgent_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"UnregisterAgent\",\n\t\t\tHandler:    _Woodpecker_UnregisterAgent_Handler,\n\t\t},\n\t\t{\n\t\t\tMethodName: \"ReportHealth\",\n\t\t\tHandler:    _Woodpecker_ReportHealth_Handler,\n\t\t},\n\t},\n\tStreams:  []grpc.StreamDesc{},\n\tMetadata: \"woodpecker.proto\",\n}\n\nconst (\n\tWoodpeckerAuth_Auth_FullMethodName = \"/proto.WoodpeckerAuth/Auth\"\n)\n\n// WoodpeckerAuthClient is the client API for WoodpeckerAuth service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\ntype WoodpeckerAuthClient interface {\n\tAuth(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error)\n}\n\ntype woodpeckerAuthClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewWoodpeckerAuthClient(cc grpc.ClientConnInterface) WoodpeckerAuthClient {\n\treturn &woodpeckerAuthClient{cc}\n}\n\nfunc (c *woodpeckerAuthClient) Auth(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(AuthResponse)\n\terr := c.cc.Invoke(ctx, WoodpeckerAuth_Auth_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// WoodpeckerAuthServer is the server API for WoodpeckerAuth service.\n// All implementations must embed UnimplementedWoodpeckerAuthServer\n// for forward compatibility.\ntype WoodpeckerAuthServer interface {\n\tAuth(context.Context, *AuthRequest) (*AuthResponse, error)\n\tmustEmbedUnimplementedWoodpeckerAuthServer()\n}\n\n// UnimplementedWoodpeckerAuthServer must be embedded to have\n// forward compatible implementations.\n//\n// NOTE: this should be embedded by value instead of pointer to avoid a nil\n// pointer dereference when methods are called.\ntype UnimplementedWoodpeckerAuthServer struct{}\n\nfunc (UnimplementedWoodpeckerAuthServer) Auth(context.Context, *AuthRequest) (*AuthResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Auth not implemented\")\n}\nfunc (UnimplementedWoodpeckerAuthServer) mustEmbedUnimplementedWoodpeckerAuthServer() {}\nfunc (UnimplementedWoodpeckerAuthServer) testEmbeddedByValue()                        {}\n\n// UnsafeWoodpeckerAuthServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to WoodpeckerAuthServer will\n// result in compilation errors.\ntype UnsafeWoodpeckerAuthServer interface {\n\tmustEmbedUnimplementedWoodpeckerAuthServer()\n}\n\nfunc RegisterWoodpeckerAuthServer(s grpc.ServiceRegistrar, srv WoodpeckerAuthServer) {\n\t// If the following call panics, it indicates UnimplementedWoodpeckerAuthServer was\n\t// embedded by pointer and is nil.  This will cause panics if an\n\t// unimplemented method is ever invoked, so we test this at initialization\n\t// time to prevent it from happening at runtime later due to I/O.\n\tif t, ok := srv.(interface{ testEmbeddedByValue() }); ok {\n\t\tt.testEmbeddedByValue()\n\t}\n\ts.RegisterService(&WoodpeckerAuth_ServiceDesc, srv)\n}\n\nfunc _WoodpeckerAuth_Auth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(AuthRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(WoodpeckerAuthServer).Auth(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: WoodpeckerAuth_Auth_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(WoodpeckerAuthServer).Auth(ctx, req.(*AuthRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\n// WoodpeckerAuth_ServiceDesc is the grpc.ServiceDesc for WoodpeckerAuth service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar WoodpeckerAuth_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"proto.WoodpeckerAuth\",\n\tHandlerType: (*WoodpeckerAuthServer)(nil),\n\tMethods: []grpc.MethodDesc{\n\t\t{\n\t\t\tMethodName: \"Auth\",\n\t\t\tHandler:    _WoodpeckerAuth_Auth_Handler,\n\t\t},\n\t},\n\tStreams:  []grpc.StreamDesc{},\n\tMetadata: \"woodpecker.proto\",\n}\n"
  },
  {
    "path": "rpc/types.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n)\n\ntype (\n\t// Filter defines filters for fetching items from the queue.\n\tFilter struct {\n\t\tLabels map[string]string `json:\"labels\"`\n\t}\n\n\t// StepState defines the step state.\n\tStepState struct {\n\t\tStepUUID string `json:\"step_uuid\"`\n\t\tStarted  int64  `json:\"started\"`\n\t\tFinished int64  `json:\"finished\"`\n\t\tExited   bool   `json:\"exited\"`\n\t\tExitCode int    `json:\"exit_code\"`\n\t\tError    string `json:\"error\"`\n\t\tCanceled bool   `json:\"canceled\"`\n\t\tSkipped  bool   `json:\"skipped\"`\n\t}\n\n\t// WorkflowState defines the workflow state.\n\tWorkflowState struct {\n\t\tStarted  int64  `json:\"started\"`\n\t\tFinished int64  `json:\"finished\"`\n\t\tError    string `json:\"error\"`\n\t\tCanceled bool   `json:\"canceled\"`\n\t}\n\n\t// Workflow defines the workflow execution details.\n\tWorkflow struct {\n\t\tID      string                `json:\"id\"`\n\t\tConfig  *backend_types.Config `json:\"config\"`\n\t\tTimeout int64                 `json:\"timeout\"`\n\t}\n\n\tVersion struct {\n\t\tGrpcVersion   int32  `json:\"grpc_version,omitempty\"`\n\t\tServerVersion string `json:\"server_version,omitempty\"`\n\t}\n\n\t// AgentInfo represents all the metadata that should be known about an agent.\n\tAgentInfo struct {\n\t\tVersion      string            `json:\"version\"`\n\t\tPlatform     string            `json:\"platform\"`\n\t\tBackend      string            `json:\"backend\"`\n\t\tCapacity     int               `json:\"capacity\"`\n\t\tCustomLabels map[string]string `json:\"custom_labels\"`\n\t}\n)\n"
  },
  {
    "path": "server/api/agent.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n//\n// Global Agents.\n//\n\n// GetAgents\n//\n//\t@Summary\tList agents\n//\t@Router\t\t/agents [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tAgent\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetAgents(c *gin.Context) {\n\tagents, err := store.FromContext(c).AgentList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting agent list. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, agents)\n}\n\n// GetAgent\n//\n//\t@Summary\tGet an agent\n//\t@Router\t\t/agents/{agent_id} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tAgent\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\nfunc GetAgent(c *gin.Context) {\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tagent, err := store.FromContext(c).AgentFind(agentID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, agent)\n}\n\n// GetAgentTasks\n//\n//\t@Summary\tList agent tasks\n//\t@Router\t\t/agents/{agent_id}/tasks [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tTask\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\nfunc GetAgentTasks(c *gin.Context) {\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tagent, err := store.FromContext(c).AgentFind(agentID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tvar tasks []*model.Task\n\tinfo := server.Config.Services.Scheduler.Info(c)\n\tfor _, task := range info.Running {\n\t\tif task.AgentID == agent.ID {\n\t\t\ttasks = append(tasks, task)\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, tasks)\n}\n\n// PatchAgent\n//\n//\t@Summary\tUpdate an agent\n//\t@Router\t\t/agents/{agent_id} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tAgent\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\n//\t@Param\t\tagentData\t\tbody\tAgent\ttrue\t\"the agent's data\"\nfunc PatchAgent(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tin := &model.Agent{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tagent, err := _store.AgentFind(agentID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\t// Update allowed fields\n\tagent.Name = in.Name\n\tagent.NoSchedule = in.NoSchedule\n\tif agent.NoSchedule {\n\t\tserver.Config.Services.Scheduler.KickAgentWorkers(agent.ID)\n\t}\n\n\terr = _store.AgentUpdate(agent)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusConflict)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, agent)\n}\n\n// PostAgent\n//\n//\t@Summary\t\tCreate a new agent\n//\t@Description\tCreates a new agent with a random token\n//\t@Router\t\t\t/agents [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tAgent\n//\t@Tags\t\t\tAgents\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tagent\t\t\tbody\tAgent\ttrue\t\"the agent's data (only 'name' and 'no_schedule' are read)\"\nfunc PostAgent(c *gin.Context) {\n\tin := &model.Agent{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\tuser := session.User(c)\n\n\tagent := &model.Agent{\n\t\tName:       in.Name,\n\t\tOwnerID:    user.ID,\n\t\tOrgID:      model.IDNotSet,\n\t\tNoSchedule: in.NoSchedule,\n\t\tToken:      model.GenerateNewAgentToken(),\n\t}\n\tif err = store.FromContext(c).AgentCreate(agent); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, agent)\n}\n\n// DeleteAgent\n//\n//\t@Summary\tDelete an agent\n//\t@Router\t\t/agents/{agent_id} [delete]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\nfunc DeleteAgent(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tagent, err := _store.AgentFind(agentID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\t// prevent deletion of agents with running tasks\n\tinfo := server.Config.Services.Scheduler.Info(c)\n\tfor _, task := range info.Running {\n\t\tif task.AgentID == agent.ID {\n\t\t\tc.String(http.StatusConflict, \"Agent has running tasks\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// kick workers to remove the agent from the queue\n\tserver.Config.Services.Scheduler.KickAgentWorkers(agent.ID)\n\n\tif err = _store.AgentDelete(agent); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error deleting user. %s\", err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n\n//\n// Org/User Agents.\n//\n\n// PostOrgAgent\n//\n//\t@Summary\t\tCreate a new organization-scoped agent\n//\t@Description\tCreates a new agent with a random token, scoped to the specified organization\n//\t@Router\t\t\t/orgs/{org_id}/agents [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tAgent\n//\t@Tags\t\t\tAgents\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\torg_id\t\t\tpath\tint\t\ttrue\t\"the organization's id\"\n//\t@Param\t\t\tagent\t\t\tbody\tAgent\ttrue\t\"the agent's data (only 'name' and 'no_schedule' are read)\"\nfunc PostOrgAgent(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tuser := session.User(c)\n\n\torgID, err := strconv.ParseInt(c.Param(\"org_id\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Invalid organization ID\")\n\t\treturn\n\t}\n\n\tin := new(model.Agent)\n\terr = c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\tagent := &model.Agent{\n\t\tName:       in.Name,\n\t\tOwnerID:    user.ID,\n\t\tOrgID:      orgID,\n\t\tNoSchedule: in.NoSchedule,\n\t\tToken:      model.GenerateNewAgentToken(),\n\t}\n\n\tif err = _store.AgentCreate(agent); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, agent)\n}\n\n// GetOrgAgents\n//\n//\t@Summary\tList agents for an organization\n//\t@Router\t\t/orgs/{org_id}/agents [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tAgent\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tint\t\ttrue\t\"the organization's id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetOrgAgents(c *gin.Context) {\n\t_store := store.FromContext(c)\n\torg := session.Org(c)\n\n\tagents, err := _store.AgentListForOrg(org.ID, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting agent list. %s\", err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, agents)\n}\n\n// PatchOrgAgent\n//\n//\t@Summary\tUpdate an organization-scoped agent\n//\t@Router\t\t/orgs/{org_id}/agents/{agent_id} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tAgent\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tint\t\ttrue\t\"the organization's id\"\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\n//\t@Param\t\tagent\t\t\tbody\tAgent\ttrue\t\"the agent's updated data\"\nfunc PatchOrgAgent(c *gin.Context) {\n\t_store := store.FromContext(c)\n\torg := session.Org(c)\n\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Invalid agent ID\")\n\t\treturn\n\t}\n\n\tagent, err := _store.AgentFind(agentID)\n\tif err != nil {\n\t\tc.String(http.StatusNotFound, \"Agent not found\")\n\t\treturn\n\t}\n\n\tif agent.OrgID != org.ID {\n\t\tc.String(http.StatusNotFound, \"Agent not found\")\n\t\treturn\n\t}\n\n\tin := new(model.Agent)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\t// Update allowed fields\n\tagent.Name = in.Name\n\tagent.NoSchedule = in.NoSchedule\n\tif agent.NoSchedule {\n\t\tserver.Config.Services.Scheduler.KickAgentWorkers(agent.ID)\n\t}\n\n\tif err := _store.AgentUpdate(agent); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, agent)\n}\n\n// DeleteOrgAgent\n//\n//\t@Summary\tDelete an organization-scoped agent\n//\t@Router\t\t/orgs/{org_id}/agents/{agent_id} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tAgents\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tint\t\ttrue\t\"the organization's id\"\n//\t@Param\t\tagent_id\t\tpath\tint\t\ttrue\t\"the agent's id\"\nfunc DeleteOrgAgent(c *gin.Context) {\n\t_store := store.FromContext(c)\n\torg := session.Org(c)\n\n\tagentID, err := strconv.ParseInt(c.Param(\"agent_id\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Invalid agent ID\")\n\t\treturn\n\t}\n\n\tagent, err := _store.AgentFind(agentID)\n\tif err != nil {\n\t\tc.String(http.StatusNotFound, \"Agent not found\")\n\t\treturn\n\t}\n\n\tif agent.OrgID != org.ID {\n\t\tc.String(http.StatusNotFound, \"Agent not found\")\n\t\treturn\n\t}\n\n\t// Check if the agent has any running tasks\n\tinfo := server.Config.Services.Scheduler.Info(c)\n\tfor _, task := range info.Running {\n\t\tif task.AgentID == agent.ID {\n\t\t\tc.String(http.StatusConflict, \"Agent has running tasks\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// Kick workers to remove the agent from the queue\n\tserver.Config.Services.Scheduler.KickAgentWorkers(agent.ID)\n\n\tif err := _store.AgentDelete(agent); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error deleting agent. %s\", err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/agent_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\tqueue_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nvar fakeAgent = &model.Agent{\n\tID:         1,\n\tName:       \"test-agent\",\n\tOwnerID:    1,\n\tNoSchedule: false,\n}\n\nfunc TestGetAgents(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should get agents\", func(t *testing.T) {\n\t\tagents := []*model.Agent{fakeAgent}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentList\", mock.Anything).Return(agents, nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\n\t\tGetAgents(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentList\", mock.Anything)\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\tvar response []*model.Agent\n\t\terr := json.Unmarshal(w.Body.Bytes(), &response)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, agents, response)\n\t})\n}\n\nfunc TestGetAgent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should get agent\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(fakeAgent, nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"1\"}}\n\n\t\tGetAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentFind\", int64(1))\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\tvar response model.Agent\n\t\terr := json.Unmarshal(w.Body.Bytes(), &response)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeAgent, &response)\n\t})\n\n\tt.Run(\"should return bad request for invalid agent id\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"invalid\"}}\n\n\t\tGetAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\t})\n\n\tt.Run(\"should return not found for non-existent agent\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return((*model.Agent)(nil), types.ErrRecordNotExist)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"2\"}}\n\n\t\tGetAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tassert.Equal(t, http.StatusNotFound, w.Code)\n\t})\n}\n\nfunc TestPatchAgent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should update agent\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(fakeAgent, nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.AnythingOfType(\"*model.Agent\")).Return(nil)\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tserver.Config.Services.Manager = mockManager\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"1\"}}\n\t\tc.Request, _ = http.NewRequest(http.MethodPatch, \"/\", strings.NewReader(`{\"name\":\"updated-agent\"}`))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tPatchAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentFind\", int64(1))\n\t\tmockStore.AssertCalled(t, \"AgentUpdate\", mock.AnythingOfType(\"*model.Agent\"))\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\tvar response model.Agent\n\t\terr := json.Unmarshal(w.Body.Bytes(), &response)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"updated-agent\", response.Name)\n\t})\n}\n\nfunc TestPostAgent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should create agent\", func(t *testing.T) {\n\t\tnewAgent := &model.Agent{\n\t\t\tName: \"new-agent\",\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentCreate\", mock.AnythingOfType(\"*model.Agent\")).Return(nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Set(\"user\", &model.User{ID: 1})\n\t\tc.Request, _ = http.NewRequest(http.MethodPost, \"/\", strings.NewReader(`{\"name\":\"new-agent\"}`))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tPostAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentCreate\", mock.AnythingOfType(\"*model.Agent\"))\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\tvar response model.Agent\n\t\terr := json.Unmarshal(w.Body.Bytes(), &response)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, newAgent.Name, response.Name)\n\t\tassert.NotEmpty(t, response.Token)\n\t})\n}\n\nfunc TestDeleteAgent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should delete agent\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(fakeAgent, nil)\n\t\tmockStore.On(\"AgentDelete\", mock.AnythingOfType(\"*model.Agent\")).Return(nil)\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tserver.Config.Services.Manager = mockManager\n\n\t\tmockQueue := queue_mocks.NewMockQueue(t)\n\t\tmockQueue.On(\"Info\", mock.Anything).Return(queue.InfoT{})\n\t\tmockQueue.On(\"KickAgentWorkers\", int64(1)).Return()\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New())\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"1\"}}\n\n\t\tDeleteAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentFind\", int64(1))\n\t\tmockStore.AssertCalled(t, \"AgentDelete\", mock.AnythingOfType(\"*model.Agent\"))\n\t\tmockQueue.AssertCalled(t, \"KickAgentWorkers\", int64(1))\n\t\tassert.Equal(t, http.StatusNoContent, w.Code)\n\t})\n\n\tt.Run(\"should not delete agent with running tasks\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(fakeAgent, nil)\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tserver.Config.Services.Manager = mockManager\n\n\t\tmockQueue := queue_mocks.NewMockQueue(t)\n\t\tmockQueue.On(\"Info\", mock.Anything).Return(queue.InfoT{\n\t\t\tRunning: []*model.Task{{AgentID: 1}},\n\t\t})\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New())\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"agent_id\", Value: \"1\"}}\n\n\t\tDeleteAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tmockStore.AssertCalled(t, \"AgentFind\", int64(1))\n\t\tmockStore.AssertNotCalled(t, \"AgentDelete\", mock.Anything)\n\t\tassert.Equal(t, http.StatusConflict, w.Code)\n\t})\n}\n\nfunc TestPostOrgAgent(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"create org agent should succeed\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"AgentCreate\", mock.AnythingOfType(\"*model.Agent\")).Return(nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\n\t\t// Set up a non-admin user\n\t\tc.Set(\"user\", &model.User{\n\t\t\tID:    1,\n\t\t\tAdmin: false,\n\t\t})\n\n\t\tc.Params = gin.Params{{Key: \"org_id\", Value: \"1\"}}\n\t\tc.Request, _ = http.NewRequest(http.MethodPost, \"/\", strings.NewReader(`{\"name\":\"new-agent\"}`))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tPostOrgAgent(c)\n\t\tc.Writer.WriteHeaderNow()\n\n\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\t// Ensure an agent was created\n\t\tmockStore.AssertCalled(t, \"AgentCreate\", mock.AnythingOfType(\"*model.Agent\"))\n\t})\n}\n"
  },
  {
    "path": "server/api/badge.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/badges\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/ccmenu\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// GetBadge\n//\n//\t@Summary\tGet status of pipeline as SVG badge\n//\t@Router\t\t/badges/{repo_id}/status.svg [get]\n//\t@Produce\timage/svg+xml\n//\t@Success\t200\n//\t@Tags\t\tBadges\n//\t@Param\t\trepo_id\tpath\tint\ttrue\t\"the repository id\"\nfunc GetBadge(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tvar repo *model.Repo\n\tvar err error\n\n\tif c.Param(\"repo_name\") != \"\" {\n\t\trepo, err = _store.GetRepoName(c.Param(\"repo_id_or_owner\") + \"/\" + c.Param(\"repo_name\"))\n\t} else {\n\t\tvar repoID int64\n\t\trepoID, err = strconv.ParseInt(c.Param(\"repo_id_or_owner\"), 10, 64)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\trepo, err = _store.GetRepo(repoID)\n\t}\n\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\t// if no commit was found then display\n\t// the 'none' badge, instead of throwing\n\t// an error response\n\tbranch := c.Query(\"branch\")\n\tif len(branch) == 0 {\n\t\tbranch = repo.Branch\n\t}\n\n\t// Events to lookup, multiple separated by comma\n\tvar events []model.WebhookEvent\n\teventsQuery := c.Query(\"events\")\n\t// If none given, fallback to default \"push\"\n\tif len(eventsQuery) == 0 {\n\t\tevents = []model.WebhookEvent{model.EventPush}\n\t} else {\n\t\tstrEvents := strings.Split(eventsQuery, \",\")\n\t\tevents = make([]model.WebhookEvent, len(strEvents))\n\t\tfor i, strEvent := range strEvents {\n\t\t\tevent := model.WebhookEvent(strEvent)\n\t\t\tif err := event.Validate(); err == nil {\n\t\t\t\tevents[i] = event\n\t\t\t} else {\n\t\t\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tname := \"pipeline\"\n\tvar status *model.StatusValue = nil\n\n\tpl, err := _store.GetPipelineBadge(repo, branch, events)\n\tif err != nil {\n\t\tif !errors.Is(err, types.ErrRecordNotExist) {\n\t\t\tlog.Warn().Err(err).Msg(\"could not get last pipeline for badge\")\n\t\t}\n\t} else {\n\t\tstatus = &pl.Status\n\t}\n\n\t// we serve an SVG, so set content type appropriately.\n\tc.Writer.Header().Set(\"Content-Type\", \"image/svg+xml\")\n\n\t// Allow workflow (and step) specific badges\n\tworkflowName := c.Query(\"workflow\")\n\tif len(workflowName) != 0 {\n\t\tname = workflowName\n\t\tstatus = nil\n\n\t\tworkflows, err := _store.WorkflowGetTree(pl)\n\t\tif err == nil {\n\t\t\tfor _, wf := range workflows {\n\t\t\t\tif wf.Name == workflowName {\n\t\t\t\t\tstepName := c.Query(\"step\")\n\t\t\t\t\tif len(stepName) == 0 {\n\t\t\t\t\t\tif status != nil {\n\t\t\t\t\t\t\tmerged := pipeline.MergeStatusValues(*status, wf.State)\n\t\t\t\t\t\t\tstatus = &merged\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tstatus = &wf.State\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// If step is explicitly requested\n\t\t\t\t\tname = workflowName + \": \" + stepName\n\t\t\t\t\tfor _, s := range wf.Children {\n\t\t\t\t\t\tif s.Name == stepName {\n\t\t\t\t\t\t\tif status != nil {\n\t\t\t\t\t\t\t\tmerged := pipeline.MergeStatusValues(*status, s.State)\n\t\t\t\t\t\t\t\tstatus = &merged\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tstatus = &s.State\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tbadge, err := badges.Generate(name, status)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Failed to generate badge.\")\n\t} else {\n\t\tc.String(http.StatusOK, badge)\n\t}\n}\n\n// GetCC\n//\n//\t@Summary\t\tProvide pipeline status information to the CCMenu tool\n//\t@Description\tCCMenu displays the pipeline status of projects on a CI server as an item in the Mac's menu bar.\n//\t@Description\tMore details on how to install, you can find at http://ccmenu.org/\n//\t@Description\tThe response format adheres to CCTray v1 Specification, https://cctray.org/v1/\n//\t@Router\t\t\t/badges/{repo_id}/cc.xml [get]\n//\t@Produce\t\txml\n//\t@Success\t\t200\n//\t@Tags\t\t\tBadges\n//\t@Param\t\t\trepo_id\tpath\tint\ttrue\t\"the repository id\"\nfunc GetCC(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tvar repo *model.Repo\n\tvar err error\n\n\tif c.Param(\"repo_name\") != \"\" {\n\t\trepo, err = _store.GetRepoName(c.Param(\"repo_id_or_owner\") + \"/\" + c.Param(\"repo_name\"))\n\t} else {\n\t\tvar repoID int64\n\t\trepoID, err = strconv.ParseInt(c.Param(\"repo_id_or_owner\"), 10, 64)\n\t\tif err != nil {\n\t\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\trepo, err = _store.GetRepo(repoID)\n\t}\n\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tpipelines, err := _store.GetPipelineList(repo, &model.ListOptions{Page: 1, PerPage: 1}, nil)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\tlog.Warn().Err(err).Msg(\"could not get pipeline list\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\tif len(pipelines) == 0 {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\treturn\n\t}\n\n\turl := fmt.Sprintf(\"%s/repos/%d/pipeline/%d\", server.Config.Server.Host, repo.ID, pipelines[0].Number)\n\tcc := ccmenu.New(repo, pipelines[0], url)\n\tc.XML(http.StatusOK, cc)\n}\n"
  },
  {
    "path": "server/api/cron.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tcron_scheduler \"go.woodpecker-ci.org/woodpecker/v3/server/cron\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// GetCron\n//\n//\t@Summary\tGet a cron job\n//\t@Router\t\t/repos/{repo_id}/cron/{cron} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tCron\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tcron\t\t\tpath\tstring\ttrue\t\"the cron job id\"\nfunc GetCron(c *gin.Context) {\n\trepo := session.Repo(c)\n\tid, err := strconv.ParseInt(c.Param(\"cron\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing cron id. %s\", err)\n\t\treturn\n\t}\n\n\tcron, err := store.FromContext(c).CronFind(repo, id)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, cron)\n}\n\n// RunCron\n//\n//\t@Summary\tStart a cron job now\n//\t@Router\t\t/repos/{repo_id}/cron/{cron} [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tPipeline\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tcron\t\t\tpath\tstring\ttrue\t\"the cron job id\"\nfunc RunCron(c *gin.Context) {\n\trepo := session.Repo(c)\n\t_store := store.FromContext(c)\n\tid, err := strconv.ParseInt(c.Param(\"cron\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing cron id. %s\", err)\n\t\treturn\n\t}\n\n\tcron, err := _store.CronFind(repo, id)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\trepo, newPipeline, err := cron_scheduler.CreatePipeline(c, _store, cron)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error creating pipeline for cron %q. %s\", id, err)\n\t\treturn\n\t}\n\n\tpl, err := pipeline.Create(c, _store, repo, newPipeline)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, pl)\n}\n\n// PostCron\n//\n//\t@Summary\tCreate a cron job\n//\t@Router\t\t/repos/{repo_id}/cron [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tCron\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tcronJob\t\t\tbody\tCron\ttrue\t\"the new cron job\"\nfunc PostCron(c *gin.Context) {\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\t_store := store.FromContext(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tin := new(model.Cron)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing request. %s\", err)\n\t\treturn\n\t}\n\tcron := &model.Cron{\n\t\tRepoID:    repo.ID,\n\t\tName:      in.Name,\n\t\tCreatorID: user.ID,\n\t\tSchedule:  in.Schedule,\n\t\tBranch:    in.Branch,\n\t\tVariables: in.Variables,\n\t\tEnabled:   in.Enabled,\n\t}\n\tif err := cron.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error inserting cron. validate failed: %s\", err)\n\t\treturn\n\t}\n\n\tnextExec, err := cron_scheduler.CalcNewNext(in.Schedule, time.Now())\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error inserting cron. schedule could not parsed: %s\", err)\n\t\treturn\n\t}\n\tcron.NextExec = nextExec.Unix()\n\n\tif in.Branch != \"\" {\n\t\t// check if branch exists on forge\n\t\t_, err := _forge.BranchHead(c, user, repo, in.Branch)\n\t\tif err != nil {\n\t\t\tc.String(http.StatusBadRequest, \"Error inserting cron. branch not resolved: %s\", err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := _store.CronCreate(cron); err != nil {\n\t\tif errors.Is(err, types.ErrInsertDuplicateDetected) {\n\t\t\tc.String(http.StatusConflict, \"cron with this exists for this repo already\")\n\t\t} else {\n\t\t\tc.String(http.StatusInternalServerError, \"Error inserting cron %q. %s\", in.Name, err)\n\t\t}\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, cron)\n}\n\n// PatchCron\n//\n//\t@Summary\tUpdate a cron job\n//\t@Router\t\t/repos/{repo_id}/cron/{cron} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tCron\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\tcron\t\t\tpath\tstring\t\ttrue\t\"the cron job id\"\n//\t@Param\t\tcronJob\t\t\tbody\tCronPatch\ttrue\t\"the cron job data\"\nfunc PatchCron(c *gin.Context) {\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\t_store := store.FromContext(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tid, err := strconv.ParseInt(c.Param(\"cron\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing cron id. %s\", err)\n\t\treturn\n\t}\n\n\tin := new(model.CronPatch)\n\terr = c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing request. %s\", err)\n\t\treturn\n\t}\n\n\tcron, err := _store.CronFind(repo, id)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Branch != nil && *in.Branch != \"\" {\n\t\t// check if branch exists on forge\n\t\t_, err := _forge.BranchHead(c, user, repo, *in.Branch)\n\t\tif err != nil {\n\t\t\tc.String(http.StatusBadRequest, \"Error inserting cron. branch not resolved: %s\", err)\n\t\t\treturn\n\t\t}\n\t\tcron.Branch = *in.Branch\n\t}\n\tif in.Schedule != nil && *in.Schedule != \"\" {\n\t\tcron.Schedule = *in.Schedule\n\t\tnextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now())\n\t\tif err != nil {\n\t\t\tc.String(http.StatusBadRequest, \"Error inserting cron. schedule could not parsed: %s\", err)\n\t\t\treturn\n\t\t}\n\t\tcron.NextExec = nextExec.Unix()\n\t}\n\tif in.Name != nil && *in.Name != \"\" {\n\t\tcron.Name = *in.Name\n\t}\n\tif in.Enabled != nil {\n\t\tcron.Enabled = *in.Enabled\n\t\t// if we re-enable a cron we have to calc NextExec because it was not while disabled\n\t\tif cron.Enabled {\n\t\t\tnextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now())\n\t\t\tif err != nil {\n\t\t\t\tc.String(http.StatusInternalServerError, \"Cron schedule could not parsed: %s\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tcron.NextExec = nextExec.Unix()\n\t\t}\n\t}\n\tif in.Variables != nil {\n\t\tcron.Variables = in.Variables\n\t}\n\tcron.CreatorID = user.ID\n\n\tif err := cron.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error inserting cron. validate failed: %s\", err)\n\t\treturn\n\t}\n\tif err := _store.CronUpdate(repo, cron); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating cron %q. %s\", in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, cron)\n}\n\n// GetCronList\n//\n//\t@Summary\tList cron jobs\n//\t@Router\t\t/repos/{repo_id}/cron [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tCron\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetCronList(c *gin.Context) {\n\trepo := session.Repo(c)\n\tlist, err := store.FromContext(c).CronList(repo, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting cron list. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// DeleteCron\n//\n//\t@Summary\tDelete a cron job\n//\t@Router\t\t/repos/{repo_id}/cron/{cron} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRepository cron jobs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tcron\t\t\tpath\tstring\ttrue\t\"the cron job id\"\nfunc DeleteCron(c *gin.Context) {\n\trepo := session.Repo(c)\n\tid, err := strconv.ParseInt(c.Param(\"cron\"), 10, 64)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing cron id. %s\", err)\n\t\treturn\n\t}\n\tif err := store.FromContext(c).CronDelete(repo, id); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/debug/debug.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage debug\n\nimport (\n\t\"net/http/pprof\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// IndexHandler\n//\n//\t@Summary\t\tList available pprof profiles (HTML)\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof [get]\n//\t@Produce\t\thtml\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc IndexHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Index(c.Writer, c.Request)\n\t}\n}\n\n// HeapHandler\n//\n//\t@Summary\t\tGet pprof heap dump, a sampling of memory allocations of live objects\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof/heap [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\t\t\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tgc\t\t\t\tquery\tstring\tfalse\t\"You can specify gc=heap to run GC before taking the heap sample\"\tdefault()\nfunc HeapHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Handler(\"heap\").ServeHTTP(c.Writer, c.Request)\n\t}\n}\n\n// GoroutineHandler\n//\n//\t@Summary\t\tGet pprof stack traces of all current goroutines\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof/goroutine [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tdebug\t\t\tquery\tint\t\tfalse\t\"Use debug=2 as a query parameter to export in the same format as an un-recovered panic\"\tdefault(1)\nfunc GoroutineHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Handler(\"goroutine\").ServeHTTP(c.Writer, c.Request)\n\t}\n}\n\n// BlockHandler\n//\n//\t@Summary\t\tGet pprof stack traces that led to blocking on synchronization primitives\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof/block [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc BlockHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Handler(\"block\").ServeHTTP(c.Writer, c.Request)\n\t}\n}\n\n// ThreadCreateHandler\n//\n//\t@Summary\t\tGet pprof stack traces that led to the creation of new OS threads\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof/threadcreate [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc ThreadCreateHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Handler(\"threadcreate\").ServeHTTP(c.Writer, c.Request)\n\t}\n}\n\n// CmdlineHandler\n//\n//\t@Summary\t\tGet the command line invocation of the current program\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Router\t\t\t/debug/pprof/cmdline [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc CmdlineHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Cmdline(c.Writer, c.Request)\n\t}\n}\n\n// ProfileHandler\n//\n//\t@Summary\t\tGet pprof CPU profile\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Description\tAfter you get the profile file, use the go tool pprof command to investigate the profile.\n//\t@Router\t\t\t/debug/pprof/profile [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\t\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tseconds\t\t\tquery\tint\t\ttrue\t\"You can specify the duration in the seconds GET parameter.\"\tdefault\t(30)\nfunc ProfileHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Profile(c.Writer, c.Request)\n\t}\n}\n\n// SymbolHandler\n//\n//\t@Summary\t\tGet pprof program counters mapping to function names\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Description\tLooks up the program counters listed in the request,\n//\t@Description\tresponding with a table mapping program counters to function names.\n//\t@Description\tThe requested program counters can be provided via GET + query parameters,\n//\t@Description\tor POST + body parameters. Program counters shall be space delimited.\n//\t@Router\t\t\t/debug/pprof/symbol [get]\n//\t@Router\t\t\t/debug/pprof/symbol [post]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc SymbolHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Symbol(c.Writer, c.Request)\n\t}\n}\n\n// TraceHandler\n//\n//\t@Summary\t\tGet a trace of execution of the current program\n//\t@Description\tOnly available, when server was started with WOODPECKER_LOG_LEVEL=debug\n//\t@Description\tAfter you get the profile file, use the go tool pprof command to investigate the profile.\n//\t@Router\t\t\t/debug/pprof/trace [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tProcess profiling and debugging\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\t\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tseconds\t\t\tquery\tint\t\ttrue\t\"You can specify the duration in the seconds GET parameter.\"\tdefault\t(30)\nfunc TraceHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpprof.Trace(c.Writer, c.Request)\n\t}\n}\n"
  },
  {
    "path": "server/api/forge.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// GetForges\n//\n//\t@Summary\tList forges\n//\t@Router\t\t/forges [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tForge\n//\t@Tags\t\tForges\n//\t@Param\t\tAuthorization\theader\tstring\tfalse\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetForges(c *gin.Context) {\n\tforges, err := store.FromContext(c).ForgeList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting forge list. %s\", err)\n\t\treturn\n\t}\n\n\tuser := session.User(c)\n\tif user != nil && user.Admin {\n\t\tc.JSON(http.StatusOK, forges)\n\t\treturn\n\t}\n\n\t// copy forges data without sensitive information\n\tfor i, forge := range forges {\n\t\tforges[i] = forge.PublicCopy()\n\t}\n\n\tc.JSON(http.StatusOK, forges)\n}\n\n// GetForge\n//\n//\t@Summary\tGet a forge\n//\t@Router\t\t/forges/{forge_id} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tForge\n//\t@Tags\t\tForges\n//\t@Param\t\tAuthorization\theader\tstring\tfalse\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tforge_id\t\tpath\tint\t\ttrue\t\"the forge's id\"\nfunc GetForge(c *gin.Context) {\n\tforgeID, err := strconv.ParseInt(c.Param(\"forge_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tforge, err := store.FromContext(c).ForgeGet(forgeID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tuser := session.User(c)\n\tif user != nil && user.Admin {\n\t\tc.JSON(http.StatusOK, forge)\n\t} else {\n\t\tc.JSON(http.StatusOK, forge.PublicCopy())\n\t}\n}\n\n// PatchForge\n//\n//\t@Summary\tUpdate a forge\n//\t@Router\t\t/forges/{forge_id} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tForge\n//\t@Tags\t\tForges\n//\t@Param\t\tAuthorization\theader\tstring\t\t\t\t\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tforge_id\t\tpath\tint\t\t\t\t\t\t\ttrue\t\"the forge's id\"\n//\t@Param\t\tforgeData\t\tbody\tForgeWithOAuthClientSecret\ttrue\t\"the forge's data\"\nfunc PatchForge(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tin := &model.ForgeWithOAuthClientSecret{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tforgeID, err := strconv.ParseInt(c.Param(\"forge_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tforge, err := _store.ForgeGet(forgeID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tforge.URL = in.URL\n\tforge.Type = in.Type\n\tforge.OAuthClientID = in.OAuthClientID\n\tforge.OAuthHost = in.OAuthHost\n\tforge.SkipVerify = in.SkipVerify\n\tforge.AdditionalOptions = in.AdditionalOptions\n\tif in.OAuthClientSecret != \"\" {\n\t\tforge.OAuthClientSecret = in.OAuthClientSecret\n\t}\n\n\terr = _store.ForgeUpdate(forge)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusConflict)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, forge)\n}\n\n// PostForge\n//\n//\t@Summary\t\tCreate a new forge\n//\t@Description\tCreates a new forge with a random token\n//\t@Router\t\t\t/forges [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tForge\n//\t@Tags\t\t\tForges\n//\t@Param\t\t\tAuthorization\theader\tstring\t\t\t\t\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tforge\t\t\tbody\tForgeWithOAuthClientSecret\ttrue\t\"the forge's data (only 'name' and 'no_schedule' are read)\"\nfunc PostForge(c *gin.Context) {\n\tin := &model.ForgeWithOAuthClientSecret{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\n\tforge := &model.Forge{\n\t\tURL:               in.URL,\n\t\tType:              in.Type,\n\t\tOAuthClientID:     in.OAuthClientID,\n\t\tOAuthClientSecret: in.OAuthClientSecret,\n\t\tOAuthHost:         in.OAuthHost,\n\t\tSkipVerify:        in.SkipVerify,\n\t\tAdditionalOptions: in.AdditionalOptions,\n\t}\n\tif err = store.FromContext(c).ForgeCreate(forge); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, forge)\n}\n\n// DeleteForge\n//\n//\t@Summary\tDelete a forge\n//\t@Router\t\t/forges/{forge_id} [delete]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tForges\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tforge_id\t\tpath\tint\t\ttrue\t\"the forge's id\"\nfunc DeleteForge(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tforgeID, err := strconv.ParseInt(c.Param(\"forge_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tforge, err := _store.ForgeGet(forgeID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tif err = _store.ForgeDelete(forge); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error deleting user. %s\", err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/global_registry.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetGlobalRegistryList\n//\n//\t@Summary\tList global registries\n//\t@Router\t\t/registries [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tRegistry\n//\t@Tags\t\tRegistries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetGlobalRegistryList(c *gin.Context) {\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tlist, err := registryService.GlobalRegistryList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting global registry list. %s\", err)\n\t\treturn\n\t}\n\t// copy the registry detail to remove the sensitive\n\t// password and token fields.\n\tfor i, registry := range list {\n\t\tlist[i] = registry.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// GetGlobalRegistry\n//\n//\t@Summary\tGet a global registry by name\n//\t@Router\t\t/registries/{registry} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRegistries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry's name\"\nfunc GetGlobalRegistry(c *gin.Context) {\n\taddr := c.Param(\"registry\")\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tregistry, err := registryService.GlobalRegistryFind(addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// PostGlobalRegistry\n//\n//\t@Summary\tCreate a global registry\n//\t@Router\t\t/registries [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRegistries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tregistry\t\tbody\tRegistry\ttrue\t\"the registry object data\"\nfunc PostGlobalRegistry(c *gin.Context) {\n\tin := new(model.Registry)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing global registry. %s\", err)\n\t\treturn\n\t}\n\tregistry := &model.Registry{\n\t\tAddress:  in.Address,\n\t\tUsername: in.Username,\n\t\tPassword: in.Password,\n\t}\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error inserting global registry. %s\", err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tif err := registryService.GlobalRegistryCreate(registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting global registry %q. %s\", in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// PatchGlobalRegistry\n//\n//\t@Summary\tUpdate a global registry by name\n//\t@Router\t\t/registries/{registry} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRegistries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tregistry\t\tpath\tstring\t\ttrue\t\"the registry's name\"\n//\t@Param\t\tregistryData\tbody\tRegistry\ttrue\t\"the registry's data\"\nfunc PatchGlobalRegistry(c *gin.Context) {\n\taddr := c.Param(\"registry\")\n\n\tin := new(model.Registry)\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing registry. %s\", err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tregistry, err := registryService.GlobalRegistryFind(addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Address != \"\" {\n\t\tregistry.Address = in.Address\n\t}\n\tif in.Username != \"\" {\n\t\tregistry.Username = in.Username\n\t}\n\tif in.Password != \"\" {\n\t\tregistry.Password = in.Password\n\t}\n\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error updating global registry. %s\", err)\n\t\treturn\n\t}\n\n\tif err := registryService.GlobalRegistryUpdate(registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating global registry %q. %s\", in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// DeleteGlobalRegistry\n//\n//\t@Summary\tDelete a global registry by name\n//\t@Router\t\t/registries/{registry} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRegistries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry's name\"\nfunc DeleteGlobalRegistry(c *gin.Context) {\n\taddr := c.Param(\"registry\")\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tif err := registryService.GlobalRegistryDelete(addr); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/global_secret.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetGlobalSecretList\n//\n//\t@Summary\tList global secrets\n//\t@Router\t\t/secrets [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tSecret\n//\t@Tags\t\tSecrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetGlobalSecretList(c *gin.Context) {\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tlist, err := secretService.GlobalSecretList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting global secret list. %s\", err)\n\t\treturn\n\t}\n\t// copy the secret detail to remove the sensitive\n\t// password and token fields.\n\tfor i, secret := range list {\n\t\tlist[i] = secret.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// GetGlobalSecret\n//\n//\t@Summary\tGet a global secret by name\n//\t@Router\t\t/secrets/{secret} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tSecrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tsecret\t\t\tpath\tstring\ttrue\t\"the secret's name\"\nfunc GetGlobalSecret(c *gin.Context) {\n\tname := c.Param(\"secret\")\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tsecret, err := secretService.GlobalSecretFind(name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// PostGlobalSecret\n//\n//\t@Summary\tCreate a global secret\n//\t@Router\t\t/secrets [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tSecrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tsecret\t\t\tbody\tSecret\ttrue\t\"the secret object data\"\nfunc PostGlobalSecret(c *gin.Context) {\n\tin := new(model.Secret)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing global secret. %s\", err)\n\t\treturn\n\t}\n\tsecret := &model.Secret{\n\t\tName:   in.Name,\n\t\tValue:  in.Value,\n\t\tEvents: in.Events,\n\t\tImages: in.Images,\n\t\tNote:   in.Note,\n\t}\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error inserting global secret. %s\", err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tif err := secretService.GlobalSecretCreate(secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting global secret %q. %s\", in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// PatchGlobalSecret\n//\n//\t@Summary\tUpdate a global secret by name\n//\t@Router\t\t/secrets/{secret} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tSecrets\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tsecret\t\t\tpath\tstring\t\ttrue\t\"the secret's name\"\n//\t@Param\t\tsecretData\t\tbody\tSecretPatch\ttrue\t\"the secret's data\"\nfunc PatchGlobalSecret(c *gin.Context) {\n\tname := c.Param(\"secret\")\n\n\tin := new(model.SecretPatch)\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing secret. %s\", err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tsecret, err := secretService.GlobalSecretFind(name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Value != nil && *in.Value != \"\" {\n\t\tsecret.Value = *in.Value\n\t}\n\tif in.Events != nil {\n\t\tsecret.Events = in.Events\n\t}\n\tif in.Images != nil {\n\t\tsecret.Images = in.Images\n\t}\n\tif in.Note != nil {\n\t\tsecret.Note = *in.Note\n\t}\n\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error updating global secret. %s\", err)\n\t\treturn\n\t}\n\n\tif err := secretService.GlobalSecretUpdate(secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating global secret %q. %s\", in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// DeleteGlobalSecret\n//\n//\t@Summary\tDelete a global secret by name\n//\t@Router\t\t/secrets/{secret} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tSecrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tsecret\t\t\tpath\tstring\ttrue\t\"the secret's name\"\nfunc DeleteGlobalSecret(c *gin.Context) {\n\tname := c.Param(\"secret\")\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tif err := secretService.GlobalSecretDelete(name); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/helper.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc handlePipelineErr(c *gin.Context, err error) {\n\tswitch {\n\tcase errors.Is(err, &pipeline.ErrNotFound{}):\n\t\tc.String(http.StatusNotFound, \"%s\", err)\n\tcase errors.Is(err, &pipeline.ErrBadRequest{}):\n\t\tc.String(http.StatusBadRequest, \"%s\", err)\n\tcase errors.Is(err, pipeline.ErrFiltered):\n\t\t// for debugging purpose we add a header\n\t\tc.Writer.Header().Add(\"Pipeline-Filtered\", \"true\")\n\t\tc.Status(http.StatusNoContent)\n\tdefault:\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t}\n}\n\nfunc handleDBError(c *gin.Context, err error) {\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\treturn\n\t}\n\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n}\n\n// If the forge has a refresh token, the current access token may be stale.\n// Therefore, we should refresh prior to dispatching the job.\nfunc refreshUserToken(c *gin.Context, user *model.User) {\n\t_store := store.FromContext(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\tforge.Refresh(c, _forge, _store, user)\n}\n\n// pipelineDeleteAllowed checks if the given pipeline can be deleted based on its status.\n// It returns a bool indicating if delete is allowed, and the pipeline's status.\nfunc pipelineDeleteAllowed(pl *model.Pipeline) bool {\n\tswitch pl.Status {\n\tcase model.StatusRunning, model.StatusPending, model.StatusBlocked:\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "server/api/helper_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\nfunc TestHandlePipelineError(t *testing.T) {\n\ttests := []struct {\n\t\terr  error\n\t\tcode int\n\t}{\n\t\t{\n\t\t\terr:  pipeline.ErrFiltered,\n\t\t\tcode: http.StatusNoContent,\n\t\t},\n\t\t{\n\t\t\terr:  &pipeline.ErrNotFound{Msg: \"pipeline not found\"},\n\t\t\tcode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\terr:  &pipeline.ErrBadRequest{Msg: \"bad request error\"},\n\t\t\tcode: http.StatusBadRequest,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tr := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(r)\n\t\thandlePipelineErr(c, tt.err)\n\t\tc.Writer.WriteHeaderNow() // require written header\n\t\tassert.Equal(t, tt.code, r.Code)\n\t}\n}\n"
  },
  {
    "path": "server/api/hook.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\n// getAgentName finds an agent's name, utilizing a map as a cache.\nfunc getAgentName(store store.Store, agentNameMap map[int64]string, agentID int64) (string, bool) {\n\t// 1. Check the cache first.\n\tname, exists := agentNameMap[agentID]\n\tif exists {\n\t\treturn name, true\n\t}\n\n\t// 2. If not in cache, query the store.\n\tagent, err := store.AgentFind(agentID)\n\tif err != nil || agent == nil {\n\t\t// Agent not found or an error occurred.\n\t\treturn \"\", false\n\t}\n\n\t// 3. Found the agent, update the cache and return the name.\n\tif agent.Name != \"\" {\n\t\tagentNameMap[agentID] = agent.Name\n\t\treturn agent.Name, true\n\t}\n\n\treturn \"\", false\n}\n\n// PostHook\n//\n//\t@Summary\tIncoming webhook from forge\n//\t@Router\t\t/hook [post]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tSystem\n//\t@Param\t\thook\tbody\tobject\ttrue\t\"the webhook payload; forge is automatically detected\"\nfunc PostHook(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\t//\n\t// 1. Check if the webhook is valid and authorized\n\t//\n\n\tvar repo *model.Repo\n\n\t_, err := token.ParseRequest([]token.Type{token.HookToken}, c.Request, func(t *token.Token) (string, error) {\n\t\tvar err error\n\t\trepo, err = getRepoFromToken(_store, t)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\treturn repo.Hash, nil\n\t})\n\tif err != nil {\n\t\tmsg := \"failure to parse token from hook\"\n\t\tlog.Error().Err(err).Msg(msg)\n\t\tc.String(http.StatusBadRequest, msg)\n\t\treturn\n\t}\n\n\tif repo == nil {\n\t\tmsg := \"failure to get repo from token\"\n\t\tlog.Error().Msg(msg)\n\t\tc.String(http.StatusBadRequest, msg)\n\t\treturn\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Int64(\"repo-id\", repo.ID).Msgf(\"Cannot get forge with id: %d\", repo.ForgeID)\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t//\n\t// 2. Parse the webhook data\n\t//\n\n\trepoFromForge, pipelineFromForge, err := _forge.Hook(c, c.Request)\n\tif err != nil {\n\t\tif errors.Is(err, &types.ErrIgnoreEvent{}) {\n\t\t\tmsg := fmt.Sprintf(\"forge driver: %s\", err)\n\t\t\tlog.Debug().Err(err).Msg(msg)\n\t\t\tc.String(http.StatusOK, msg)\n\t\t\treturn\n\t\t}\n\n\t\tmsg := \"failure to parse hook\"\n\t\tlog.Debug().Err(err).Msg(msg)\n\t\tc.String(http.StatusBadRequest, msg)\n\t\treturn\n\t}\n\n\tif pipelineFromForge == nil {\n\t\tmsg := \"ignoring hook: hook parsing resulted in empty pipeline\"\n\t\tlog.Debug().Msg(msg)\n\t\tc.String(http.StatusOK, msg)\n\t\treturn\n\t}\n\tif repoFromForge == nil {\n\t\tmsg := \"failure to ascertain repo from hook\"\n\t\tlog.Debug().Msg(msg)\n\t\tc.String(http.StatusBadRequest, msg)\n\t\treturn\n\t}\n\n\t//\n\t// 3. Check the repo from the token is matching the repo returned by the forge\n\t//\n\n\tif repo.ForgeRemoteID != repoFromForge.ForgeRemoteID {\n\t\tlog.Warn().Msgf(\"ignoring hook: repo %s does not match the repo from the token\", repo.FullName)\n\t\tc.String(http.StatusBadRequest, \"failure to parse token from hook\")\n\t\treturn\n\t}\n\n\t//\n\t// 4. Check if the repo is active and has an owner\n\t//\n\n\tif !repo.IsActive {\n\t\tlog.Debug().Msgf(\"ignoring hook: repo %s is inactive\", repoFromForge.FullName)\n\t\tc.Status(http.StatusNoContent)\n\t\treturn\n\t}\n\n\tif repo.UserID == 0 {\n\t\tlog.Warn().Msgf(\"ignoring hook. repo %s has no owner.\", repo.FullName)\n\t\tc.Status(http.StatusNoContent)\n\t\treturn\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tforge.Refresh(c, _forge, _store, user)\n\n\t//\n\t// 4. Update the repo\n\t//\n\n\tif repo.FullName != repoFromForge.FullName {\n\t\t// create a redirection\n\t\terr = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName})\n\t\tif err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\trepo.Update(repoFromForge)\n\terr = _store.UpdateRepo(repo)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\t//\n\t// 5. Check if pull requests are allowed for this repo\n\t//\n\n\tif pipelineFromForge.IsPullRequest() && !repo.AllowPull {\n\t\tlog.Debug().Str(\"repo\", repo.FullName).Msg(\"ignoring hook: pull requests are disabled for this repo in woodpecker\")\n\t\tc.Status(http.StatusNoContent)\n\t\treturn\n\t}\n\n\t//\n\t// 6. Finally create a pipeline\n\t//\n\n\tpl, err := pipeline.Create(c, _store, repo, pipelineFromForge)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t} else {\n\t\tc.JSON(http.StatusOK, pl)\n\t}\n}\n\nfunc getRepoFromToken(store store.Store, t *token.Token) (*model.Repo, error) {\n\tif t.Get(\"repo-forge-remote-id\") != \"\" {\n\t\tforgeID, err := strconv.ParseInt(t.Get(\"forge-id\"), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn store.GetRepoForgeID(forgeID, model.ForgeRemoteID(t.Get(\"repo-forge-remote-id\")))\n\t}\n\n\t// get the repo by the repo-id\n\t// TODO: remove in next major\n\trepoID, err := strconv.ParseInt(t.Get(\"repo-id\"), 10, 64)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn store.GetRepo(repoID)\n}\n"
  },
  {
    "path": "server/api/hook_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api_test\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tconfig_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n\tregistry_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks\"\n\tsecret_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\nfunc TestHook(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t_manager := manager_mocks.NewMockManager(t)\n\t_forge := forge_mocks.NewMockForge(t)\n\t_store := store_mocks.NewMockStore(t)\n\t_configService := config_service_mocks.NewMockService(t)\n\t_secretService := secret_service_mocks.NewMockService(t)\n\t_registryService := registry_service_mocks.NewMockService(t)\n\tserver.Config.Services.Manager = _manager\n\tserver.Config.Permissions.Open = true\n\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\tc.Set(\"store\", _store)\n\tuser := &model.User{\n\t\tID: 123,\n\t}\n\trepo := &model.Repo{\n\t\tID:            123,\n\t\tForgeRemoteID: \"123\",\n\t\tOwner:         \"owner\",\n\t\tName:          \"name\",\n\t\tIsActive:      true,\n\t\tUserID:        user.ID,\n\t\tHash:          \"secret-123-this-is-a-secret\",\n\t}\n\tpipeline := &model.Pipeline{\n\t\tID:     123,\n\t\tRepoID: repo.ID,\n\t\tEvent:  model.EventPush,\n\t}\n\n\trepoToken := token.New(token.HookToken)\n\trepoToken.Set(\"repo-id\", fmt.Sprintf(\"%d\", repo.ID))\n\tsignedToken, err := repoToken.Sign(\"secret-123-this-is-a-secret\")\n\tassert.NoError(t, err)\n\n\theader := http.Header{}\n\theader.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", signedToken))\n\tc.Request = &http.Request{\n\t\tHeader: header,\n\t\tURL: &url.URL{\n\t\t\tScheme: \"https\",\n\t\t},\n\t}\n\n\t_manager.On(\"ForgeFromRepo\", repo).Return(_forge, nil)\n\t_forge.On(\"Hook\", mock.Anything, mock.Anything).Return(repo, pipeline, nil)\n\t_store.On(\"GetRepo\", repo.ID).Return(repo, nil)\n\t_store.On(\"GetUser\", user.ID).Return(user, nil)\n\t_store.On(\"UpdateRepo\", repo).Return(nil)\n\t_store.On(\"CreatePipeline\", mock.Anything).Return(nil)\n\t_manager.On(\"ConfigServiceFromRepo\", repo).Return(_configService)\n\t_configService.On(\"Fetch\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\t_forge.On(\"Netrc\", mock.Anything, mock.Anything).Return(&model.Netrc{}, nil)\n\t_store.On(\"GetPipelineLastBefore\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\t_manager.On(\"SecretServiceFromRepo\", repo).Return(_secretService)\n\t_secretService.On(\"SecretListPipeline\", mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\t_manager.On(\"RegistryServiceFromRepo\", repo).Return(_registryService)\n\t_registryService.On(\"RegistryListPipeline\", mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, nil)\n\t_manager.On(\"EnvironmentService\").Return(nil)\n\t_store.On(\"DeletePipeline\", mock.Anything).Return(nil)\n\n\tapi.PostHook(c)\n\n\tassert.Equal(t, http.StatusNoContent, c.Writer.Status())\n\tassert.Equal(t, \"true\", w.Header().Get(\"Pipeline-Filtered\"))\n}\n"
  },
  {
    "path": "server/api/login.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/base32\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tstateTokenDuration = time.Minute * 5\n\tperPage            = 50\n\tmaxPage            = 10000\n)\n\nfunc HandleAuth(c *gin.Context) {\n\t// TODO: check if this is really needed\n\tc.Writer.Header().Del(\"Content-Type\")\n\n\t// redirect when getting oauth error from forge to login page\n\tif err := c.Request.FormValue(\"error\"); err != \"\" {\n\t\tquery := url.Values{}\n\t\tquery.Set(\"error\", err)\n\t\tif errorDescription := c.Request.FormValue(\"error_description\"); errorDescription != \"\" {\n\t\t\tquery.Set(\"error_description\", errorDescription)\n\t\t}\n\t\tif errorURI := c.Request.FormValue(\"error_uri\"); errorURI != \"\" {\n\t\t\tquery.Set(\"error_uri\", errorURI)\n\t\t}\n\t\tc.Redirect(http.StatusSeeOther, fmt.Sprintf(\"%s/login?%s\", server.Config.Server.RootPath, query.Encode()))\n\t\treturn\n\t}\n\n\t_store := store.FromContext(c)\n\n\tcode := c.Request.FormValue(\"code\")\n\tstate := c.Request.FormValue(\"state\")\n\tisCallback := code != \"\" && state != \"\"\n\tvar forgeID int64\n\n\tif isCallback { // validate the state token\n\t\tstateToken, err := token.Parse([]token.Type{token.OAuthStateToken}, state, func(_ *token.Token) (string, error) {\n\t\t\treturn server.Config.Server.JWTSecret, nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"cannot verify state token\")\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=invalid_state\")\n\t\t\treturn\n\t\t}\n\n\t\t_forgeID := stateToken.Get(\"forge-id\")\n\t\tforgeID, err = strconv.ParseInt(_forgeID, 10, 64)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"forge-id of state token invalid\")\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=invalid_state\")\n\t\t\treturn\n\t\t}\n\t} else { // only generate a state token if not a callback\n\t\tvar err error\n\n\t\t_forgeID := c.Request.FormValue(\"forge_id\")\n\t\tif _forgeID == \"\" {\n\t\t\tforgeID = 1 // fallback to main forge\n\t\t} else {\n\t\t\tforgeID, err = strconv.ParseInt(_forgeID, 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"forge-id of state token invalid\")\n\t\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=invalid_state\")\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tjwtSecret := server.Config.Server.JWTSecret\n\t\texp := time.Now().Add(stateTokenDuration).Unix()\n\t\tstateToken := token.New(token.OAuthStateToken)\n\t\tstateToken.Set(\"forge-id\", strconv.FormatInt(forgeID, 10))\n\t\tstate, err = stateToken.SignExpires(jwtSecret, exp)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"cannot create state token\")\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeByID(forgeID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot get forge by id %d\", forgeID)\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\treturn\n\t}\n\n\tuserFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{\n\t\tCode:  c.Request.FormValue(\"code\"),\n\t\tState: state,\n\t})\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"cannot authenticate user\")\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=oauth_error\")\n\t\treturn\n\t}\n\t// The user is not authorized yet -> redirect\n\tif userFromForge == nil {\n\t\thttp.Redirect(c.Writer, c.Request, redirectURL, http.StatusSeeOther)\n\t\treturn\n\t}\n\n\t// if organization filter is enabled, we need to check if the user is a member of one\n\t// of the configured organizations\n\tif server.Config.Permissions.Orgs.IsConfigured {\n\t\tisMember := false\n\t\tfor page := 1; page <= maxPage; page++ {\n\t\t\tteams, terr := _forge.Teams(c, userFromForge, &model.ListOptions{\n\t\t\t\tPage:    page,\n\t\t\t\tPerPage: perPage,\n\t\t\t})\n\t\t\tif errors.Is(terr, forge_types.ErrNotImplemented) {\n\t\t\t\tlog.Debug().Msg(\"Could not fetch membership of user as forge adapter did not implement it\")\n\t\t\t} else if terr != nil {\n\t\t\t\tlog.Error().Err(terr).Msgf(\"cannot verify team membership for %s\", userFromForge.Login)\n\t\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif server.Config.Permissions.Orgs.IsMember(teams) {\n\t\t\t\tisMember = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !isMember {\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=org_access_denied\")\n\t\t\treturn\n\t\t}\n\t}\n\n\tvar user *model.User\n\n\t// get the user from the database\n\tuser, err = _store.GetUserByRemoteID(forgeID, userFromForge.ForgeRemoteID)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\tlog.Error().Err(err).Msgf(\"cannot get user %s\", userFromForge.Login)\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\treturn\n\t}\n\t// update user login (in case forge supports renaming)\n\tif user != nil {\n\t\tuser.Login = userFromForge.Login\n\t}\n\n\t// re-try with login name\n\tif user == nil || errors.Is(err, types.ErrRecordNotExist) {\n\t\tuser, err = _store.GetUserByLogin(forgeID, userFromForge.Login)\n\t\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot get user %s\", userFromForge.Login)\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\treturn\n\t\t}\n\t}\n\n\tif user == nil || errors.Is(err, types.ErrRecordNotExist) {\n\t\t// if self-registration is disabled we should return a not authorized error\n\t\tif !server.Config.Permissions.Open && !server.Config.Permissions.Admins.IsAdmin(userFromForge) {\n\t\t\tlog.Error().Msgf(\"cannot register %s. registration closed\", userFromForge.Login)\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=registration_closed\")\n\t\t\treturn\n\t\t}\n\n\t\t// create the user account\n\t\tuser = &model.User{\n\t\t\tForgeID:       forgeID,\n\t\t\tForgeRemoteID: userFromForge.ForgeRemoteID,\n\t\t\tLogin:         userFromForge.Login,\n\t\t\tAccessToken:   userFromForge.AccessToken,\n\t\t\tRefreshToken:  userFromForge.RefreshToken,\n\t\t\tExpiry:        userFromForge.Expiry,\n\t\t\tEmail:         userFromForge.Email,\n\t\t\tAvatar:        userFromForge.Avatar,\n\t\t\tHash: base32.StdEncoding.EncodeToString(\n\t\t\t\trandom.GetRandomBytes(32),\n\t\t\t),\n\t\t}\n\n\t\t// insert the user into the database\n\t\tif err := _store.CreateUser(user); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot insert %s\", user.Login)\n\t\t\tlog.Trace().Msgf(\"user was: %#v\", user)\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\treturn\n\t\t}\n\t}\n\n\t// create or set the user's organization if it isn't linked yet\n\tif user.OrgID == 0 {\n\t\t// check if an org with the same name exists already and assign it to the user if it does\n\t\torg, err := _store.OrgFindByName(user.Login, forgeID)\n\t\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot get org for user %s\", user.Login)\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\treturn\n\t\t}\n\n\t\t// if an org with the same name exists => assign org to the user\n\t\tif err == nil && org != nil {\n\t\t\torg.IsUser = true\n\t\t\tuser.OrgID = org.ID\n\n\t\t\tif err := _store.OrgUpdate(org); err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"cannot assign user %s to existing org %d\", user.Login, org.ID)\n\t\t\t}\n\t\t}\n\n\t\t// if still no org with the same name exists => create a new org\n\t\tif user.OrgID == 0 || errors.Is(err, types.ErrRecordNotExist) {\n\t\t\torg := &model.Org{\n\t\t\t\tName:    user.Login,\n\t\t\t\tIsUser:  true,\n\t\t\t\tPrivate: false,\n\t\t\t\tForgeID: user.ForgeID,\n\t\t\t}\n\t\t\tif err := _store.OrgCreate(org); err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"cannot create org for user %s\", user.Login)\n\t\t\t}\n\t\t\tuser.OrgID = org.ID\n\t\t}\n\t} else {\n\t\t// update org name if necessary\n\t\torg, err := _store.OrgGet(user.OrgID)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot get org %d for user %s\", user.OrgID, user.Login)\n\t\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\t\treturn\n\t\t}\n\t\tif org != nil && org.Name != user.Login {\n\t\t\torg.Name = user.Login\n\t\t\tif err := _store.OrgUpdate(org); err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"cannot update org %d name to user name %s\", org.ID, user.Login)\n\t\t\t}\n\t\t}\n\t}\n\n\t// update the user meta data and authorization data.\n\tuser.AccessToken = userFromForge.AccessToken\n\tuser.RefreshToken = userFromForge.RefreshToken\n\tuser.Email = userFromForge.Email\n\tuser.Avatar = userFromForge.Avatar\n\tuser.ForgeID = forgeID\n\tuser.ForgeRemoteID = userFromForge.ForgeRemoteID\n\tuser.Login = userFromForge.Login\n\tuser.Admin = user.Admin || server.Config.Permissions.Admins.IsAdmin(userFromForge)\n\n\tif err := _store.UpdateUser(user); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot update user %s\", user.Login)\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\treturn\n\t}\n\n\texp := time.Now().Add(server.Config.Server.SessionExpires).Unix()\n\t_token := token.New(token.SessToken)\n\t_token.Set(\"user-id\", strconv.FormatInt(user.ID, 10))\n\ttokenString, err := _token.SignExpires(user.Hash, exp)\n\tif err != nil {\n\t\tlog.Error().Msgf(\"cannot create token for user %s\", user.Login)\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\treturn\n\t}\n\n\terr = updateRepoPermissions(c, user, _store, _forge, forgeID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot update repo permissions for user %s\", user.Login)\n\t\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/login?error=internal_error\")\n\t\treturn\n\t}\n\n\thttputil.SetCookie(c.Writer, c.Request, \"user_sess\", tokenString)\n\n\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/\")\n}\n\nfunc updateRepoPermissions(c *gin.Context, user *model.User, _store store.Store, _forge forge.Forge, forgeID int64) error {\n\trepos, err := utils.Paginate(func(page int) ([]*model.Repo, error) {\n\t\treturn _forge.Repos(c, user, &model.ListOptions{\n\t\t\tPage:    page,\n\t\t\tPerPage: perPage,\n\t\t})\n\t}, maxPage)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar repoIDs []int64\n\n\tfor _, forgeRepo := range repos {\n\t\t// make sure forgeID is set\n\t\tforgeRepo.ForgeID = forgeID\n\n\t\tdbRepo, err := _store.GetRepoForgeID(forgeID, forgeRepo.ForgeRemoteID)\n\t\tif err != nil && errors.Is(err, types.ErrRecordNotExist) {\n\t\t\tcontinue\n\t\t}\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif !dbRepo.IsActive {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debug().Msgf(\"synced user permission for user %s and repo %s\", user.Login, dbRepo.FullName)\n\t\tperm := forgeRepo.Perm\n\t\tperm.RepoID = dbRepo.ID\n\t\tperm.UserID = user.ID\n\t\tperm.Synced = time.Now().Unix()\n\t\tif err := _store.PermUpsert(perm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trepoIDs = append(repoIDs, dbRepo.ID)\n\t}\n\n\tif err := _store.PermPrune(user.ID, repoIDs); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc GetLogout(c *gin.Context) {\n\thttputil.DelCookie(c.Writer, c.Request, \"user_sess\")\n\thttputil.DelCookie(c.Writer, c.Request, \"user_last\")\n\tc.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+\"/\")\n}\n"
  },
  {
    "path": "server/api/login_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\nfunc TestHandleAuth(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tuser := &model.User{\n\t\tID:            1,\n\t\tOrgID:         1,\n\t\tForgeID:       1,\n\t\tForgeRemoteID: \"remote-id-1\",\n\t\tLogin:         \"test\",\n\t\tEmail:         \"test@example.com\",\n\t\tAdmin:         false,\n\t}\n\torg := &model.Org{\n\t\tID:   1,\n\t\tName: user.Login,\n\t}\n\n\tserver.Config.Server.SessionExpires = time.Hour\n\n\tt.Run(\"should handle errors from the callback\", func(t *testing.T) {\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\n\t\tquery := url.Values{}\n\t\tquery.Set(\"error\", \"invalid_scope\")\n\t\tquery.Set(\"error_description\", \"The requested scope is invalid, unknown, or malformed\")\n\t\tquery.Set(\"error_uri\", \"https://developer.atlassian.com/cloud/jira/platform/rest/#api-group-OAuth2-ErrorHandling\")\n\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tMethod: http.MethodGet,\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme:   \"https\",\n\t\t\t\tPath:     \"/authorize\",\n\t\t\t\tRawQuery: query.Encode(),\n\t\t\t},\n\t\t}\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, fmt.Sprintf(\"/login?%s\", query.Encode()), c.Writer.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"should fail if the state is wrong\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\n\t\tquery := url.Values{}\n\t\tquery.Set(\"code\", \"assumed_to_be_valid_code\")\n\n\t\twrongToken := token.New(token.OAuthStateToken)\n\t\twrongToken.Set(\"forge_id\", \"1\")\n\t\tsignedWrongToken, _ := wrongToken.Sign(\"wrong_secret\")\n\t\tquery.Set(\"state\", signedWrongToken)\n\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme:   \"https\",\n\t\t\t\tRawQuery: query.Encode(),\n\t\t\t},\n\t\t}\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/login?error=invalid_state\", c.Writer.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"should redirect to forge login page\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\n\t\tforgeRedirectURL := \"\"\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {\n\t\t\tstate, ok := args.Get(1).(*forge_types.OAuthRequest)\n\t\t\tif ok {\n\t\t\t\tforgeRedirectURL = fmt.Sprintf(\"https://my-awesome-forge.com/oauth/authorize?client_id=client-id&state=%s\", state.State)\n\t\t\t}\n\t\t}).Return(nil, func(context.Context, *forge_types.OAuthRequest) string {\n\t\t\treturn forgeRedirectURL\n\t\t}, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, forgeRedirectURL, c.Writer.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"should register a new user\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(nil, types.ErrRecordNotExist)\n\t\t_store.On(\"GetUserByLogin\", user.ForgeID, user.Login).Return(nil, types.ErrRecordNotExist)\n\t\t_store.On(\"CreateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"OrgFindByName\", user.Login, user.ForgeID).Return(nil, nil)\n\t\t_store.On(\"OrgCreate\", mock.Anything).Return(nil)\n\t\t_store.On(\"UpdateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"PermPrune\", mock.Anything, []int64(nil)).Return(nil)\n\t\t_forge.On(\"Repos\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/\", c.Writer.Header().Get(\"Location\"))\n\t\tassert.NotEmpty(t, c.Writer.Header().Get(\"Set-Cookie\"))\n\t})\n\n\tt.Run(\"should login an existing user\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(user, nil)\n\t\t_store.On(\"OrgGet\", org.ID).Return(org, nil)\n\t\t_store.On(\"UpdateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"PermPrune\", mock.Anything, []int64(nil)).Return(nil)\n\t\t_forge.On(\"Repos\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/\", c.Writer.Header().Get(\"Location\"))\n\t\tassert.NotEmpty(t, c.Writer.Header().Get(\"Set-Cookie\"))\n\t})\n\n\tt.Run(\"should deny a new user if registration is closed\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = false\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(nil, types.ErrRecordNotExist)\n\t\t_store.On(\"GetUserByLogin\", user.ForgeID, user.Login).Return(nil, types.ErrRecordNotExist)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/login?error=registration_closed\", c.Writer.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"should deny a user with missing org access\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs([]string{\"org1\"})\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_forge.On(\"Teams\", mock.Anything, user, mock.Anything).Return([]*model.Team{\n\t\t\t{\n\t\t\t\tLogin: \"org2\",\n\t\t\t},\n\t\t}, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/login?error=org_access_denied\", c.Writer.Header().Get(\"Location\"))\n\t})\n\n\tt.Run(\"should create an user org if it does not exists\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\t\tuser.OrgID = 0\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(user, nil)\n\t\t_store.On(\"OrgFindByName\", user.Login, user.ForgeID).Return(nil, types.ErrRecordNotExist)\n\t\t_store.On(\"OrgCreate\", mock.Anything).Return(nil)\n\t\t_store.On(\"UpdateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"PermPrune\", mock.Anything, []int64(nil)).Return(nil)\n\t\t_forge.On(\"Repos\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/\", c.Writer.Header().Get(\"Location\"))\n\t\tassert.NotEmpty(t, c.Writer.Header().Get(\"Set-Cookie\"))\n\t})\n\n\tt.Run(\"should link an user org if it has the same name as the user\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\t\tuser.OrgID = 0\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(user, nil)\n\t\t_store.On(\"OrgFindByName\", user.Login, user.ForgeID).Return(org, nil)\n\t\t_store.On(\"OrgUpdate\", mock.Anything).Return(nil)\n\t\t_store.On(\"UpdateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"PermPrune\", mock.Anything, []int64(nil)).Return(nil)\n\t\t_forge.On(\"Repos\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/\", c.Writer.Header().Get(\"Location\"))\n\t\tassert.NotEmpty(t, c.Writer.Header().Get(\"Set-Cookie\"))\n\t})\n\n\tt.Run(\"should update an user org if the user name was changed\", func(t *testing.T) {\n\t\t_manager := manager_mocks.NewMockManager(t)\n\t\t_forge := forge_mocks.NewMockForge(t)\n\t\t_store := store_mocks.NewMockStore(t)\n\t\tserver.Config.Services.Manager = _manager\n\t\tserver.Config.Permissions.Open = true\n\t\tserver.Config.Permissions.Orgs = permissions.NewOrgs(nil)\n\t\tserver.Config.Permissions.Admins = permissions.NewAdmins(nil)\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", _store)\n\t\tc.Request = &http.Request{\n\t\t\tHeader: make(http.Header),\n\t\t\tURL: &url.URL{\n\t\t\t\tScheme: \"https\",\n\t\t\t},\n\t\t}\n\t\torg.Name = \"not-the-user-name\"\n\n\t\t_manager.On(\"ForgeByID\", int64(1)).Return(_forge, nil)\n\t\t_forge.On(\"Login\", mock.Anything, mock.Anything).Return(user, \"\", nil)\n\t\t_store.On(\"GetUserByRemoteID\", user.ForgeID, user.ForgeRemoteID).Return(user, nil)\n\t\t_store.On(\"OrgGet\", user.OrgID).Return(org, nil)\n\t\t_store.On(\"OrgUpdate\", mock.Anything).Return(nil)\n\t\t_store.On(\"UpdateUser\", mock.Anything).Return(nil)\n\t\t_store.On(\"PermPrune\", mock.Anything, []int64(nil)).Return(nil)\n\t\t_forge.On(\"Repos\", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)\n\n\t\tapi.HandleAuth(c)\n\n\t\tassert.Equal(t, http.StatusSeeOther, c.Writer.Status())\n\t\tassert.Equal(t, \"/\", c.Writer.Header().Get(\"Location\"))\n\t\tassert.NotEmpty(t, c.Writer.Header().Get(\"Set-Cookie\"))\n\t})\n}\n"
  },
  {
    "path": "server/api/metrics/prometheus.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage metrics\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n)\n\n// errInvalidToken is returned when the api request token is invalid.\nvar errInvalidToken = errors.New(\"invalid or missing token\")\n\n// PromHandler will pass the call from /api/metrics/prometheus to prometheus.\nfunc PromHandler() gin.HandlerFunc {\n\thandler := promhttp.Handler()\n\n\treturn func(c *gin.Context) {\n\t\ttoken := server.Config.Prometheus.AuthToken\n\n\t\tif token == \"\" {\n\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\theader := c.Request.Header.Get(\"Authorization\")\n\n\t\tif header == \"\" {\n\t\t\tc.String(http.StatusUnauthorized, errInvalidToken.Error())\n\t\t\treturn\n\t\t}\n\n\t\tbearer := fmt.Sprintf(\"Bearer %s\", token)\n\n\t\tif header != bearer {\n\t\t\tc.String(http.StatusForbidden, errInvalidToken.Error())\n\t\t\treturn\n\t\t}\n\n\t\thandler.ServeHTTP(c.Writer, c.Request)\n\t}\n}\n"
  },
  {
    "path": "server/api/org.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// GetOrgs\n//\n//\t@Summary\t\tList organizations\n//\t@Description\tReturns all registered orgs in the system. Requires admin rights.\n//\t@Router\t\t\t/orgs [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tOrg\n//\t@Tags\t\t\tOrgs\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetOrgs(c *gin.Context) {\n\torgs, err := store.FromContext(c).OrgList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting user list. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, orgs)\n}\n\n// GetOrg\n//\n//\t@Summary\tGet an organization\n//\t@Router\t\t/orgs/{org_id} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tOrg\n//\t@Tags\t\tOrganization\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the organization's id\"\nfunc GetOrg(c *gin.Context) {\n\torg := session.Org(c)\n\tc.JSON(http.StatusOK, org)\n}\n\n// GetOrgPermissions\n//\n//\t@Summary\tGet the permissions of the currently authenticated user for the given organization\n//\t@Router\t\t/orgs/{org_id}/permissions [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tOrgPerm\n//\t@Tags\t\tOrganization permissions\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the organization's id\"\nfunc GetOrgPermissions(c *gin.Context) {\n\tuser := session.User(c)\n\torg := session.Org(c)\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tc.JSON(http.StatusOK, &model.OrgPerm{})\n\t\treturn\n\t}\n\n\tif (org.IsUser && org.Name == user.Login) || (user.Admin && !org.IsUser) {\n\t\tc.JSON(http.StatusOK, &model.OrgPerm{\n\t\t\tMember: true,\n\t\t\tAdmin:  true,\n\t\t})\n\t\treturn\n\t} else if org.IsUser {\n\t\tc.JSON(http.StatusOK, &model.OrgPerm{})\n\t\treturn\n\t}\n\n\tperm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting membership for %d. %s\", org.ID, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, perm)\n}\n\n// LookupOrg\n//\n//\t@Summary\tLookup an organization by full name\n//\t@Router\t\t/orgs/lookup/{org_full_name} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tOrg\n//\t@Tags\t\tOrgs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_full_name\tpath\tstring\ttrue\t\"the organizations full name / slug\"\nfunc LookupOrg(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\torgFullName := strings.TrimLeft(c.Param(\"org_full_name\"), \"/\")\n\n\torg, err := _store.OrgFindByName(orgFullName, user.ForgeID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\t// don't leak private org infos\n\tif org.Private {\n\t\tuser := session.User(c)\n\t\tif user == nil {\n\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tif !user.Admin && org.Name != user.Login {\n\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\treturn\n\t\t} else if !user.Admin {\n\t\t\tperm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"failed to check membership\")\n\t\t\t\tc.Status(http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif perm == nil || !perm.Member {\n\t\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, org)\n}\n\n// DeleteOrg\n//\n//\t@Summary\t\tDelete an organization\n//\t@Description\tDeletes the given org. Requires admin rights.\n//\t@Router\t\t\t/orgs/{id} [delete]\n//\t@Produce\t\tplain\n//\t@Success\t\t204\n//\t@Tags\t\t\tOrgs\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tid\t\t\t\tpath\tstring\ttrue\t\"the org's id\"\nfunc DeleteOrg(c *gin.Context) {\n\t_store := store.FromContext(c)\n\torg := session.Org(c)\n\n\tif err := _store.OrgDelete(org.ID); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/org_registry.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetOrgRegistry\n//\n//\t@Summary\tGet a organization registry by address\n//\t@Router\t\t/orgs/{org_id}/registries/{registry} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tOrganization registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry's address\"\nfunc GetOrgRegistry(c *gin.Context) {\n\torg := session.Org(c)\n\taddr := c.Param(\"registry\")\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tregistry, err := registryService.OrgRegistryFind(org.ID, addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// GetOrgRegistryList\n//\n//\t@Summary\tList organization registries\n//\t@Router\t\t/orgs/{org_id}/registries [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tRegistry\n//\t@Tags\t\tOrganization registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetOrgRegistryList(c *gin.Context) {\n\torg := session.Org(c)\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tlist, err := registryService.OrgRegistryList(org.ID, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting registry list for %q. %s\", org.ID, err)\n\t\treturn\n\t}\n\t// copy the registry detail to remove the sensitive\n\t// password and token fields.\n\tfor i, registry := range list {\n\t\tlist[i] = registry.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// PostOrgRegistry\n//\n//\t@Summary\tCreate an organization registry\n//\t@Router\t\t/orgs/{org_id}/registries [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tOrganization registries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\t\ttrue\t\"the org's id\"\n//\t@Param\t\tregistryData\tbody\tRegistry\ttrue\t\"the new registry\"\nfunc PostOrgRegistry(c *gin.Context) {\n\torg := session.Org(c)\n\n\tin := new(model.Registry)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing org %q registry. %s\", org.ID, err)\n\t\treturn\n\t}\n\tregistry := &model.Registry{\n\t\tOrgID:    org.ID,\n\t\tAddress:  in.Address,\n\t\tUsername: in.Username,\n\t\tPassword: in.Password,\n\t}\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error inserting org %q registry. %s\", org.ID, err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tif err := registryService.OrgRegistryCreate(org.ID, registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting org %q registry %q. %s\", org.ID, in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// PatchOrgRegistry\n//\n//\t@Summary\tUpdate an organization registry by name\n//\t@Router\t\t/orgs/{org_id}/registries/{registry} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tOrganization registries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\t\ttrue\t\"the org's id\"\n//\t@Param\t\tregistry\t\tpath\tstring\t\ttrue\t\"the registry's name\"\n//\t@Param\t\tregistryData\tbody\tRegistry\ttrue\t\"the update registry data\"\nfunc PatchOrgRegistry(c *gin.Context) {\n\torg := session.Org(c)\n\taddr := c.Param(\"registry\")\n\n\tin := new(model.Registry)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing registry. %s\", err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tregistry, err := registryService.OrgRegistryFind(org.ID, addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Address != \"\" {\n\t\tregistry.Address = in.Address\n\t}\n\tif in.Username != \"\" {\n\t\tregistry.Username = in.Username\n\t}\n\tif in.Password != \"\" {\n\t\tregistry.Password = in.Password\n\t}\n\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error updating org %q registry. %s\", org.ID, err)\n\t\treturn\n\t}\n\n\tif err := registryService.OrgRegistryUpdate(org.ID, registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating org %q registry %q. %s\", org.ID, in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// DeleteOrgRegistry\n//\n//\t@Summary\tDelete an organization registry by name\n//\t@Router\t\t/orgs/{org_id}/registries/{registry} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tOrganization registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry's name\"\nfunc DeleteOrgRegistry(c *gin.Context) {\n\torg := session.Org(c)\n\taddr := c.Param(\"registry\")\n\n\tregistryService := server.Config.Services.Manager.RegistryService()\n\tif err := registryService.OrgRegistryDelete(org.ID, addr); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/org_secret.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetOrgSecret\n//\n//\t@Summary\tGet a organization secret by name\n//\t@Router\t\t/orgs/{org_id}/secrets/{secret} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tOrganization secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tsecret\t\t\tpath\tstring\ttrue\t\"the secret's name\"\nfunc GetOrgSecret(c *gin.Context) {\n\torg := session.Org(c)\n\tname := c.Param(\"secret\")\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tsecret, err := secretService.OrgSecretFind(org.ID, name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// GetOrgSecretList\n//\n//\t@Summary\tList organization secrets\n//\t@Router\t\t/orgs/{org_id}/secrets [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tSecret\n//\t@Tags\t\tOrganization secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetOrgSecretList(c *gin.Context) {\n\torg := session.Org(c)\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tlist, err := secretService.OrgSecretList(org.ID, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting secret list for %q. %s\", org.ID, err)\n\t\treturn\n\t}\n\t// copy the secret detail to remove the sensitive\n\t// password and token fields.\n\tfor i, secret := range list {\n\t\tlist[i] = secret.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// PostOrgSecret\n//\n//\t@Summary\tCreate an organization secret\n//\t@Router\t\t/orgs/{org_id}/secrets [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tOrganization secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tsecretData\t\tbody\tSecret\ttrue\t\"the new secret\"\nfunc PostOrgSecret(c *gin.Context) {\n\torg := session.Org(c)\n\n\tin := new(model.Secret)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing org %q secret. %s\", org.ID, err)\n\t\treturn\n\t}\n\tsecret := &model.Secret{\n\t\tOrgID:  org.ID,\n\t\tName:   in.Name,\n\t\tValue:  in.Value,\n\t\tEvents: in.Events,\n\t\tImages: in.Images,\n\t\tNote:   in.Note,\n\t}\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error inserting org %q secret. %s\", org.ID, err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tif err := secretService.OrgSecretCreate(org.ID, secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting org %q secret %q. %s\", org.ID, in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// PatchOrgSecret\n//\n//\t@Summary\tUpdate an organization secret by name\n//\t@Router\t\t/orgs/{org_id}/secrets/{secret} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tOrganization secrets\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\t\ttrue\t\"the org's id\"\n//\t@Param\t\tsecret\t\t\tpath\tstring\t\ttrue\t\"the secret's name\"\n//\t@Param\t\tsecretData\t\tbody\tSecretPatch\ttrue\t\"the update secret data\"\nfunc PatchOrgSecret(c *gin.Context) {\n\torg := session.Org(c)\n\tname := c.Param(\"secret\")\n\n\tin := new(model.SecretPatch)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing secret. %s\", err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tsecret, err := secretService.OrgSecretFind(org.ID, name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Value != nil && *in.Value != \"\" {\n\t\tsecret.Value = *in.Value\n\t}\n\tif in.Events != nil {\n\t\tsecret.Events = in.Events\n\t}\n\tif in.Images != nil {\n\t\tsecret.Images = in.Images\n\t}\n\tif in.Note != nil {\n\t\tsecret.Note = *in.Note\n\t}\n\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error updating org %q secret. %s\", org.ID, err)\n\t\treturn\n\t}\n\n\tif err := secretService.OrgSecretUpdate(org.ID, secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating org %q secret %q. %s\", org.ID, in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// DeleteOrgSecret\n//\n//\t@Summary\tDelete an organization secret by name\n//\t@Router\t\t/orgs/{org_id}/secrets/{secret} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tOrganization secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\torg_id\t\t\tpath\tstring\ttrue\t\"the org's id\"\n//\t@Param\t\tsecret\t\t\tpath\tstring\ttrue\t\"the secret's name\"\nfunc DeleteOrgSecret(c *gin.Context) {\n\torg := session.Org(c)\n\tname := c.Param(\"secret\")\n\n\tsecretService := server.Config.Services.Manager.SecretService()\n\tif err := secretService.OrgSecretDelete(org.ID, name); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/pipeline.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// CreatePipeline\n//\n//\t@Summary\tTrigger a manual pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tPipeline\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\t\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\toptions\t\t\tbody\tPipelineOptions\ttrue\t\"the options for the pipeline to run\"\nfunc CreatePipeline(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// parse create options\n\tvar opts model.PipelineOptions\n\terr = json.NewDecoder(c.Request.Body).Decode(&opts)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tuser := session.User(c)\n\n\tlastCommit, err := _forge.BranchHead(c, user, repo, opts.Branch)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, fmt.Errorf(\"could not fetch branch head: %w\", err))\n\t\treturn\n\t}\n\n\ttmpPipeline := createTmpPipeline(model.EventManual, lastCommit, user, &opts)\n\n\tpl, err := pipeline.Create(c, _store, repo, tmpPipeline)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t\treturn\n\t}\n\n\tif pl != nil {\n\t\tc.JSON(http.StatusOK, pl.ToAPIModel())\n\t} else {\n\t\tc.Status(http.StatusNoContent)\n\t}\n}\n\nfunc createTmpPipeline(event model.WebhookEvent, commit *model.Commit, user *model.User, opts *model.PipelineOptions) *model.Pipeline {\n\treturn &model.Pipeline{\n\t\tEvent:     event,\n\t\tCommit:    commit.SHA,\n\t\tBranch:    opts.Branch,\n\t\tTimestamp: time.Now().UTC().Unix(),\n\n\t\tAvatar:  user.Avatar,\n\t\tMessage: \"MANUAL PIPELINE @ \" + opts.Branch,\n\n\t\tRef:                 opts.Branch,\n\t\tAdditionalVariables: opts.Variables,\n\n\t\tAuthor: user.Login,\n\t\tEmail:  user.Email,\n\n\t\tForgeURL: commit.ForgeURL,\n\t}\n}\n\n// GetPipelines\n//\n//\t@Summary\t\tList repository pipelines\n//\t@Description\tGet a list of pipelines for a repository.\n//\t@Router\t\t\t/repos/{repo_id}/pipelines [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tPipeline\n//\t@Tags\t\t\tPipelines\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\n//\t@Param\t\t\tbefore\t\t\tquery\tstring\tfalse\t\"only return pipelines before this RFC3339 date\"\n//\t@Param\t\t\tafter\t\t\tquery\tstring\tfalse\t\"only return pipelines after this RFC3339 date\"\n//\t@Param\t\t\tbranch\t\t\tquery\tstring\tfalse\t\"filter pipelines by branch\"\n//\t@Param\t\t\tevent\t\t\tquery\tstring\tfalse\t\"filter pipelines by webhook events (comma separated)\"\n//\t@Param\t\t\tref\t\t\t\tquery\tstring\tfalse\t\"filter pipelines by strings contained in ref\"\n//\t@Param\t\t\tstatus\t\t\tquery\tstring\tfalse\t\"filter pipelines by status\"\nfunc GetPipelines(c *gin.Context) {\n\trepo := session.Repo(c)\n\n\tfilter := &model.PipelineFilter{\n\t\tBranch:      c.Query(\"branch\"),\n\t\tRefContains: c.Query(\"ref\"),\n\t}\n\n\tif events := c.Query(\"event\"); events != \"\" {\n\t\teventList := strings.Split(events, \",\")\n\t\twel := make(model.WebhookEventList, 0, len(eventList))\n\t\tfor _, event := range eventList {\n\t\t\twe := model.WebhookEvent(event)\n\t\t\tif err := we.Validate(); err != nil {\n\t\t\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\twel = append(wel, we)\n\t\t}\n\t\tfilter.Events = wel\n\t}\n\n\tif status := c.Query(\"status\"); status != \"\" {\n\t\tps := model.StatusValue(status)\n\t\tif err := ps.Validate(); err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\t\treturn\n\t\t}\n\t\tfilter.Status = ps\n\t}\n\n\tif before := c.Query(\"before\"); before != \"\" {\n\t\tbeforeDt, err := time.Parse(time.RFC3339, before)\n\t\tif err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\t\treturn\n\t\t}\n\t\tfilter.Before = beforeDt.Unix()\n\t}\n\n\tif after := c.Query(\"after\"); after != \"\" {\n\t\tafterDt, err := time.Parse(time.RFC3339, after)\n\t\tif err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\t\treturn\n\t\t}\n\t\tfilter.After = afterDt.Unix()\n\t}\n\n\tpipelines, err := store.FromContext(c).GetPipelineList(repo, session.Pagination(c), filter)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tpls := make([]*model.APIPipeline, len(pipelines))\n\tfor i, p := range pipelines {\n\t\tpls[i] = p.ToAPIModel()\n\t}\n\tc.JSON(http.StatusOK, pls)\n}\n\n// DeletePipeline\n//\n//\t@Summary\tDelete a pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc DeletePipeline(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\trepo := session.Repo(c)\n\tnum, err := strconv.ParseInt(c.Param(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tif ok := pipelineDeleteAllowed(pl); !ok {\n\t\tc.String(http.StatusUnprocessableEntity, \"Cannot delete pipeline with status %s\", pl.Status)\n\t\treturn\n\t}\n\n\terr = store.FromContext(c).DeletePipeline(pl)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error deleting pipeline. %s\", err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n\n// GetPipeline\n//\n//\t@Summary\tGet a repositories pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tPipeline\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline, OR 'latest'\"\nfunc GetPipeline(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tif c.Param(\"pipeline_number\") == \"latest\" {\n\t\tGetPipelineLastByBranch(c)\n\t\treturn\n\t}\n\n\trepo := session.Repo(c)\n\tnum, err := strconv.ParseInt(c.Param(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif pl.Workflows, err = _store.WorkflowGetTree(pl); err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, pl.ToAPIModel())\n}\n\nfunc GetPipelineLastByBranch(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tbranch := c.DefaultQuery(\"branch\", repo.Branch)\n\n\tpl, err := _store.GetPipelineLastByBranch(repo, branch)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tif pl.Workflows, err = _store.WorkflowGetTree(pl); err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, pl.ToAPIModel())\n}\n\n// GetStepLogs\n//\n//\t@Summary\tGet logs for a pipeline step\n//\t@Router\t\t/repos/{repo_id}/logs/{pipeline_number}/{step_id} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tLogEntry\n//\t@Tags\t\tPipeline logs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\n//\t@Param\t\tstep_id\t\t\tpath\tint\t\ttrue\t\"the step id\"\nfunc GetStepLogs(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\n\t// parse the pipeline number and step sequence number from\n\t// the request parameter.\n\tnum, err := strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tstepID, err := strconv.ParseInt(c.Params.ByName(\"step_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tstep, err := _store.StepLoad(pl.ID, stepID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tlogs, err := server.Config.Services.LogStore.LogFind(step)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, logs)\n}\n\n// DeleteStepLogs\n//\n//\t@Summary\tDelete step logs of a pipeline\n//\t@Router\t\t/repos/{repo_id}/logs/{pipeline_number}/{step_id} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipeline logs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\n//\t@Param\t\tstep_id\t\t\tpath\tint\t\ttrue\t\"the step id\"\nfunc DeleteStepLogs(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\n\tpipelineNumber, err := strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\t_pipeline, err := _store.GetPipelineNumber(repo, pipelineNumber)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tstepID, err := strconv.ParseInt(c.Params.ByName(\"step_id\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\t_step, err := _store.StepLoad(_pipeline.ID, stepID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tswitch _step.State {\n\tcase model.StatusRunning, model.StatusPending:\n\t\tc.String(http.StatusUnprocessableEntity, \"Cannot delete logs for a pending or running step\")\n\t\treturn\n\t}\n\n\terr = _store.LogDelete(_step)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n\n// GetPipelineConfig\n//\n//\t@Summary\tGet configuration files for a pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number}/config [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tConfig\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc GetPipelineConfig(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tnum, err := strconv.ParseInt(c.Param(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tconfigs, err := _store.ConfigsForPipeline(pl.ID)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, configs)\n}\n\n// GetPipelineMetadata\n//\n//\t@Summary\tGet metadata for a pipeline or a specific workflow, including previous pipeline info\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number}/metadata [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tmetadata.Metadata\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc GetPipelineMetadata(c *gin.Context) {\n\trepo := session.Repo(c)\n\tnum, err := strconv.ParseInt(c.Param(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\t_store := store.FromContext(c)\n\tcurrentPipeline, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tforge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tprevPipeline, err := _store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tmetadata := step_builder.MetadataFromStruct(forge, repo, currentPipeline, prevPipeline, nil, server.Config.Server.Host)\n\tc.JSON(http.StatusOK, metadata)\n}\n\n// CancelPipeline\n//\n//\t@Summary\tCancel a pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number}/cancel [post]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc CancelPipeline(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tnum, _ := strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tif err := pipeline.Cancel(c, _forge, _store, repo, user, pl, &model.CancelInfo{\n\t\tCanceledByUser: user.Login,\n\t}); err != nil {\n\t\thandlePipelineErr(c, err)\n\t} else {\n\t\tc.Status(http.StatusNoContent)\n\t}\n}\n\n// PostApproval\n//\n//\t@Summary\tApprove and start a pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number}/approve [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tPipeline\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc PostApproval(c *gin.Context) {\n\tvar (\n\t\t_store = store.FromContext(c)\n\t\trepo   = session.Repo(c)\n\t\tuser   = session.User(c)\n\t\tnum, _ = strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\t)\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tnewPipeline, err := pipeline.Approve(c, _store, pl, user, repo)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t} else {\n\t\tc.JSON(http.StatusOK, newPipeline.ToAPIModel())\n\t}\n}\n\n// PostDecline\n//\n//\t@Summary\tDecline a pipeline\n//\t@Router\t\t/repos/{repo_id}/pipelines/{pipeline_number}/decline [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tPipeline\n//\t@Tags\t\tPipelines\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc PostDecline(c *gin.Context) {\n\tvar (\n\t\t_store = store.FromContext(c)\n\t\trepo   = session.Repo(c)\n\t\tuser   = session.User(c)\n\t\tnum, _ = strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\t)\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tpl, err = pipeline.Decline(c, _store, pl, user, repo)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t} else {\n\t\tc.JSON(http.StatusOK, pl.ToAPIModel())\n\t}\n}\n\n// GetPipelineQueue\n//\n//\t@Summary\tList pipelines in queue\n//\t@Router\t\t/pipelines [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tFeed\n//\t@Tags\t\tPipeline queues\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc GetPipelineQueue(c *gin.Context) {\n\tout, err := store.FromContext(c).GetPipelineQueue()\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting pipeline queue. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, out)\n}\n\n// PostPipeline\n//\n//\t@Summary\t\tRestart a pipeline\n//\t@Description\tRestarts a pipeline optional with altered event, deploy or environment\n//\t@Router\t\t\t/repos/{repo_id}/pipelines/{pipeline_number} [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tPipeline\n//\t@Tags\t\t\tPipelines\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\n//\t@Param\t\t\tevent\t\t\tquery\tstring\tfalse\t\"override the event type\"\n//\t@Param\t\t\tdeploy_to\t\tquery\tstring\tfalse\t\"override the target deploy value\"\nfunc PostPipeline(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\n\tnum, err := strconv.ParseInt(c.Param(\"pipeline_number\"), 10, 64)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\t// refresh the token to make sure, pipeline.Restart can still obtain the pipeline config if necessary again\n\trefreshUserToken(c, user)\n\n\t// make Deploy overridable\n\n\t// make Deploy task overridable\n\tpl.DeployTask = c.DefaultQuery(\"deploy_task\", pl.DeployTask)\n\n\t// make Event overridable to deploy\n\t// TODO: refactor to use own proper API for deploy\n\tif event, ok := c.GetQuery(\"event\"); ok {\n\t\tpl.Event = model.WebhookEvent(event)\n\t\tif pl.Event != model.EventDeploy {\n\t\t\t_ = c.AbortWithError(http.StatusBadRequest, model.ErrInvalidWebhookEvent)\n\t\t\treturn\n\t\t}\n\n\t\tif !repo.AllowDeploy {\n\t\t\t_ = c.AbortWithError(http.StatusForbidden, fmt.Errorf(\"repo does not allow deployments\"))\n\t\t\treturn\n\t\t}\n\n\t\tpl.DeployTo = c.DefaultQuery(\"deploy_to\", pl.DeployTo)\n\t}\n\n\t// Read query string parameters into pipelineParams, exclude reserved params\n\tenvs := map[string]string{}\n\tfor key, val := range c.Request.URL.Query() {\n\t\tswitch key {\n\t\t// Skip some options of the endpoint\n\t\tcase \"fork\", \"event\", \"deploy_to\":\n\t\t\tcontinue\n\t\tdefault:\n\t\t\t// We only accept string literals, because pipeline parameters will be\n\t\t\t// injected as environment variables\n\t\t\t// TODO: sanitize the value\n\t\t\tenvs[key] = val[0]\n\t\t}\n\t}\n\n\tnewPipeline, err := pipeline.Restart(c, _store, pl, user, repo, envs)\n\tif err != nil {\n\t\thandlePipelineErr(c, err)\n\t} else {\n\t\tc.JSON(http.StatusOK, newPipeline.ToAPIModel())\n\t}\n}\n\n// DeletePipelineLogs\n//\n//\t@Summary\tDeletes all logs of a pipeline\n//\t@Router\t\t/repos/{repo_id}/logs/{pipeline_number} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipeline logs\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline_number\tpath\tint\t\ttrue\t\"the number of the pipeline\"\nfunc DeletePipelineLogs(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\trepo := session.Repo(c)\n\tnum, _ := strconv.ParseInt(c.Params.ByName(\"pipeline_number\"), 10, 64)\n\n\tpl, err := _store.GetPipelineNumber(repo, num)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tsteps, err := _store.StepList(pl.ID)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tif ok := pipelineDeleteAllowed(pl); !ok {\n\t\tc.String(http.StatusUnprocessableEntity, \"Cannot delete logs for pipeline with status %s\", pl.Status)\n\t\treturn\n\t}\n\n\tfor _, step := range steps {\n\t\tif lErr := server.Config.Services.LogStore.LogDelete(step); err != nil {\n\t\t\terr = errors.Join(err, lErr)\n\t\t}\n\t}\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error deleting pipeline logs. %s\", err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/pipeline_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\tqueue_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\tconfig_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\tregistry_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks\"\n\tsecret_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nvar fakePipeline = &model.Pipeline{\n\tID:     2,\n\tNumber: 2,\n\tStatus: model.StatusSuccess,\n}\n\nfunc TestGetPipelines(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should get pipelines\", func(t *testing.T) {\n\t\tpipelines := []*model.Pipeline{fakePipeline}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineList\", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\n\t\tGetPipelines(c)\n\n\t\tmockStore.AssertCalled(t, \"GetPipelineList\", mock.Anything, mock.Anything, mock.Anything)\n\t\tassert.Equal(t, http.StatusOK, c.Writer.Status())\n\t})\n\n\tt.Run(\"should not parse pipeline filter\", func(t *testing.T) {\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tc.Request, _ = http.NewRequest(http.MethodDelete, \"/?before=2023-01-16&after=2023-01-15\", nil)\n\n\t\tGetPipelines(c)\n\n\t\tassert.Equal(t, http.StatusBadRequest, c.Writer.Status())\n\t})\n\n\tt.Run(\"should parse pipeline filter\", func(t *testing.T) {\n\t\tpipelines := []*model.Pipeline{fakePipeline}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineList\", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)\n\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Request, _ = http.NewRequest(http.MethodDelete, \"/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z\", nil)\n\n\t\tGetPipelines(c)\n\n\t\tassert.Equal(t, http.StatusOK, c.Writer.Status())\n\t})\n\n\tt.Run(\"should parse pipeline filter with tz offset\", func(t *testing.T) {\n\t\tpipelines := []*model.Pipeline{fakePipeline}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineList\", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)\n\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Request, _ = http.NewRequest(http.MethodDelete, \"/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00\", nil)\n\n\t\tGetPipelines(c)\n\n\t\tassert.Equal(t, http.StatusOK, c.Writer.Status())\n\t})\n\n\tt.Run(\"should filter pipelines by events\", func(t *testing.T) {\n\t\tpipelines := []*model.Pipeline{fakePipeline}\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineList\", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Request, _ = http.NewRequest(http.MethodGet, \"/?event=push,pull_request\", nil)\n\n\t\tGetPipelines(c)\n\n\t\tmockStore.AssertCalled(t, \"GetPipelineList\", mock.Anything, mock.Anything, &model.PipelineFilter{\n\t\t\tEvents: model.WebhookEventList{model.EventPush, model.EventPull},\n\t\t})\n\t\tassert.Equal(t, http.StatusOK, c.Writer.Status())\n\t})\n}\n\nfunc TestDeletePipeline(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should delete pipeline\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineNumber\", mock.Anything, mock.Anything).Return(fakePipeline, nil)\n\t\tmockStore.On(\"DeletePipeline\", mock.Anything).Return(nil)\n\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"2\"}}\n\n\t\tDeletePipeline(c)\n\n\t\tmockStore.AssertCalled(t, \"GetPipelineNumber\", mock.Anything, mock.Anything)\n\t\tmockStore.AssertCalled(t, \"DeletePipeline\", mock.Anything)\n\t\tassert.Equal(t, http.StatusNoContent, c.Writer.Status())\n\t})\n\n\tt.Run(\"should not delete without pipeline number\", func(t *testing.T) {\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\n\t\tDeletePipeline(c)\n\n\t\tassert.Equal(t, http.StatusBadRequest, c.Writer.Status())\n\t})\n\n\tt.Run(\"should not delete pending\", func(t *testing.T) {\n\t\tfakePipeline := *fakePipeline\n\t\tfakePipeline.Status = model.StatusPending\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineNumber\", mock.Anything, mock.Anything).Return(&fakePipeline, nil)\n\n\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"2\"}}\n\n\t\tDeletePipeline(c)\n\n\t\tmockStore.AssertCalled(t, \"GetPipelineNumber\", mock.Anything, mock.Anything)\n\t\tmockStore.AssertNotCalled(t, \"DeletePipeline\", mock.Anything)\n\t\tassert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status())\n\t})\n}\n\nfunc TestGetPipelineMetadata(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tprevPipeline := &model.Pipeline{\n\t\tID:     1,\n\t\tNumber: 1,\n\t\tStatus: model.StatusFailure,\n\t}\n\n\tfakeRepo := &model.Repo{ID: 1}\n\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockForge.On(\"Name\").Return(\"mock\")\n\tmockForge.On(\"URL\").Return(\"https://codeberg.org\")\n\n\tmockManager := manager_mocks.NewMockManager(t)\n\tmockManager.On(\"ForgeFromRepo\", fakeRepo).Return(mockForge, nil)\n\tserver.Config.Services.Manager = mockManager\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tmockStore.On(\"GetPipelineNumber\", mock.Anything, int64(2)).Return(fakePipeline, nil)\n\tmockStore.On(\"GetPipelineLastBefore\", mock.Anything, mock.Anything, int64(2)).Return(prevPipeline, nil)\n\n\tt.Run(\"PipelineMetadata\", func(t *testing.T) {\n\t\tt.Run(\"should get pipeline metadata\", func(t *testing.T) {\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, _ := gin.CreateTestContext(w)\n\t\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"2\"}}\n\t\t\tc.Set(\"store\", mockStore)\n\t\t\tc.Set(\"forge\", mockForge)\n\t\t\tc.Set(\"repo\", fakeRepo)\n\n\t\t\tGetPipelineMetadata(c)\n\n\t\t\tassert.Equal(t, http.StatusOK, w.Code)\n\n\t\t\tvar response metadata.Metadata\n\t\t\terr := json.Unmarshal(w.Body.Bytes(), &response)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.Equal(t, int64(1), response.Repo.ID)\n\t\t\tassert.Equal(t, int64(2), response.Curr.Number)\n\t\t\tassert.Equal(t, int64(1), response.Prev.Number)\n\t\t})\n\n\t\tt.Run(\"should return bad request for invalid pipeline number\", func(t *testing.T) {\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, _ := gin.CreateTestContext(w)\n\t\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"invalid\"}}\n\n\t\t\tGetPipelineMetadata(c)\n\n\t\t\tassert.Equal(t, http.StatusBadRequest, w.Code)\n\t\t})\n\n\t\tt.Run(\"should return not found for non-existent pipeline\", func(t *testing.T) {\n\t\t\tmockStore := store_mocks.NewMockStore(t)\n\t\t\tmockStore.On(\"GetPipelineNumber\", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist)\n\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, _ := gin.CreateTestContext(w)\n\t\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"3\"}}\n\t\t\tc.Set(\"store\", mockStore)\n\t\t\tc.Set(\"repo\", fakeRepo)\n\n\t\t\tGetPipelineMetadata(c)\n\n\t\t\tassert.Equal(t, http.StatusNotFound, w.Code)\n\t\t})\n\t})\n}\n\nfunc TestCancelPipeline(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tt.Run(\"should cancel running pipeline\", func(t *testing.T) {\n\t\trunningPipeline := &model.Pipeline{\n\t\t\tID:     2,\n\t\t\tNumber: 2,\n\t\t\tStatus: model.StatusRunning,\n\t\t}\n\n\t\tfakeRepo := &model.Repo{ID: 1}\n\t\tfakeUser := &model.User{Login: \"testuser\"}\n\n\t\tmockForge := forge_mocks.NewMockForge(t)\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"GetPipelineNumber\", fakeRepo, int64(2)).Return(runningPipeline, nil)\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{}, nil)\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil)\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tmockManager.On(\"ForgeFromRepo\", fakeRepo).Return(mockForge, nil)\n\t\tserver.Config.Services.Manager = mockManager\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(nil, memory.New())\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Set(\"repo\", fakeRepo)\n\t\tc.Set(\"user\", fakeUser)\n\t\tc.Params = gin.Params{{Key: \"pipeline_number\", Value: \"2\"}}\n\n\t\tCancelPipeline(c)\n\n\t\tassert.Equal(t, http.StatusNoContent, c.Writer.Status())\n\t})\n}\n\nfunc TestCreatePipeline(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t// 1. normal: config fetch succeeds (no error, returns config) -> success\n\tt.Run(\"normal workflow - config can be read\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockConfigService := config_service_mocks.NewMockService(t)\n\t\tmockSecretService := secret_service_mocks.NewMockService(t)\n\t\tmockRegistryService := registry_service_mocks.NewMockService(t)\n\n\t\tfakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: \"test/repo\"}\n\t\tfakeUser := &model.User{ID: 1, Login: \"testuser\", Email: \"test@example.com\", Avatar: \"avatar.png\", Hash: \"hash123\"}\n\t\tfakeCommit := &model.Commit{SHA: \"abc123\", ForgeURL: \"https://example.com/commit/abc123\"}\n\n\t\tmockForge := forge_mocks.NewMockForge(t)\n\t\tmockForge.On(\"Name\").Return(\"mock\").Maybe()\n\t\tmockForge.On(\"URL\").Return(\"https://example.com\").Maybe()\n\t\tmockForge.On(\"BranchHead\", mock.Anything, fakeUser, fakeRepo, \"main\").Return(fakeCommit, nil)\n\t\tmockForge.On(\"Netrc\", fakeUser, fakeRepo).Return(&model.Netrc{\n\t\t\tMachine:  \"example.com\",\n\t\t\tLogin:    \"testuser\",\n\t\t\tPassword: \"testpass\",\n\t\t}, nil).Maybe()\n\t\tmockForge.On(\"Status\", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()\n\n\t\tmockSecretService.On(\"SecretListPipeline\", mock.Anything, fakeRepo, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe()\n\t\tmockRegistryService.On(\"RegistryListPipeline\", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Registry{}, nil).Maybe()\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tmockManager.On(\"ForgeFromRepo\", fakeRepo).Return(mockForge, nil)\n\t\tmockManager.On(\"ConfigServiceFromRepo\", fakeRepo).Return(mockConfigService)\n\t\tmockManager.On(\"SecretServiceFromRepo\", fakeRepo).Return(mockSecretService).Maybe()\n\t\tmockManager.On(\"RegistryServiceFromRepo\", fakeRepo).Return(mockRegistryService).Maybe()\n\t\tmockManager.On(\"EnvironmentService\").Return(nil).Maybe()\n\t\tserver.Config.Services.Manager = mockManager\n\n\t\tmockQueue := queue_mocks.NewMockQueue(t)\n\t\tmockQueue.On(\"Push\", mock.Anything, mock.Anything).Return(nil).Maybe()\n\t\tmockQueue.On(\"PushAtOnce\", mock.Anything, mock.Anything).Return(nil).Maybe()\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New())\n\n\t\t// mimic the valid config data\n\t\tconfigData := []*forge_types.FileMeta{\n\t\t\t{Name: \".woodpecker.yml\", Data: []byte(\"when:\\n  event: manual\\nsteps:\\n  test:\\n    image: alpine:latest\\n    commands:\\n      - echo test\")},\n\t\t}\n\t\tmockConfigService.On(\"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(configData, nil)\n\n\t\tmockStore.On(\"GetUser\", int64(1)).Return(fakeUser, nil)\n\t\tmockStore.On(\"CreatePipeline\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"GetPipelineLastBefore\", fakeRepo, \"main\", mock.Anything).Return(nil, nil).Maybe()\n\t\tmockStore.On(\"ConfigPersist\", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe()\n\t\tmockStore.On(\"ConfigFindIdentical\", mock.Anything, mock.Anything).Return(nil, nil).Maybe()\n\t\tmockStore.On(\"PipelineConfigCreate\", mock.Anything).Return(nil).Maybe()\n\t\tmockStore.On(\"WorkflowsCreate\", mock.Anything).Return(nil).Maybe()\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil).Maybe()\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Set(\"repo\", fakeRepo)\n\t\tc.Set(\"user\", fakeUser)\n\n\t\tc.Request, _ = http.NewRequest(http.MethodPost, \"\", io.NopCloser(bytes.NewBufferString(`{\"branch\": \"main\"}`)))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tCreatePipeline(c)\n\n\t\t// verify the config service was called successfully (no error, returns config)\n\t\tmockConfigService.AssertCalled(t, \"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)\n\t\tmockForge.AssertCalled(t, \"BranchHead\", mock.Anything, fakeUser, fakeRepo, \"main\")\n\t\tmockStore.AssertCalled(t, \"GetUser\", int64(1))\n\t\tmockStore.AssertCalled(t, \"CreatePipeline\", mock.Anything)\n\t})\n\n\t// 2. abnormal with oldconfig: config fetch fails but returns config data (error + non-nil config) -> continues with fallback\n\tt.Run(\"abnormal workflow - cannot read config but has oldconfig\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockConfigService := config_service_mocks.NewMockService(t)\n\t\tmockSecretService := secret_service_mocks.NewMockService(t)\n\t\tmockRegistryService := registry_service_mocks.NewMockService(t)\n\n\t\tfakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: \"test/repo\"}\n\t\tfakeUser := &model.User{ID: 1, Login: \"testuser\", Email: \"test@example.com\", Avatar: \"avatar.png\", Hash: \"hash123\"}\n\t\tfakeCommit := &model.Commit{SHA: \"abc123\", ForgeURL: \"https://example.com/commit/abc123\"}\n\n\t\tmockForge := forge_mocks.NewMockForge(t)\n\t\tmockForge.On(\"Name\").Return(\"mock\").Maybe()\n\t\tmockForge.On(\"URL\").Return(\"https://example.com\").Maybe()\n\t\tmockForge.On(\"BranchHead\", mock.Anything, fakeUser, fakeRepo, \"main\").Return(fakeCommit, nil)\n\t\t// mock the netrc for parse config\n\t\tmockForge.On(\"Netrc\", fakeUser, fakeRepo).Return(&model.Netrc{\n\t\t\tMachine:  \"example.com\",\n\t\t\tLogin:    \"testuser\",\n\t\t\tPassword: \"testpass\",\n\t\t}, nil).Maybe()\n\n\t\tmockForge.On(\"Status\", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()\n\t\tmockSecretService.On(\"SecretListPipeline\", mock.Anything, fakeRepo, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe()\n\t\tmockRegistryService.On(\"RegistryListPipeline\", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Registry{}, nil).Maybe()\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tmockManager.On(\"ForgeFromRepo\", fakeRepo).Return(mockForge, nil)\n\t\tmockManager.On(\"ConfigServiceFromRepo\", fakeRepo).Return(mockConfigService)\n\t\tmockManager.On(\"SecretServiceFromRepo\", fakeRepo).Return(mockSecretService).Maybe()\n\t\tmockManager.On(\"RegistryServiceFromRepo\", fakeRepo).Return(mockRegistryService).Maybe()\n\t\tmockManager.On(\"EnvironmentService\").Return(nil).Maybe()\n\t\tserver.Config.Services.Manager = mockManager\n\n\t\tmockQueue := queue_mocks.NewMockQueue(t)\n\t\tmockQueue.On(\"Push\", mock.Anything, mock.Anything).Return(nil).Maybe()\n\t\tmockQueue.On(\"PushAtOnce\", mock.Anything, mock.Anything).Return(nil).Maybe()\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New())\n\n\t\t// mimic the old config data\n\t\toldConfigData := []*forge_types.FileMeta{\n\t\t\t{Name: \".woodpecker.yml\", Data: []byte(\"when:\\n  event: manual\\nsteps:\\n  test:\\n    image: alpine:latest\\n    commands:\\n      - echo test\")},\n\t\t}\n\t\tmockConfigService.On(\"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(oldConfigData, http.ErrHandlerTimeout)\n\n\t\tmockStore.On(\"GetUser\", int64(1)).Return(fakeUser, nil)\n\t\tmockStore.On(\"CreatePipeline\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"GetPipelineLastBefore\", fakeRepo, \"main\", mock.Anything).Return(nil, nil).Maybe()\n\t\tmockStore.On(\"ConfigPersist\", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe()\n\t\tmockStore.On(\"ConfigFindIdentical\", mock.Anything, mock.Anything).Return(nil, nil).Maybe()\n\t\tmockStore.On(\"PipelineConfigCreate\", mock.Anything).Return(nil).Maybe()\n\t\tmockStore.On(\"WorkflowsCreate\", mock.Anything).Return(nil).Maybe()\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil).Maybe()\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Set(\"repo\", fakeRepo)\n\t\tc.Set(\"user\", fakeUser)\n\n\t\tc.Request, _ = http.NewRequest(http.MethodPost, \"\", io.NopCloser(bytes.NewBufferString(`{\"branch\": \"main\"}`)))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tCreatePipeline(c)\n\n\t\t// verify the config service returned error + old config (fallback scenario)\n\t\tmockConfigService.AssertCalled(t, \"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)\n\t\tmockStore.AssertCalled(t, \"GetUser\", int64(1))\n\t\tmockStore.AssertCalled(t, \"CreatePipeline\", mock.Anything)\n\t})\n\n\t// 3. abnormal without oldconfig: config fetch fails without config data (error + nil config) -> fails immediately\n\tt.Run(\"abnormal workflow - cannot read config and no oldconfig\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockConfigService := config_service_mocks.NewMockService(t)\n\n\t\tfakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: \"test/repo\"}\n\t\tfakeUser := &model.User{ID: 1, Login: \"testuser\", Email: \"test@example.com\", Avatar: \"avatar.png\", Hash: \"hash123\"}\n\t\tfakeCommit := &model.Commit{SHA: \"abc123\", ForgeURL: \"https://example.com/commit/abc123\"}\n\n\t\tmockForge := forge_mocks.NewMockForge(t)\n\t\tmockForge.On(\"BranchHead\", mock.Anything, fakeUser, fakeRepo, \"main\").Return(fakeCommit, nil)\n\t\tmockForge.On(\"Netrc\", fakeUser, fakeRepo).Return(nil, nil).Maybe()\n\t\tmockForge.On(\"Status\", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()\n\n\t\tmockManager := manager_mocks.NewMockManager(t)\n\t\tmockManager.On(\"ForgeFromRepo\", fakeRepo).Return(mockForge, nil)\n\t\tmockManager.On(\"ConfigServiceFromRepo\", fakeRepo).Return(mockConfigService)\n\t\tserver.Config.Services.Manager = mockManager\n\t\tserver.Config.Services.Scheduler = scheduler.NewScheduler(nil, memory.New())\n\n\t\t// return nil config with error\n\t\tmockConfigService.On(\"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(nil, http.ErrHandlerTimeout)\n\n\t\tmockStore.On(\"GetUser\", int64(1)).Return(fakeUser, nil)\n\t\tmockStore.On(\"CreatePipeline\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil)\n\n\t\tw := httptest.NewRecorder()\n\t\tc, _ := gin.CreateTestContext(w)\n\t\tc.Set(\"store\", mockStore)\n\t\tc.Set(\"repo\", fakeRepo)\n\t\tc.Set(\"user\", fakeUser)\n\n\t\tc.Request, _ = http.NewRequest(http.MethodPost, \"\", io.NopCloser(bytes.NewBufferString(`{\"branch\": \"main\"}`)))\n\t\tc.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tCreatePipeline(c)\n\n\t\t// verify the config service returned error without any config data\n\t\tmockConfigService.AssertCalled(t, \"Fetch\", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false)\n\t\tmockStore.AssertCalled(t, \"GetUser\", int64(1))\n\t\tmockStore.AssertCalled(t, \"CreatePipeline\", mock.Anything)\n\t\tmockStore.AssertCalled(t, \"UpdatePipeline\", mock.Anything)\n\t})\n}\n"
  },
  {
    "path": "server/api/queue.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// GetQueueInfo\n//\n//\t@Summary\t\tGet pipeline queue information\n//\t@Description\tReturns pipeline queue information with agent details\n//\t@Router\t\t\t/queue/info [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tQueueInfo\n//\t@Tags\t\t\tPipeline queues\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc GetQueueInfo(c *gin.Context) {\n\tinfo := server.Config.Services.Scheduler.Info(c)\n\t_store := store.FromContext(c)\n\n\t// Create a map to store agent names by ID\n\tagentNameMap := make(map[int64]string)\n\n\t// Process tasks and add agent names\n\tpendingWithAgents, err := processQueueTasks(_store, info.Pending, agentNameMap)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\twaitingWithAgents, err := processQueueTasks(_store, info.WaitingOnDeps, agentNameMap)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\trunningWithAgents, err := processQueueTasks(_store, info.Running, agentNameMap)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\t// Create response with agent-enhanced tasks\n\tresponse := model.QueueInfo{\n\t\tPending:       pendingWithAgents,\n\t\tWaitingOnDeps: waitingWithAgents,\n\t\tRunning:       runningWithAgents,\n\t\tStats: struct {\n\t\t\tWorkerCount        int `json:\"worker_count\"`\n\t\t\tPendingCount       int `json:\"pending_count\"`\n\t\t\tWaitingOnDepsCount int `json:\"waiting_on_deps_count\"`\n\t\t\tRunningCount       int `json:\"running_count\"`\n\t\t}{\n\t\t\tWorkerCount:        info.Stats.Workers,\n\t\t\tPendingCount:       info.Stats.Pending,\n\t\t\tWaitingOnDepsCount: info.Stats.WaitingOnDeps,\n\t\t\tRunningCount:       info.Stats.Running,\n\t\t},\n\t\tPaused: info.Paused,\n\t}\n\n\tc.IndentedJSON(http.StatusOK, response)\n}\n\n// PauseQueue\n//\n//\t@Summary\tPause the pipeline queue\n//\t@Router\t\t/queue/pause [post]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipeline queues\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc PauseQueue(c *gin.Context) {\n\tserver.Config.Services.Scheduler.Pause()\n\tc.Status(http.StatusNoContent)\n}\n\n// ResumeQueue\n//\n//\t@Summary\tResume the pipeline queue\n//\t@Router\t\t/queue/resume [post]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipeline queues\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc ResumeQueue(c *gin.Context) {\n\tserver.Config.Services.Scheduler.Resume()\n\tc.Status(http.StatusNoContent)\n}\n\n// BlockTilQueueHasRunningItem\n//\n//\t@Summary\tBlock til pipeline queue has a running item\n//\t@Router\t\t/queue/norunningpipelines [get]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tPipeline queues\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc BlockTilQueueHasRunningItem(c *gin.Context) {\n\tfor {\n\t\tinfo := server.Config.Services.Scheduler.Info(c)\n\t\tif info.Stats.Running == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\tc.Status(http.StatusNoContent)\n}\n\n// processQueueTasks converts tasks to QueueTask structs and adds agent names.\nfunc processQueueTasks(store store.Store, tasks []*model.Task, agentNameMap map[int64]string) ([]model.QueueTask, error) {\n\tresult := make([]model.QueueTask, 0, len(tasks))\n\n\tfor _, task := range tasks {\n\t\ttaskResponse := model.QueueTask{\n\t\t\tTask: *task,\n\t\t}\n\n\t\tif task.AgentID != 0 {\n\t\t\tname, ok := getAgentName(store, agentNameMap, task.AgentID)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"agent not found for task %s\", task.ID)\n\t\t\t}\n\n\t\t\ttaskResponse.AgentName = name\n\t\t}\n\n\t\tif task.PipelineID != 0 {\n\t\t\tp, err := store.GetPipeline(task.PipelineID)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"pipeline not found for task %s\", task.ID)\n\t\t\t}\n\n\t\t\ttaskResponse.PipelineNumber = p.Number\n\t\t}\n\n\t\tresult = append(result, taskResponse)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "server/api/registry.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetRegistry\n//\n//\t@Summary\tGet a registry by name\n//\t@Router\t\t/repos/{repo_id}/registries/{registry} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRepository registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry name\"\nfunc GetRegistry(c *gin.Context) {\n\trepo := session.Repo(c)\n\taddr := c.Param(\"registry\")\n\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\tregistry, err := registryService.RegistryFind(repo, addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, registry.Copy())\n}\n\n// PostRegistry\n//\n//\t@Summary\tCreate a registry\n//\t@Router\t\t/repos/{repo_id}/registries [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRepository registries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\tregistry\t\tbody\tRegistry\ttrue\t\"the new registry data\"\nfunc PostRegistry(c *gin.Context) {\n\trepo := session.Repo(c)\n\n\tin := new(model.Registry)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing request. %s\", err)\n\t\treturn\n\t}\n\tregistry := &model.Registry{\n\t\tRepoID:   repo.ID,\n\t\tAddress:  in.Address,\n\t\tUsername: in.Username,\n\t\tPassword: in.Password,\n\t}\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error inserting registry. %s\", err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\tif err := registryService.RegistryCreate(repo, registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting registry %q. %s\", in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, in.Copy())\n}\n\n// PatchRegistry\n//\n//\t@Summary\tUpdate a registry by name\n//\t@Router\t\t/repos/{repo_id}/registries/{registry} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRegistry\n//\t@Tags\t\tRepository registries\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\tregistry\t\tpath\tstring\t\ttrue\t\"the registry name\"\n//\t@Param\t\tregistryData\tbody\tRegistry\ttrue\t\"the attributes for the registry\"\nfunc PatchRegistry(c *gin.Context) {\n\trepo := session.Repo(c)\n\taddr := c.Param(\"registry\")\n\n\tin := new(model.Registry)\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing request. %s\", err)\n\t\treturn\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\tregistry, err := registryService.RegistryFind(repo, addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Username != \"\" {\n\t\tregistry.Username = in.Username\n\t}\n\tif in.Password != \"\" {\n\t\tregistry.Password = in.Password\n\t}\n\n\tif err := registry.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error updating registry. %s\", err)\n\t\treturn\n\t}\n\tif err := registryService.RegistryUpdate(repo, registry); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating registry %q. %s\", in.Address, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, in.Copy())\n}\n\n// GetRegistryList\n//\n//\t@Summary\tList registries\n//\t@Router\t\t/repos/{repo_id}/registries [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tRegistry\n//\t@Tags\t\tRepository registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetRegistryList(c *gin.Context) {\n\trepo := session.Repo(c)\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\tlist, err := registryService.RegistryList(repo, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting registry list. %s\", err)\n\t\treturn\n\t}\n\t// copy the registry detail to remove the sensitive\n\t// password and token fields.\n\tfor i, registry := range list {\n\t\tlist[i] = registry.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// DeleteRegistry\n//\n//\t@Summary\tDelete a registry by name\n//\t@Router\t\t/repos/{repo_id}/registries/{registry} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRepository registries\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tregistry\t\tpath\tstring\ttrue\t\"the registry name\"\nfunc DeleteRegistry(c *gin.Context) {\n\trepo := session.Repo(c)\n\taddr := c.Param(\"registry\")\n\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\terr := registryService.RegistryDelete(repo, addr)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/repo.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/base32\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\n// PostRepo\n//\n//\t@Summary\tActivate a repository\n//\t@Router\t\t/repos [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\tforge_remote_id\tquery\tstring\ttrue\t\"the id of a repository at the forge\"\nfunc PostRepo(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tforge.Refresh(c, _forge, _store, user)\n\n\tforgeRemoteID := model.ForgeRemoteID(c.Query(\"forge_remote_id\"))\n\tif !forgeRemoteID.IsValid() {\n\t\tc.String(http.StatusBadRequest, \"No forge_remote_id provided\")\n\t\treturn\n\t}\n\n\trepo, err := _store.GetRepoForgeID(user.ForgeID, forgeRemoteID)\n\tenabledOnce := err == nil // if there's no error, the repo was found and enabled once already\n\tif enabledOnce && repo.IsActive {\n\t\tc.String(http.StatusConflict, \"Repository is already active.\")\n\t\treturn\n\t} else if err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\tmsg := \"could not get repo by remote id from store.\"\n\t\tlog.Error().Err(err).Msg(msg)\n\t\tc.String(http.StatusInternalServerError, msg)\n\t\treturn\n\t}\n\n\tfrom, err := _forge.Repo(c, user, forgeRemoteID, \"\", \"\")\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Could not fetch repository from forge.\")\n\t\treturn\n\t}\n\tif !from.Perm.Admin {\n\t\tc.String(http.StatusForbidden, \"User has to be a admin of this repository\")\n\t\treturn\n\t}\n\tif !server.Config.Permissions.OwnersAllowlist.IsAllowed(from) {\n\t\tc.String(http.StatusForbidden, \"Repo owner is not allowed\")\n\t\treturn\n\t}\n\n\tfrom.ForgeID = user.ForgeID\n\tif enabledOnce {\n\t\trepo.Update(from)\n\t} else {\n\t\trepo = from\n\t\trepo.RequireApproval = server.Config.Pipeline.DefaultApprovalMode\n\t\trepo.AllowPull = server.Config.Pipeline.DefaultAllowPullRequests\n\t\trepo.AllowDeploy = false\n\t\trepo.CancelPreviousPipelineEvents = server.Config.Pipeline.DefaultCancelPreviousPipelineEvents\n\t}\n\trepo.IsActive = true\n\trepo.UserID = user.ID\n\n\tif repo.Visibility == \"\" {\n\t\trepo.Visibility = model.VisibilityPublic\n\t\tif repo.IsSCMPrivate {\n\t\t\trepo.Visibility = model.VisibilityPrivate\n\t\t}\n\t}\n\n\tif repo.Timeout == 0 {\n\t\trepo.Timeout = server.Config.Pipeline.DefaultTimeout\n\t} else if repo.Timeout > server.Config.Pipeline.MaxTimeout {\n\t\trepo.Timeout = server.Config.Pipeline.MaxTimeout\n\t}\n\n\tif repo.Hash == \"\" {\n\t\trepo.Hash = base32.StdEncoding.EncodeToString(\n\t\t\trandom.GetRandomBytes(32),\n\t\t)\n\t}\n\n\t// find org of repo\n\tvar org *model.Org\n\torg, err = _store.OrgFindByName(repo.Owner, user.ForgeID)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\t// create an org if it doesn't exist yet\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\torg, err = _forge.Org(c, user, repo.Owner)\n\t\tif err != nil {\n\t\t\tmsg := fmt.Sprintf(\"Organization %s not found in DB. Attempting to create new one.\", repo.Owner)\n\t\t\tlog.Error().Err(err).Msg(msg)\n\t\t\tc.String(http.StatusInternalServerError, msg)\n\t\t\treturn\n\t\t}\n\n\t\torg.ForgeID = user.ForgeID\n\t\terr = _store.OrgCreate(org)\n\t\tif err != nil {\n\t\t\tmsg := fmt.Sprintf(\"Failed to create organization %s.\", repo.Owner)\n\t\t\tlog.Error().Err(err).Msg(msg)\n\t\t\tc.String(http.StatusInternalServerError, msg)\n\t\t\treturn\n\t\t}\n\t}\n\n\trepo.OrgID = org.ID\n\n\t// creates the jwt token used to verify the repository\n\tt := token.New(token.HookToken)\n\tt.Set(\"repo-forge-remote-id\", string(forgeRemoteID))\n\tt.Set(\"forge-id\", strconv.FormatInt(repo.ForgeID, 10))\n\tsig, err := t.Sign(repo.Hash)\n\tif err != nil {\n\t\tmsg := \"could not generate new jwt token.\"\n\t\tlog.Error().Err(err).Msg(msg)\n\t\tc.String(http.StatusInternalServerError, msg)\n\t\treturn\n\t}\n\n\thookURL := fmt.Sprintf(\n\t\t\"%s/api/hook?access_token=%s\",\n\t\tserver.Config.Server.WebhookHost,\n\t\tsig,\n\t)\n\n\terr = _forge.Activate(c, user, repo, hookURL)\n\tif err != nil {\n\t\tmsg := \"could not create webhook in forge.\"\n\t\tlog.Error().Err(err).Msg(msg)\n\t\tc.String(http.StatusInternalServerError, msg)\n\t\treturn\n\t}\n\n\tif enabledOnce {\n\t\terr = _store.UpdateRepo(repo)\n\t} else {\n\t\terr = _store.CreateRepo(repo)\n\t}\n\tif err != nil {\n\t\tif errors.Is(err, types.ErrInsertDuplicateDetected) {\n\t\t\tc.String(http.StatusConflict, \"Repository already exists in Woodpecker. Remove the stale repository entry and try again.\")\n\t\t\treturn\n\t\t}\n\t\tmsg := \"could not create/update repo in store.\"\n\t\tlog.Error().Err(err).Msg(msg)\n\t\tc.String(http.StatusInternalServerError, msg)\n\t\treturn\n\t}\n\n\trepo.Perm = from.Perm\n\trepo.Perm.Synced = time.Now().Unix()\n\trepo.Perm.UserID = user.ID\n\trepo.Perm.RepoID = repo.ID\n\terr = _store.PermUpsert(repo.Perm)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, repo)\n}\n\n// PatchRepo\n//\n//\t@Summary\tUpdate a repository\n//\t@Router\t\t/repos/{repo_id} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\trepo\t\t\tbody\tRepoPatch\ttrue\t\"the repository's information\"\nfunc PatchRepo(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\n\tin := new(model.RepoPatch)\n\tif err := c.Bind(in); err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tif in.Timeout != nil && *in.Timeout > server.Config.Pipeline.MaxTimeout && !user.Admin {\n\t\tc.String(http.StatusForbidden, fmt.Sprintf(\"Timeout is not allowed to be higher than max timeout (%d min)\", server.Config.Pipeline.MaxTimeout))\n\t\treturn\n\t}\n\n\tif in.Trusted != nil {\n\t\t// if user is not admin\n\t\tif !user.Admin &&\n\t\t\t// and some trusted settings got changed\n\t\t\t((in.Trusted.Network != nil && *in.Trusted.Network != repo.Trusted.Network) ||\n\t\t\t\t(in.Trusted.Volumes != nil && *in.Trusted.Volumes != repo.Trusted.Volumes) ||\n\t\t\t\t(in.Trusted.Security != nil && *in.Trusted.Security != repo.Trusted.Security)) {\n\t\t\tlog.Trace().Msgf(\"user '%s' wants to change trusted without being an instance admin\", user.Login)\n\t\t\t// return error\n\t\t\tc.String(http.StatusForbidden, \"Insufficient privileges\")\n\t\t\treturn\n\t\t}\n\n\t\tif in.Trusted.Network != nil {\n\t\t\trepo.Trusted.Network = *in.Trusted.Network\n\t\t}\n\t\tif in.Trusted.Security != nil {\n\t\t\trepo.Trusted.Security = *in.Trusted.Security\n\t\t}\n\t\tif in.Trusted.Volumes != nil {\n\t\t\trepo.Trusted.Volumes = *in.Trusted.Volumes\n\t\t}\n\t}\n\n\tif in.AllowPull != nil {\n\t\trepo.AllowPull = *in.AllowPull\n\t}\n\tif in.AllowDeploy != nil {\n\t\trepo.AllowDeploy = *in.AllowDeploy\n\t}\n\n\tif in.RequireApproval != nil {\n\t\tif mode := model.ApprovalMode(*in.RequireApproval); mode.Valid() {\n\t\t\trepo.RequireApproval = mode\n\t\t} else {\n\t\t\tc.String(http.StatusBadRequest, \"Invalid require-approval setting\")\n\t\t\treturn\n\t\t}\n\t}\n\tif in.ApprovalAllowedUsers != nil {\n\t\trepo.ApprovalAllowedUsers = *in.ApprovalAllowedUsers\n\t}\n\tif in.Timeout != nil {\n\t\trepo.Timeout = *in.Timeout\n\t}\n\tif in.Config != nil {\n\t\trepo.Config = *in.Config\n\t}\n\tif in.CancelPreviousPipelineEvents != nil {\n\t\trepo.CancelPreviousPipelineEvents = *in.CancelPreviousPipelineEvents\n\t}\n\tif in.NetrcTrusted != nil {\n\t\trepo.NetrcTrustedPlugins = *in.NetrcTrusted\n\t}\n\tif in.Visibility != nil {\n\t\tswitch *in.Visibility {\n\t\tcase string(model.VisibilityInternal), string(model.VisibilityPrivate), string(model.VisibilityPublic):\n\t\t\trepo.Visibility = model.RepoVisibility(*in.Visibility)\n\t\tdefault:\n\t\t\tc.String(http.StatusBadRequest, \"Invalid visibility type\")\n\t\t\treturn\n\t\t}\n\t}\n\tif in.ConfigExtensionEndpoint != nil {\n\t\trepo.ConfigExtensionEndpoint = *in.ConfigExtensionEndpoint\n\t}\n\tif in.ConfigExtensionExclusive != nil {\n\t\trepo.ConfigExtensionExclusive = *in.ConfigExtensionExclusive\n\t}\n\tif in.ConfigExtensionNetrc != nil {\n\t\trepo.ConfigExtensionNetrc = *in.ConfigExtensionNetrc\n\t}\n\tif in.RegistryExtensionEndpoint != nil {\n\t\trepo.RegistryExtensionEndpoint = *in.RegistryExtensionEndpoint\n\t}\n\tif in.RegistryExtensionNetrc != nil {\n\t\trepo.RegistryExtensionNetrc = *in.RegistryExtensionNetrc\n\t}\n\tif in.SecretExtensionEndpoint != nil {\n\t\trepo.SecretExtensionEndpoint = *in.SecretExtensionEndpoint\n\t}\n\tif in.SecretExtensionNetrc != nil {\n\t\trepo.SecretExtensionNetrc = *in.SecretExtensionNetrc\n\t}\n\n\terr := _store.UpdateRepo(repo)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, repo)\n}\n\n// ChownRepo\n//\n//\t@Summary\tChange a repository's owner to the currently authenticated user\n//\t@Router\t\t/repos/{repo_id}/chown [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\nfunc ChownRepo(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\trepo.UserID = user.ID\n\n\terr := _store.UpdateRepo(repo)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, repo)\n}\n\n// LookupRepo\n//\n//\t@Summary\tLookup a repository by full name\n//\t@Router\t\t/repos/lookup/{repo_full_name} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_full_name\tpath\tstring\ttrue\t\"the repository full name / slug\"\nfunc LookupRepo(c *gin.Context) {\n\tc.JSON(http.StatusOK, session.Repo(c))\n}\n\n// GetRepo\n//\n//\t@Summary\tGet a repository\n//\t@Router\t\t/repos/{repo_id} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\nfunc GetRepo(c *gin.Context) {\n\tc.JSON(http.StatusOK, session.Repo(c))\n}\n\n// GetRepoPermissions\n//\n//\t@Summary\t\tCheck current authenticated users access to the repository\n//\t@Description\tThe repository permission, according to the used access token.\n//\t@Router\t\t\t/repos/{repo_id}/permissions [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tPerm\n//\t@Tags\t\t\tRepositories\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\nfunc GetRepoPermissions(c *gin.Context) {\n\tperm := session.Perm(c)\n\tc.JSON(http.StatusOK, perm)\n}\n\n// GetRepoBranches\n//\n//\t@Summary\tGet branches of a repository\n//\t@Router\t\t/repos/{repo_id}/branches [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tstring\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetRepoBranches(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\trepoUser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tforge.Refresh(c, _forge, _store, repoUser)\n\n\tbranches, err := _forge.Branches(c, repoUser, repo, session.Pagination(c))\n\tif errors.Is(err, forge_types.ErrNotImplemented) {\n\t\tlog.Debug().Msg(\"Could not fetch repo branch list as forge adapter did not implement it\")\n\t} else if err != nil {\n\t\tlog.Error().Err(err).Msg(\"failed to load branches\")\n\t\tc.String(http.StatusInternalServerError, \"failed to load branches: %s\", err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, branches)\n}\n\n// GetRepoPullRequests\n//\n//\t@Summary\tList active pull requests of a repository\n//\t@Router\t\t/repos/{repo_id}/pull_requests [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tPullRequest\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetRepoPullRequests(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\trepoUser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tforge.Refresh(c, _forge, _store, repoUser)\n\n\tprs, err := _forge.PullRequests(c, repoUser, repo, session.Pagination(c))\n\tif errors.Is(err, forge_types.ErrNotImplemented) {\n\t\tlog.Debug().Msg(\"Could not fetch repo pull-request list as forge adapter did not implement it\")\n\t} else if err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, prs)\n}\n\n// DeleteRepo\n//\n//\t@Summary\tDelete a repository\n//\t@Router\t\t/repos/{repo_id} [delete]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tRepo\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\nfunc DeleteRepo(c *gin.Context) {\n\tremove, _ := strconv.ParseBool(c.Query(\"remove\"))\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tforge.Refresh(c, _forge, _store, user)\n\n\tif err := _forge.Deactivate(c, user, repo, server.Config.Server.WebhookHost); err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not deactivate repo [%d] on forge\", repo.ID)\n\n\t\t// in case we want to delete the repo on our side we should not worry to much on the forge side\n\t\t// also if we get signalized that the repo on the forge is gone we can just ignore that\n\t\tif errors.Is(err, forge_types.ErrRepoNotFound) || remove {\n\t\t\tlog.Debug().Msg(\"ignore deactivating repo on forge\")\n\t\t} else {\n\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tif remove {\n\t\tif err := _store.DeleteRepo(repo); err != nil {\n\t\t\thandleDBError(c, err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\trepo.IsActive = false\n\t\trepo.UserID = 0\n\n\t\tif err := _store.UpdateRepo(repo); err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, repo)\n}\n\n// RepairRepo\n//\n//\t@Summary\tRepair a repository\n//\t@Router\t\t/repos/{repo_id}/repair [post]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\nfunc RepairRepo(c *gin.Context) {\n\trepo := session.Repo(c)\n\terr := repairRepo(c, repo, true)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"repair repo '%s' failed\", repo.FullName)\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusNoContent)\n}\n\n// MoveRepo\n//\n//\t@Summary\tMove a repository to a new owner\n//\t@Router\t\t/repos/{repo_id}/move [post]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRepositories\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tto\t\t\t\tquery\tstring\ttrue\t\"the username to move the repository to\"\nfunc MoveRepo(c *gin.Context) {\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\tforge.Refresh(c, _forge, _store, user)\n\n\tto, exists := c.GetQuery(\"to\")\n\tif !exists {\n\t\terr := fmt.Errorf(\"missing required to query value\")\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\towner, name, errParse := model.ParseRepo(to)\n\tif errParse != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, errParse)\n\t\treturn\n\t}\n\n\tfrom, err := _forge.Repo(c, user, \"\", owner, name)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tfrom.ForgeID = repo.ForgeID\n\tif !from.Perm.Admin {\n\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\treturn\n\t}\n\n\terr = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName})\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\n\trepo.Update(from)\n\terrStore := _store.UpdateRepo(repo)\n\tif errStore != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, errStore)\n\t\treturn\n\t}\n\trepo.Perm = from.Perm\n\trepo.Perm.Synced = time.Now().Unix()\n\trepo.Perm.UserID = user.ID\n\trepo.Perm.RepoID = repo.ID\n\terrStore = _store.PermUpsert(repo.Perm)\n\tif errStore != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, errStore)\n\t\treturn\n\t}\n\n\t// creates the jwt token used to verify the repository\n\tt := token.New(token.HookToken)\n\tt.Set(\"repo-forge-remote-id\", string(repo.ForgeRemoteID))\n\tt.Set(\"forge-id\", strconv.FormatInt(repo.ForgeID, 10))\n\tsig, err := t.Sign(repo.Hash)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\n\t// reconstruct the hook url\n\thost := server.Config.Server.WebhookHost\n\thookURL := fmt.Sprintf(\n\t\t\"%s/api/hook?access_token=%s\",\n\t\thost,\n\t\tsig,\n\t)\n\n\tif err := _forge.Deactivate(c, user, repo, host); err != nil {\n\t\tlog.Trace().Err(err).Msgf(\"deactivate repo '%s' for move to activate later, got an error\", strconv.FormatInt(repo.ID, 10))\n\t}\n\tif err := _forge.Activate(c, user, repo, hookURL); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n\n// GetAllRepos\n//\n//\t@Summary\t\tList all repositories on the server\n//\t@Description\tReturns a list of all repositories. Requires admin rights.\n//\t@Router\t\t\t/repos [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tRepo\n//\t@Tags\t\t\tRepositories\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tactive\t\t\tquery\tbool\tfalse\t\"only list active repos\"\n//\t@Param\t\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetAllRepos(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tactive, _ := strconv.ParseBool(c.Query(\"active\"))\n\n\trepos, err := _store.RepoListAll(active, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, repos)\n}\n\n// RepairAllRepos\n//\n//\t@Summary\t\tRepair all repositories on the server\n//\t@Description\tExecutes a repair process on all repositories. Requires admin rights.\n//\t@Router\t\t\t/repos/repair [post]\n//\t@Produce\t\tplain\n//\t@Success\t\t204\n//\t@Tags\t\t\tRepositories\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc RepairAllRepos(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\trepos, err := _store.RepoListAll(true, &model.ListOptions{All: true})\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\treturn\n\t}\n\n\tfailedRepos := make([]int64, 0)\n\tfor _, r := range repos {\n\t\t// updatePermissions is false as RepoListAll does not load permissions\n\t\tupdatePermissions := false\n\t\terr := repairRepo(c, r, updatePermissions)\n\t\tif err != nil {\n\t\t\tfailedRepos = append(failedRepos, r.ID)\n\t\t\t_ = c.Error(err)\n\t\t\tlog.Error().Err(err).Msgf(\"failed to repair repo '%s'\", r.FullName)\n\t\t}\n\t}\n\n\tif len(failedRepos) > 0 {\n\t\tc.JSON(http.StatusInternalServerError, map[string]any{\n\t\t\t\"error\":        \"failed to repair some repos\",\n\t\t\t\"failed_repos\": failedRepos,\n\t\t})\n\t} else {\n\t\tc.Status(http.StatusNoContent)\n\t}\n}\n\nfunc repairRepo(c *gin.Context, repo *model.Repo, updatePermissions bool) error {\n\t_store := store.FromContext(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\treturn err\n\t}\n\n\trepoUser, err := repairRepoUser(c, repo, _store)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot assign user to repo '%s'\", repo.FullName)\n\t\treturn err\n\t}\n\n\t// refresh user token if necessary\n\tforge.Refresh(c, _forge, _store, repoUser)\n\n\t// creates a new jwt token used to verify webhook calls\n\tt := token.New(token.HookToken)\n\tt.Set(\"repo-forge-remote-id\", string(repo.ForgeRemoteID))\n\tt.Set(\"forge-id\", strconv.FormatInt(repo.ForgeID, 10))\n\tsig, err := t.Sign(repo.Hash)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// reconstruct the webhook url\n\thost := server.Config.Server.WebhookHost\n\thookURL := fmt.Sprintf(\n\t\t\"%s/api/hook?access_token=%s\",\n\t\thost,\n\t\tsig,\n\t)\n\n\tfrom, err := _forge.Repo(c, repoUser, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\t// If we have valid ForgeRemoteID and can not find the repo,\n\t\t// we assume the repo was deleted and try to get a new one if it was re-created.\n\t\tif errors.Is(err, forge_types.ErrRepoNotFound) && repo.ForgeRemoteID.IsValid() {\n\t\t\tfrom, err = _forge.Repo(c, repoUser, \"\", repo.Owner, repo.Name)\n\t\t\tif err == nil {\n\t\t\t\tlog.Debug().Str(\"repoFullName\", repo.FullName).\n\t\t\t\t\tStr(\"old ForgeRemoteID\", string(repo.ForgeRemoteID)).Str(\"new ForgeRemoteID\", string(from.ForgeRemoteID)).\n\t\t\t\t\tMsgf(\"RepoRepair detected remote repo ID change and updated it\")\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"get repo '%s/%s' from forge\", repo.Owner, repo.Name)\n\t\treturn fmt.Errorf(\"fetching repo from forge: %w\", err)\n\t}\n\n\tfrom.ForgeID = repo.ForgeID\n\n\tif repo.FullName != from.FullName {\n\t\t// create a redirection\n\t\terr = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\trepo.Update(from)\n\tif err := _store.UpdateRepo(repo); err != nil {\n\t\treturn err\n\t}\n\n\tif updatePermissions {\n\t\trepo.Perm = from.Perm\n\t\trepo.Perm.Synced = time.Now().Unix()\n\t\trepo.Perm.UserID = repoUser.ID\n\t\trepo.Perm.RepoID = repo.ID\n\t\tif err := _store.PermUpsert(repo.Perm); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// remove webhook (deactivate) and recreate it (activate)\n\tif err := _forge.Deactivate(c, repoUser, repo, host); err != nil {\n\t\tlog.Debug().Err(err).Msgf(\"deactivate repo '%s' to repair failed\", repo.FullName)\n\t}\n\n\treturn _forge.Activate(c, repoUser, repo, hookURL)\n}\n\nfunc repairRepoUser(c *gin.Context, repo *model.Repo, _store store.Store) (*model.User, error) {\n\trepoUser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\t\toldUserID := repo.UserID\n\t\t\tsessionUser := session.User(c)\n\t\t\trepo.UserID = sessionUser.ID\n\t\t\terr = _store.UpdateRepo(repo)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tlog.Debug().Msgf(\"Could not find repo user with ID %d during repo repair, set to repair request user with ID %d\", oldUserID, sessionUser.ID)\n\t\t\treturn sessionUser, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn repoUser, nil\n}\n"
  },
  {
    "path": "server/api/repo_secret.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\n// GetSecret\n//\n//\t@Summary\tGet a repository secret by name\n//\t@Router\t\t/repos/{repo_id}/secrets/{secretName} [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tRepository secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tsecretName\t\tpath\tstring\ttrue\t\"the secret name\"\nfunc GetSecret(c *gin.Context) {\n\trepo := session.Repo(c)\n\tname := c.Param(\"secret\")\n\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tsecret, err := secretService.SecretFind(repo, name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// PostSecret\n//\n//\t@Summary\tCreate a repository secret\n//\t@Router\t\t/repos/{repo_id}/secrets [post]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tRepository secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tsecret\t\t\tbody\tSecret\ttrue\t\"the new secret\"\nfunc PostSecret(c *gin.Context) {\n\trepo := session.Repo(c)\n\n\tin := new(model.Secret)\n\tif err := c.Bind(in); err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing secret. %s\", err)\n\t\treturn\n\t}\n\tsecret := &model.Secret{\n\t\tRepoID: repo.ID,\n\t\tName:   in.Name,\n\t\tValue:  in.Value,\n\t\tEvents: in.Events,\n\t\tImages: in.Images,\n\t\tNote:   in.Note,\n\t}\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error inserting secret. %s\", err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tif err := secretService.SecretCreate(repo, secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error inserting secret %q. %s\", in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// PatchSecret\n//\n//\t@Summary\tUpdate a repository secret by name\n//\t@Router\t\t/repos/{repo_id}/secrets/{secretName} [patch]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tSecret\n//\t@Tags\t\tRepository secrets\n//\t@Param\t\tAuthorization\theader\tstring\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\t\ttrue\t\"the repository id\"\n//\t@Param\t\tsecretName\t\tpath\tstring\t\ttrue\t\"the secret name\"\n//\t@Param\t\tsecret\t\t\tbody\tSecretPatch\ttrue\t\"the secret itself\"\nfunc PatchSecret(c *gin.Context) {\n\tvar (\n\t\trepo = session.Repo(c)\n\t\tname = c.Param(\"secret\")\n\t)\n\n\tin := new(model.SecretPatch)\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, \"Error parsing secret. %s\", err)\n\t\treturn\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tsecret, err := secretService.SecretFind(repo, name)\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif in.Value != nil && *in.Value != \"\" {\n\t\tsecret.Value = *in.Value\n\t}\n\tif in.Events != nil {\n\t\tsecret.Events = in.Events\n\t}\n\tif in.Images != nil {\n\t\tsecret.Images = in.Images\n\t}\n\tif in.Note != nil {\n\t\tsecret.Note = *in.Note\n\t}\n\n\tif err := secret.Validate(); err != nil {\n\t\tc.String(http.StatusUnprocessableEntity, \"Error updating secret. %s\", err)\n\t\treturn\n\t}\n\tif err := secretService.SecretUpdate(repo, secret); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error updating secret %q. %s\", in.Name, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, secret.Copy())\n}\n\n// GetSecretList\n//\n//\t@Summary\tList repository secrets\n//\t@Router\t\t/repos/{repo_id}/secrets [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{array}\tSecret\n//\t@Tags\t\tRepository secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetSecretList(c *gin.Context) {\n\trepo := session.Repo(c)\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tlist, err := secretService.SecretList(repo, session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting secret list. %s\", err)\n\t\treturn\n\t}\n\t// copy the secret detail to remove the sensitive\n\t// password and token fields.\n\tfor i, secret := range list {\n\t\tlist[i] = secret.Copy()\n\t}\n\tc.JSON(http.StatusOK, list)\n}\n\n// DeleteSecret\n//\n//\t@Summary\tDelete a repository secret by name\n//\t@Router\t\t/repos/{repo_id}/secrets/{secretName} [delete]\n//\t@Produce\tplain\n//\t@Success\t204\n//\t@Tags\t\tRepository secrets\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\trepo_id\t\t\tpath\tint\t\ttrue\t\"the repository id\"\n//\t@Param\t\tsecretName\t\tpath\tstring\ttrue\t\"the secret name\"\nfunc DeleteSecret(c *gin.Context) {\n\trepo := session.Repo(c)\n\tname := c.Param(\"secret\")\n\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tif err := secretService.SecretDelete(repo, name); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/repo_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestPostRepoReturnsConflictOnDuplicateRepository(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tmockManager := manager_mocks.NewMockManager(t)\n\tmockForge := forge_mocks.NewMockForge(t)\n\n\tserver.Config.Services.Manager = mockManager\n\tserver.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist(nil)\n\tserver.Config.Server.WebhookHost = \"https://woodpecker.example\"\n\tserver.Config.Pipeline.DefaultApprovalMode = model.RequireApprovalForks\n\tserver.Config.Pipeline.DefaultAllowPullRequests = true\n\tserver.Config.Pipeline.DefaultCancelPreviousPipelineEvents = nil\n\tserver.Config.Pipeline.DefaultTimeout = 60\n\tserver.Config.Pipeline.MaxTimeout = 120\n\n\tuser := &model.User{ID: 10, ForgeID: 7, Login: \"alice\"}\n\tforgeRemoteID := model.ForgeRemoteID(\"42\")\n\n\tforgeRepo := &model.Repo{\n\t\tForgeRemoteID: forgeRemoteID,\n\t\tOwner:         \"acme\",\n\t\tName:          \"rocket\",\n\t\tFullName:      \"acme/rocket\",\n\t\tPerm:          &model.Perm{Admin: true},\n\t}\n\n\torg := &model.Org{ID: 3, Name: \"acme\", ForgeID: user.ForgeID}\n\n\tmockManager.On(\"ForgeFromUser\", user).Return(mockForge, nil)\n\tmockStore.On(\"GetRepoForgeID\", user.ForgeID, forgeRemoteID).Return(nil, types.ErrRecordNotExist)\n\tmockForge.On(\"Repo\", mock.Anything, user, forgeRemoteID, \"\", \"\").Return(forgeRepo, nil)\n\tmockStore.On(\"OrgFindByName\", forgeRepo.Owner, user.ForgeID).Return(org, nil)\n\tmockForge.On(\"Activate\", mock.Anything, user, mock.AnythingOfType(\"*model.Repo\"), mock.AnythingOfType(\"string\")).Return(nil)\n\tmockStore.On(\"CreateRepo\", mock.AnythingOfType(\"*model.Repo\")).Return(types.ErrInsertDuplicateDetected)\n\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\tc.Set(\"store\", mockStore)\n\tc.Set(\"user\", user)\n\tc.Request = httptest.NewRequest(http.MethodPost, \"/repos?forge_remote_id=42\", nil)\n\n\tPostRepo(c)\n\n\tassert.Equal(t, http.StatusConflict, w.Code)\n\tassert.Contains(t, w.Body.String(), \"Remove the stale repository entry\")\n\tmockStore.AssertNotCalled(t, \"PermUpsert\", mock.Anything)\n}\n"
  },
  {
    "path": "server/api/signature_public_key.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"crypto/x509\"\n\t\"encoding/pem\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n)\n\n// GetSignaturePublicKey\n//\n//\t@Summary\tGet server's signature public key\n//\t@Router\t\t/signature/public-key [get]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tSystem\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc GetSignaturePublicKey(c *gin.Context) {\n\tb, err := x509.MarshalPKIXPublicKey(server.Config.Services.Manager.SignaturePublicKey())\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"can't marshal public key\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tblock := &pem.Block{\n\t\tType:  \"PUBLIC KEY\",\n\t\tBytes: b,\n\t}\n\n\tc.String(http.StatusOK, \"%s\", pem.EncodeToMemory(block))\n}\n"
  },
  {
    "path": "server/api/stream.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nconst (\n\t// How many batches of logs to keep for each client before starting to\n\t// drop them if the client is not consuming them faster than they arrive.\n\tmaxQueuedBatchesPerClient int = 30\n\n\t// Is the time till we send a ping to keep the connection alive.\n\tidlePingTime = time.Second * 30\n)\n\n// EventStreamSSE\n//\n//\t@Summary\t\tStream events like pipeline updates\n//\t@Description\tWith quic and http2 support\n//\t@Router\t\t\t/stream/events [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tEvents\nfunc EventStreamSSE(c *gin.Context) {\n\tc.Header(\"Content-Type\", \"text/event-stream\")\n\tc.Header(\"Cache-Control\", \"no-store\")\n\tc.Header(\"Connection\", \"keep-alive\")\n\tc.Header(\"X-Accel-Buffering\", \"no\")\n\n\trw := c.Writer\n\n\tflusher, ok := rw.(http.Flusher)\n\tif !ok {\n\t\tc.String(http.StatusInternalServerError, \"Streaming not supported\")\n\t\treturn\n\t}\n\n\t// ping the client\n\tlogWriteStringErr(io.WriteString(rw, \": ping\\n\\n\"))\n\tflusher.Flush()\n\n\tlog.Debug().Msg(\"user feed: connection opened\")\n\n\tuser := session.User(c)\n\tsubTopics := make(map[string]struct{})\n\t// subscribe to all public state changes\n\tsubTopics[pubsub.PublicTopic] = struct{}{}\n\t// subscribe to all private state changes or repos the user owns\n\tif user != nil {\n\t\trepos, _ := store.FromContext(c).RepoList(user, false, true, nil)\n\t\tfor _, r := range repos {\n\t\t\tsubTopics[pubsub.GetRepoTopic(r)] = struct{}{}\n\t\t}\n\t}\n\n\teventChan := make(chan []byte, 10)\n\tctx, cancel := context.WithCancelCause(\n\t\tcontext.Background(),\n\t)\n\trequestCtx := c.Request.Context()\n\n\tdefer func() {\n\t\tcancel(nil)\n\t\tlog.Debug().Msg(\"user feed: connection closed\")\n\t}()\n\n\tgo func() {\n\t\terr := server.Config.Services.Scheduler.Subscribe(ctx, subTopics,\n\t\t\tfunc(m pubsub.Message) {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\tcase eventChan <- m.Data:\n\t\t\t\t}\n\t\t\t})\n\t\tcancel(err)\n\t}()\n\n\tfor {\n\t\tselect {\n\t\tcase <-requestCtx.Done():\n\t\t\treturn\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-time.After(idlePingTime):\n\t\t\tlogWriteStringErr(io.WriteString(rw, \": ping\\n\\n\"))\n\t\t\tflusher.Flush()\n\t\tcase buf, ok := <-eventChan:\n\t\t\tif ok {\n\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"data: \"))\n\t\t\t\tlogWriteStringErr(rw.Write(buf))\n\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"\\n\\n\"))\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\t}\n}\n\n// LogStreamSSE\n//\n//\t@Summary\tStream logs of a pipeline step\n//\t@Router\t\t/stream/logs/{repo_id}/{pipeline}/{step_id} [get]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tPipeline logs\n//\t@Param\t\trepo_id\t\tpath\tint\ttrue\t\"the repository id\"\n//\t@Param\t\tpipeline\tpath\tint\ttrue\t\"the number of the pipeline\"\n//\t@Param\t\tstep_id\t\tpath\tint\ttrue\t\"the step id\"\nfunc LogStreamSSE(c *gin.Context) {\n\tc.Header(\"Content-Type\", \"text/event-stream\")\n\tc.Header(\"Cache-Control\", \"no-cache\")\n\tc.Header(\"Connection\", \"keep-alive\")\n\tc.Header(\"X-Accel-Buffering\", \"no\")\n\n\trw := c.Writer\n\n\tflusher, ok := rw.(http.Flusher)\n\tif !ok {\n\t\tc.String(http.StatusInternalServerError, \"Streaming not supported\")\n\t\treturn\n\t}\n\n\tlogWriteStringErr(io.WriteString(rw, \": ping\\n\\n\"))\n\tflusher.Flush()\n\n\t_store := store.FromContext(c)\n\trepo := session.Repo(c)\n\n\tpipeline, err := strconv.ParseInt(c.Param(\"pipeline\"), 10, 64)\n\tif err != nil {\n\t\tlog.Debug().Err(err).Msg(\"pipeline number invalid\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: pipeline number invalid\\n\\n\"))\n\t\treturn\n\t}\n\tpl, err := _store.GetPipelineNumber(repo, pipeline)\n\tif err != nil {\n\t\tlog.Debug().Err(err).Msg(\"stream cannot get pipeline number\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: pipeline not found\\n\\n\"))\n\t\treturn\n\t}\n\n\tstepID, err := strconv.ParseInt(c.Param(\"step_id\"), 10, 64)\n\tif err != nil {\n\t\tlog.Debug().Err(err).Msg(\"step id invalid\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: step id invalid\\n\\n\"))\n\t\treturn\n\t}\n\tstep, err := _store.StepLoad(pl.ID, stepID)\n\tif err != nil {\n\t\tlog.Debug().Err(err).Msg(\"stream cannot get step number\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: process not found\\n\\n\"))\n\t\treturn\n\t}\n\n\tif step.State != model.StatusPending && step.State != model.StatusRunning {\n\t\tlog.Debug().Msg(\"step not running (anymore).\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: step not running (anymore)\\n\\n\"))\n\t\treturn\n\t}\n\n\tlogChan := make(chan []byte, 10)\n\tctx, cancel := context.WithCancelCause(\n\t\tcontext.Background(),\n\t)\n\trequestCtx := c.Request.Context()\n\n\tlog.Debug().Msg(\"log stream: connection opened\")\n\n\tdefer func() {\n\t\tcancel(nil)\n\t\tlog.Debug().Msg(\"log stream: connection closed\")\n\t}()\n\n\terr = server.Config.Services.Logs.Open(ctx, step.ID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"log stream: open failed\")\n\t\tlogWriteStringErr(io.WriteString(rw, \"event: error\\ndata: can't open stream\\n\\n\"))\n\t\treturn\n\t}\n\n\tgo func() {\n\t\tbatches := make(logging.LogChan, maxQueuedBatchesPerClient)\n\n\t\tvar innerDone sync.WaitGroup\n\t\tinnerDone.Add(1)\n\t\tgo func() {\n\t\t\tdefer innerDone.Done()\n\t\t\tfor entries := range batches {\n\t\t\t\tfor _, entry := range entries {\n\t\t\t\t\tif ee, err := json.Marshal(entry); err == nil {\n\t\t\t\t\t\tselect {\n\t\t\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\tcase logChan <- ee:\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Error().Err(err).Msg(\"unable to serialize log entry\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\terr := server.Config.Services.Logs.Tail(ctx, step.ID, batches)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"tail of logs failed\")\n\t\t}\n\n\t\tclose(batches)\n\t\tinnerDone.Wait()\n\t\tcancel(err)\n\t}()\n\n\tid := 1\n\tlast, _ := strconv.Atoi(\n\t\tc.Request.Header.Get(\"Last-Event-ID\"),\n\t)\n\tif last != 0 {\n\t\tlog.Debug().Msgf(\"log stream: reconnect: last-event-id: %d\", last)\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done(): // Monitor if the \"tail\" context is canceled.\n\t\t\tif err := context.Cause(ctx); errors.Is(err, context.Canceled) {\n\t\t\t\tlog.Debug().Msg(\"log stream: eof\")\n\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"event: eof\\ndata: eof\\n\\n\"))\n\t\t\t\tflusher.Flush()\n\t\t\t\treturn\n\t\t\t}\n\t\tcase <-requestCtx.Done(): // Monitor the request context for cancellation when the client has gone away.\n\t\t\tlog.Debug().Msg(\"log stream: closed, client has gone away\")\n\t\t\treturn\n\t\tcase <-time.After(idlePingTime):\n\t\t\tlogWriteStringErr(io.WriteString(rw, \": ping\\n\\n\"))\n\t\t\tflusher.Flush()\n\t\tcase buf, ok := <-logChan:\n\t\t\tif ok {\n\t\t\t\tif id > last {\n\t\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"id: \"+strconv.Itoa(id)))\n\t\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"\\n\"))\n\t\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"data: \"))\n\t\t\t\t\tlogWriteStringErr(rw.Write(buf))\n\t\t\t\t\tlogWriteStringErr(io.WriteString(rw, \"\\n\\n\"))\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t\tid++\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc logWriteStringErr(_ int, err error) {\n\tif err != nil {\n\t\tlog.Error().Err(err).Caller(1).Msg(\"fail to write string\")\n\t}\n}\n"
  },
  {
    "path": "server/api/stream_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestEventStreamSSEConcurrentDisconnect(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tbroker := memory.New()\n\tserver.Config.Services.Scheduler = scheduler.NewScheduler(nil, broker)\n\tt.Cleanup(func() { server.Config.Services.Scheduler = nil })\n\n\tfor i := range 50 {\n\t\tt.Run(fmt.Sprint(i), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, _ := gin.CreateTestContext(w)\n\n\t\t\tctx, cancel := context.WithCancelCause(t.Context())\n\t\t\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"/stream/events\", nil)\n\t\t\tc.Request = req\n\n\t\t\ttopic := map[string]struct{}{pubsub.PublicTopic: {}}\n\n\t\t\tdone := make(chan struct{})\n\t\t\tgo func() {\n\t\t\t\tdefer close(done)\n\t\t\t\tEventStreamSSE(c)\n\t\t\t}()\n\n\t\t\t// Let the event handler subscribe\n\t\t\ttime.Sleep(20 * time.Millisecond)\n\n\t\t\t// Fire concurrent publishes while canceling the request.\n\t\t\tvar wg sync.WaitGroup\n\t\t\tfor range 20 {\n\t\t\t\twg.Add(1)\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer wg.Done()\n\t\t\t\t\t_ = broker.Publish(ctx, topic, pubsub.Message{\n\t\t\t\t\t\tData: []byte(`{\"pipeline\":1}`),\n\t\t\t\t\t})\n\t\t\t\t}()\n\t\t\t}\n\n\t\t\t// Simulate client disconnect mid-publish.\n\t\t\tcancel(nil)\n\t\t\twg.Wait()\n\t\t\t<-done\n\t\t})\n\t}\n}\n\nfunc setupLogStreamContext(t *testing.T) (*httptest.ResponseRecorder, *gin.Context, context.CancelCauseFunc) {\n\tt.Helper()\n\n\tconst stepID int64 = 42\n\tconst pipelineID int64 = 10\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tmockStore.On(\"GetPipelineNumber\", mock.Anything, mock.Anything).\n\t\tReturn(&model.Pipeline{ID: pipelineID}, nil)\n\tmockStore.On(\"StepLoad\", mock.Anything, mock.Anything).\n\t\tReturn(&model.Step{\n\t\t\tID:         stepID,\n\t\t\tPipelineID: pipelineID,\n\t\t\tState:      model.StatusRunning,\n\t\t}, nil)\n\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\n\tctx, cancel := context.WithCancelCause(t.Context())\n\treq, _ := http.NewRequestWithContext(ctx, http.MethodGet, \"/stream/logs/1/1/42\", nil)\n\tc.Request = req\n\tc.Params = gin.Params{\n\t\t{Key: \"repo_id\", Value: \"1\"},\n\t\t{Key: \"pipeline\", Value: \"1\"},\n\t\t{Key: \"step_id\", Value: \"42\"},\n\t}\n\tc.Set(\"repo\", &model.Repo{ID: 1, FullName: \"owner/repo\"})\n\tc.Set(\"store\", mockStore)\n\n\treturn w, c, cancel\n}\n\nfunc TestLogStreamSSEConcurrentDisconnect(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tlogService := logging.New()\n\tserver.Config.Services.Logs = logService\n\tt.Cleanup(func() { server.Config.Services.Logs = nil })\n\n\tconst stepID int64 = 42\n\n\tfor i := range 50 {\n\t\tt.Run(fmt.Sprint(i), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tdone := make(chan struct{})\n\n\t\t\t_, c, cancel := setupLogStreamContext(t)\n\n\t\t\tgo func() {\n\t\t\t\tdefer close(done)\n\t\t\t\tLogStreamSSE(c)\n\t\t\t}()\n\n\t\t\t// Let LogStreamSSE open the stream and start tailing.\n\t\t\ttime.Sleep(20 * time.Millisecond)\n\n\t\t\t// Fire concurrent log writes while canceling the request.\n\t\t\tvar wg sync.WaitGroup\n\t\t\tfor i := range 20 {\n\t\t\t\twg.Add(1)\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer wg.Done()\n\t\t\t\t\t_ = logService.Write(t.Context(), stepID, []*model.LogEntry{\n\t\t\t\t\t\t{Line: i, Data: []byte(\"log line\")},\n\t\t\t\t\t})\n\t\t\t\t}()\n\t\t\t}\n\n\t\t\t// Simulate client disconnect mid-write.\n\t\t\tcancel(nil)\n\t\t\twg.Wait()\n\t\t\t<-done\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/api/user.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/base32\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// GetSelf\n//\n//\t@Summary\tGet the currently authenticated user\n//\t@Router\t\t/user [get]\n//\t@Produce\tjson\n//\t@Success\t200\t{object}\tUser\n//\t@Tags\t\tUser\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc GetSelf(c *gin.Context) {\n\tc.JSON(http.StatusOK, session.User(c))\n}\n\n// GetFeed\n//\n//\t@Summary\t\tGet the currently authenticated users pipeline feed\n//\t@Description\tThe feed lists the most recent pipeline for the currently authenticated user.\n//\t@Router\t\t\t/user/feed [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tFeed\n//\t@Tags\t\t\tUser\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc GetFeed(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tuser := session.User(c)\n\tlatest, _ := strconv.ParseBool(c.Query(\"latest\"))\n\n\tif latest {\n\t\tfeed, err := _store.RepoListLatest(user)\n\t\tif err != nil {\n\t\t\tc.String(http.StatusInternalServerError, \"Error fetching feed. %s\", err)\n\t\t} else {\n\t\t\tc.JSON(http.StatusOK, feed)\n\t\t}\n\t\treturn\n\t}\n\n\tfeed, err := _store.UserFeed(user)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error fetching user feed. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, feed)\n}\n\n// GetRepos\n//\n//\t@Summary\t\tGet user's repositories\n//\t@Description\tRetrieve the currently authenticated User's Repository list\n//\t@Router\t\t\t/user/repos [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tRepoLastPipeline\n//\t@Tags\t\t\tUser\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tall\t\t\t\tquery\tbool\tfalse\t\"query all repos, including inactive ones\"\n//\t@Param\t\t\tname\t\t\tquery\tstring\tfalse\t\"filter repos by name\"\nfunc GetRepos(c *gin.Context) {\n\t_store := store.FromContext(c)\n\tuser := session.User(c)\n\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tall, _ := strconv.ParseBool(c.Query(\"all\"))\n\tfilter := &model.RepoFilter{\n\t\tName: c.Query(\"name\"),\n\t}\n\n\tif all {\n\t\tdbRepos, err := _store.RepoList(user, true, false, filter)\n\t\tif err != nil {\n\t\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\t\treturn\n\t\t}\n\n\t\tdbReposMap := map[model.ForgeRemoteID]*model.Repo{}\n\t\tdbStaleReposMap := map[int64]*model.Repo{}\n\t\tdbReposFullNameMap := map[string]*model.Repo{}\n\t\tfor _, r := range dbRepos {\n\t\t\tdbReposMap[r.ForgeRemoteID] = r\n\t\t\tdbReposFullNameMap[strings.ToLower(r.FullName)] = r\n\t\t\tdbStaleReposMap[r.ID] = r\n\t\t}\n\n\t\t_repos, err := utils.Paginate(func(page int) ([]*model.Repo, error) {\n\t\t\treturn _forge.Repos(c, user, &model.ListOptions{\n\t\t\t\tPage:    page,\n\t\t\t\tPerPage: perPage,\n\t\t\t})\n\t\t}, maxPage)\n\t\tif err != nil {\n\t\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\t\treturn\n\t\t}\n\n\t\tvar repos []*model.Repo\n\t\tfor _, r := range _repos {\n\t\t\t// make sure forgeID is set\n\t\t\tr.ForgeID = user.ForgeID\n\n\t\t\tif r.Perm.Push && server.Config.Permissions.OwnersAllowlist.IsAllowed(r) {\n\t\t\t\tif existingRepo := dbReposMap[r.ForgeRemoteID]; existingRepo != nil {\n\t\t\t\t\t// update repo with forge response\n\t\t\t\t\texistingRepo.Update(r)\n\t\t\t\t\t// re-apply active info\n\t\t\t\t\texistingRepo.IsActive = dbReposMap[r.ForgeRemoteID].IsActive\n\t\t\t\t\t// add to final return list\n\t\t\t\t\trepos = append(repos, existingRepo)\n\t\t\t\t\t// not stale, so remove it\n\t\t\t\t\tdelete(dbStaleReposMap, existingRepo.ID)\n\t\t\t\t} else if r.Perm.Admin {\n\t\t\t\t\t// you must be admin of the remote repo to enable the repo\n\t\t\t\t\trepos = append(repos, r)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// detect conflicts\n\t\tfor _, r := range repos {\n\t\t\t// calc if we have a remote repo with different remote id but same name as a stored one\n\t\t\tif existingRepo := dbReposFullNameMap[strings.ToLower(r.FullName)]; existingRepo != nil && existingRepo.ForgeRemoteID != r.ForgeRemoteID {\n\t\t\t\tr.ID = existingRepo.ID\n\t\t\t\tr.HasForgeNameConflict = true\n\n\t\t\t\t// not stale, so remove it\n\t\t\t\tdelete(dbStaleReposMap, existingRepo.ID)\n\t\t\t}\n\t\t}\n\n\t\t// return stale repos\n\t\tfor _, staleRepo := range dbStaleReposMap {\n\t\t\tstaleRepo.HasNoForgeRepo = true\n\t\t\trepos = append(repos, staleRepo)\n\t\t}\n\n\t\tc.JSON(http.StatusOK, repos)\n\t\treturn\n\t}\n\n\tactiveRepos, err := _store.RepoList(user, true, true, filter)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\treturn\n\t}\n\n\trepoIDs := make([]int64, len(activeRepos))\n\tfor i, repo := range activeRepos {\n\t\trepoIDs[i] = repo.ID\n\t}\n\n\tpipelines, err := _store.GetRepoLatestPipelines(repoIDs)\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error fetching repository list. %s\", err)\n\t\treturn\n\t}\n\n\tlatestPipelines := make(map[int64]*model.Pipeline, len(activeRepos))\n\tfor _, pipeline := range pipelines {\n\t\tlatestPipelines[pipeline.RepoID] = pipeline\n\t}\n\n\trepos := make([]*model.RepoLastPipeline, len(activeRepos))\n\tfor i, repo := range activeRepos {\n\t\tvar lastAPIPipeline *model.APIPipeline\n\t\tlastPipeline, ok := latestPipelines[repo.ID]\n\t\tif ok {\n\t\t\tlastAPIPipeline = lastPipeline.ToAPIModel()\n\t\t}\n\n\t\trepos[i] = &model.RepoLastPipeline{\n\t\t\tRepo:         repo,\n\t\t\tLastPipeline: lastAPIPipeline,\n\t\t}\n\t}\n\n\tc.JSON(http.StatusOK, repos)\n}\n\n// PostToken\n//\n//\t@Summary\tReturn the token of the current user as string\n//\t@Router\t\t/user/token [post]\n//\t@Produce\tplain\n//\t@Success\t200\n//\t@Tags\t\tUser\n//\t@Param\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc PostToken(c *gin.Context) {\n\tuser := session.User(c)\n\tt := token.New(token.UserToken)\n\tt.Set(\"user-id\", strconv.FormatInt(user.ID, 10))\n\ttokenString, err := t.Sign(user.Hash)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tc.String(http.StatusOK, tokenString)\n}\n\n// DeleteToken\n//\n//\t@Summary\t\tReset a token\n//\t@Description\tReset's the current personal access token of the user and returns a new one.\n//\t@Router\t\t\t/user/token [delete]\n//\t@Produce\t\tplain\n//\t@Success\t\t200\n//\t@Tags\t\t\tUser\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\nfunc DeleteToken(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tuser := session.User(c)\n\tuser.Hash = base32.StdEncoding.EncodeToString(\n\t\trandom.GetRandomBytes(32),\n\t)\n\tif err := _store.UpdateUser(user); err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error revoking tokens. %s\", err)\n\t\treturn\n\t}\n\n\tt := token.New(token.UserToken)\n\tt.Set(\"user-id\", strconv.FormatInt(user.ID, 10))\n\ttokenString, err := t.Sign(user.Hash)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\treturn\n\t}\n\tc.String(http.StatusOK, tokenString)\n}\n"
  },
  {
    "path": "server/api/users.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"encoding/base32\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nconst defaultForgeID = 1\n\n// GetUsers\n//\n//\t@Summary\t\tList users\n//\t@Description\tReturns all registered, active users in the system. Requires admin rights.\n//\t@Router\t\t\t/users [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{array}\tUser\n//\t@Tags\t\t\tUsers\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\t\t\t\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tpage\t\t\tquery\tint\t\tfalse\t\"for response pagination, page offset number\"\tdefault(1)\n//\t@Param\t\t\tperPage\t\t\tquery\tint\t\tfalse\t\"for response pagination, max items per page\"\tdefault(50)\nfunc GetUsers(c *gin.Context) {\n\tusers, err := store.FromContext(c).GetUserList(session.Pagination(c))\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"Error getting user list. %s\", err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, users)\n}\n\n// GetUser\n//\n//\t@Summary\t\tGet a user\n//\t@Description\tReturns a user with the specified login name. Requires admin rights.\n//\t@Router\t\t\t/users/{login} [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tUser\n//\t@Tags\t\t\tUsers\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tlogin\t\t\tpath\tstring\ttrue\t\"the user's login name\"\n//\t@Param\t\t\tforge_id\t\tquery\tstring\ttrue\t\"specify forge (else default will be used)\"\n//\t@Param\t\t\tforge_remote_id\tquery\tstring\tfalse\t\"specify user id at forge (else fallback to login)\"\nfunc GetUser(c *gin.Context) {\n\tforgeID, err := strconv.ParseInt(c.DefaultQuery(\"forge_id\", fmt.Sprint(defaultForgeID)), 10, 64)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\treturn\n\t}\n\tforgeRemoteID := model.ForgeRemoteID(c.Query(\"forge_remote_id\"))\n\n\tvar user *model.User\n\n\tif forgeRemoteID.IsValid() {\n\t\tuser, err = store.FromContext(c).GetUserByRemoteID(forgeID, forgeRemoteID)\n\t} else {\n\t\tuser, err = store.FromContext(c).GetUserByLogin(forgeID, c.Param(\"login\"))\n\t}\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, user)\n}\n\n// PatchUser\n//\n//\t@Summary\t\tUpdate a user\n//\t@Description\tChanges the data of an existing user. Requires admin rights.\n//\t@Router\t\t\t/users/{login} [patch]\n//\t@Produce\t\tjson\n//\t@Accept\t\t\tjson\n//\t@Success\t\t200\t{object}\tUser\n//\t@Tags\t\t\tUsers\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tlogin\t\t\tpath\tstring\ttrue\t\"the user's login name\"\n//\t@Param\t\t\tuser\t\t\tbody\tUser\ttrue\t\"the user's data\"\nfunc PatchUser(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tin := &model.User{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif in.ForgeID < defaultForgeID {\n\t\tin.ForgeID = defaultForgeID\n\t}\n\n\tuser, err := store.FromContext(c).GetUserByRemoteID(in.ForgeID, in.ForgeRemoteID)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\n\tif user == nil {\n\t\tuser, err = _store.GetUserByLogin(in.ForgeID, c.Param(\"login\"))\n\t\tif err != nil {\n\t\t\thandleDBError(c, err)\n\t\t\treturn\n\t\t}\n\t}\n\n\t// TODO: disallow to change login, email, avatar if the user is using oauth\n\tuser.Login = in.Login\n\tuser.Email = in.Email\n\tuser.Avatar = in.Avatar\n\tuser.Admin = in.Admin\n\n\terr = _store.UpdateUser(user)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusConflict)\n\t\treturn\n\t}\n\n\tc.JSON(http.StatusOK, user)\n}\n\n// PostUser\n//\n//\t@Summary\t\tCreate a user\n//\t@Description\tCreates a new user account with the specified external login. Requires admin rights.\n//\t@Router\t\t\t/users [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tUser\n//\t@Tags\t\t\tUsers\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tuser\t\t\tbody\tUser\ttrue\t\"the user's data\"\nfunc PostUser(c *gin.Context) {\n\tin := &model.User{}\n\terr := c.Bind(in)\n\tif err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\tuser := &model.User{\n\t\tLogin:  in.Login,\n\t\tEmail:  in.Email,\n\t\tAvatar: in.Avatar,\n\t\tHash: base32.StdEncoding.EncodeToString(\n\t\t\trandom.GetRandomBytes(32),\n\t\t),\n\t\tForgeID:       in.ForgeID,\n\t\tForgeRemoteID: model.ForgeRemoteID(\"0\"), // TODO: search for the user in the forge and get the remote id\n\t}\n\tif err = user.Validate(); err != nil {\n\t\tc.String(http.StatusBadRequest, err.Error())\n\t\treturn\n\t}\n\tif err = store.FromContext(c).CreateUser(user); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\tc.JSON(http.StatusOK, user)\n}\n\n// DeleteUser\n//\n//\t@Summary\t\tDelete a user\n//\t@Description\tDeletes the given user. Requires admin rights.\n//\t@Router\t\t\t/users/{login} [delete]\n//\t@Produce\t\tplain\n//\t@Success\t\t204\n//\t@Tags\t\t\tUsers\n//\t@Param\t\t\tAuthorization\theader\tstring\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tlogin\t\t\tpath\tstring\ttrue\t\"the user's login name\"\n//\t@Param\t\t\tforge_id\t\tquery\tstring\ttrue\t\"specify forge (else default will be used)\"\n//\t@Param\t\t\tforge_remote_id\tquery\tstring\tfalse\t\"specify user id at forge (else fallback to login)\"\nfunc DeleteUser(c *gin.Context) {\n\t_store := store.FromContext(c)\n\n\tforgeID, err := strconv.ParseInt(c.DefaultQuery(\"forge_id\", fmt.Sprint(defaultForgeID)), 10, 64)\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\treturn\n\t}\n\tforgeRemoteID := model.ForgeRemoteID(c.Query(\"forge_remote_id\"))\n\n\tvar user *model.User\n\n\tif forgeRemoteID.IsValid() {\n\t\tuser, err = store.FromContext(c).GetUserByRemoteID(forgeID, forgeRemoteID)\n\t} else {\n\t\tuser, err = store.FromContext(c).GetUserByLogin(forgeID, c.Param(\"login\"))\n\t}\n\tif err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tif err = _store.DeleteUser(user); err != nil {\n\t\thandleDBError(c, err)\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n"
  },
  {
    "path": "server/api/z.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// Health\n//\n//\t@Summary\t\tHealth information\n//\t@Description\tIf everything is fine, just a 204 will be returned, a 500 signals server state is unhealthy.\n//\t@Router\t\t\t/healthz [get]\n//\t@Produce\t\tplain\n//\t@Success\t\t204\n//\t@Failure\t\t500\n//\t@Tags\t\t\tSystem\nfunc Health(c *gin.Context) {\n\tif err := store.FromContext(c).Ping(); err != nil {\n\t\tc.String(http.StatusInternalServerError, err.Error())\n\t\treturn\n\t}\n\tc.Status(http.StatusNoContent)\n}\n\n// Version\n//\n//\t@Summary\t\tGet version\n//\t@Description\tEndpoint returns the server version and build information.\n//\t@Router\t\t\t/version [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tobject{source=string,version=string}\n//\t@Tags\t\t\tSystem\nfunc Version(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"source\":  \"https://github.com/woodpecker-ci/woodpecker\",\n\t\t\"version\": version.String(),\n\t})\n}\n\n// LogLevel\n//\n//\t@Summary\t\tCurrent log level\n//\t@Description\tEndpoint returns the current logging level. Requires admin rights.\n//\t@Router\t\t\t/log-level [get]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tobject{log-level=string}\n//\t@Tags\t\t\tSystem\nfunc LogLevel(c *gin.Context) {\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"log-level\": zerolog.GlobalLevel().String(),\n\t})\n}\n\n// SetLogLevel\n//\n//\t@Summary\t\tSet log level\n//\t@Description\tEndpoint sets the current logging level. Requires admin rights.\n//\t@Router\t\t\t/log-level [post]\n//\t@Produce\t\tjson\n//\t@Success\t\t200\t{object}\tobject{log-level=string}\n//\t@Tags\t\t\tSystem\n//\t@Param\t\t\tAuthorization\theader\tstring\t\t\t\t\t\ttrue\t\"Insert your personal access token\"\tdefault(Bearer <personal access token>)\n//\t@Param\t\t\tlog-level\t\tbody\tobject{log-level=string}\ttrue\t\"the new log level, one of <debug,trace,info,warn,error,fatal,panic,disabled>\"\nfunc SetLogLevel(c *gin.Context) {\n\tlogLevel := struct {\n\t\tLogLevel string `json:\"log-level\"`\n\t}{}\n\tif err := c.Bind(&logLevel); err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tlvl, err := zerolog.ParseLevel(logLevel.LogLevel)\n\tif err != nil {\n\t\t_ = c.AbortWithError(http.StatusBadRequest, err)\n\t\treturn\n\t}\n\n\tlog.Log().Msgf(\"log level set to %s\", lvl.String())\n\tzerolog.SetGlobalLevel(lvl)\n\tc.JSON(http.StatusOK, logLevel)\n}\n"
  },
  {
    "path": "server/badges/badges.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage badges\n\nimport (\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar (\n\t// Status labels.\n\tbadgeStatusSuccess = \"success\"\n\tbadgeStatusFailure = \"failure\"\n\tbadgeStatusStarted = \"started\"\n\tbadgeStatusError   = \"error\"\n\tbadgeStatusNone    = \"none\"\n)\n\nfunc getBadgeStatusLabelAndColor(status *model.StatusValue) (string, Color) {\n\tif status == nil {\n\t\treturn badgeStatusNone, ColorGray\n\t}\n\n\tswitch *status {\n\tcase model.StatusSuccess:\n\t\treturn badgeStatusSuccess, ColorGreen\n\tcase model.StatusFailure:\n\t\treturn badgeStatusFailure, ColorRed\n\tcase model.StatusPending, model.StatusRunning:\n\t\treturn badgeStatusStarted, ColorYellow\n\tcase model.StatusError, model.StatusKilled:\n\t\treturn badgeStatusError, ColorGray\n\tdefault:\n\t\treturn badgeStatusNone, ColorGray\n\t}\n}\n\n// Generate an SVG badge based on a pipeline.\nfunc Generate(name string, status *model.StatusValue) (string, error) {\n\tlabel, color := getBadgeStatusLabelAndColor(status)\n\tbytes, err := RenderBytes(name, label, color)\n\tif err != nil {\n\t\tlog.Warn().Err(err).Msg(\"could not render badge\")\n\t\treturn \"\", err\n\t}\n\treturn string(bytes), nil\n}\n"
  },
  {
    "path": "server/badges/badges_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage badges\n\nimport (\n\t\"bytes\"\n\t\"html/template\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar (\n\tbadgeNone    = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"82\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"82\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"49\" height=\"20\" fill=\"#555\"/><rect x=\"49\" width=\"33\" height=\"20\" fill=\"#9f9f9f\"/><rect width=\"82\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"25.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">pipeline</text><text x=\"25.5\" y=\"14\">pipeline</text><text x=\"64.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">none</text><text x=\"64.5\" y=\"14\">none</text></g></svg>`\n\tbadgeSuccess = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"98\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"98\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"49\" height=\"20\" fill=\"#555\"/><rect x=\"49\" width=\"49\" height=\"20\" fill=\"#44cc11\"/><rect width=\"98\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"25.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">pipeline</text><text x=\"25.5\" y=\"14\">pipeline</text><text x=\"72.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">success</text><text x=\"72.5\" y=\"14\">success</text></g></svg>`\n\tbadgeFailure = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"89\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"89\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"49\" height=\"20\" fill=\"#555\"/><rect x=\"49\" width=\"40\" height=\"20\" fill=\"#e05d44\"/><rect width=\"89\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"25.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">pipeline</text><text x=\"25.5\" y=\"14\">pipeline</text><text x=\"68\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">failure</text><text x=\"68\" y=\"14\">failure</text></g></svg>`\n\tbadgeError   = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"81\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"81\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"49\" height=\"20\" fill=\"#555\"/><rect x=\"49\" width=\"32\" height=\"20\" fill=\"#9f9f9f\"/><rect width=\"81\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"25.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">pipeline</text><text x=\"25.5\" y=\"14\">pipeline</text><text x=\"64\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">error</text><text x=\"64\" y=\"14\">error</text></g></svg>`\n\tbadgeStarted = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"94\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"94\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"49\" height=\"20\" fill=\"#555\"/><rect x=\"49\" width=\"45\" height=\"20\" fill=\"#dfb317\"/><rect width=\"94\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"25.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">pipeline</text><text x=\"25.5\" y=\"14\">pipeline</text><text x=\"70.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">started</text><text x=\"70.5\" y=\"14\">started</text></g></svg>`\n)\n\n// Generate an SVG badge based on a pipeline.\nfunc TestGenerate(t *testing.T) {\n\tstatus := model.StatusDeclined\n\tbadge, err := Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeNone, badge)\n\tstatus = model.StatusSuccess\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeSuccess, badge)\n\tstatus = model.StatusFailure\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeFailure, badge)\n\tstatus = model.StatusError\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeError, badge)\n\tstatus = model.StatusKilled\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeError, badge)\n\tstatus = model.StatusPending\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeStarted, badge)\n\tstatus = model.StatusRunning\n\tbadge, err = Generate(\"pipeline\", &status)\n\tassert.NoError(t, err)\n\tassert.Equal(t, badgeStarted, badge)\n}\n\nfunc TestBadgeDrawerRender(t *testing.T) {\n\tmockTemplate := strings.TrimSpace(`\n\t{{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}}\n\t`)\n\tmockFontSize := 11.0\n\tmockDPI := 72.0\n\n\tfd, err := mustNewFontDrawer(mockFontSize, mockDPI)\n\tassert.NoError(t, err)\n\n\td := &badgeDrawer{\n\t\tfd:    fd,\n\t\ttmpl:  template.Must(template.New(\"mock-template\").Parse(mockTemplate)),\n\t\tmutex: &sync.Mutex{},\n\t}\n\n\toutput := \"XXX,YYY,#c0c0c0,15.5,29,41,26,55\"\n\n\tvar buf bytes.Buffer\n\tassert.NoError(t, d.Render(\"XXX\", \"YYY\", \"#c0c0c0\", &buf))\n\tassert.Equal(t, output, buf.String())\n}\n\nfunc TestBadgeDrawerRenderBytes(t *testing.T) {\n\tmockTemplate := strings.TrimSpace(`\n\t{{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}}\n\t`)\n\tmockFontSize := 11.0\n\tmockDPI := 72.0\n\n\tfd, err := mustNewFontDrawer(mockFontSize, mockDPI)\n\tassert.NoError(t, err)\n\n\td := &badgeDrawer{\n\t\tfd:    fd,\n\t\ttmpl:  template.Must(template.New(\"mock-template\").Parse(mockTemplate)),\n\t\tmutex: &sync.Mutex{},\n\t}\n\n\toutput := \"XXX,YYY,#c0c0c0,15.5,29,41,26,55\"\n\n\tbytes, err := d.RenderBytes(\"XXX\", \"YYY\", \"#c0c0c0\")\n\n\tassert.NoError(t, err)\n\tassert.Equal(t, output, string(bytes))\n}\n"
  },
  {
    "path": "server/badges/color.go",
    "content": "// Copyright 2023 The narqo/go-badge Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\npackage badges\n\n// Color represents color of the badge.\ntype Color string\n\n// Standard colors.\nconst (\n\tColorGreen  = \"#44cc11\"\n\tColorYellow = \"#dfb317\"\n\tColorRed    = \"#e05d44\"\n\tColorGray   = \"#9f9f9f\"\n)\n"
  },
  {
    "path": "server/badges/drawer.go",
    "content": "// Copyright 2023 The narqo/go-badge Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\npackage badges\n\n// cspell:words Verdana\n\nimport (\n\t\"bytes\"\n\t\"html/template\"\n\t\"io\"\n\t\"sync\"\n\n\t\"golang.org/x/image/font\"\n\t\"golang.org/x/image/font/opentype\"\n\t\"golang.org/x/image/font/sfnt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/badges/fonts\"\n)\n\ntype badge struct {\n\tSubject string\n\tStatus  string\n\tColor   Color\n\tBounds  bounds\n}\n\ntype bounds struct {\n\t// SubjectDx is the width of subject string of the badge.\n\tSubjectDx float64\n\tSubjectX  float64\n\t// StatusDx is the width of status string of the badge.\n\tStatusDx float64\n\tStatusX  float64\n}\n\nfunc (b bounds) Dx() float64 {\n\treturn b.SubjectDx + b.StatusDx\n}\n\ntype badgeDrawer struct {\n\tfd    *font.Drawer\n\ttmpl  *template.Template\n\tmutex *sync.Mutex\n}\n\nfunc (d *badgeDrawer) Render(subject, status string, color Color, w io.Writer) error {\n\td.mutex.Lock()\n\tsubjectDx := d.measureString(subject)\n\tstatusDx := d.measureString(status)\n\td.mutex.Unlock()\n\n\tbdg := badge{\n\t\tSubject: subject,\n\t\tStatus:  status,\n\t\tColor:   color,\n\t\tBounds: bounds{\n\t\t\tSubjectDx: subjectDx,\n\t\t\tSubjectX:  subjectDx/2.0 + 1,\n\t\t\tStatusDx:  statusDx,\n\t\t\tStatusX:   subjectDx + statusDx/2.0 - 1,\n\t\t},\n\t}\n\treturn d.tmpl.Execute(w, bdg)\n}\n\nfunc (d *badgeDrawer) RenderBytes(subject, status string, color Color) ([]byte, error) {\n\tbuf := &bytes.Buffer{}\n\terr := d.Render(subject, status, color, buf)\n\treturn buf.Bytes(), err\n}\n\n// shields.io uses Verdana.ttf to measure text width with an extra 10px.\n// As we use DejaVuSans.ttf, we have to tune this value a little.\nconst extraDx = 5\n\nfunc (d *badgeDrawer) measureString(s string) float64 {\n\tSHIFT := 6\n\treturn float64(d.fd.MeasureString(s)>>SHIFT) + extraDx\n}\n\n// RenderBytes renders a badge of the given color, with given subject and status to bytes.\nfunc RenderBytes(subject, status string, color Color) ([]byte, error) {\n\tdrawer, err := initDrawer()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn drawer.RenderBytes(subject, status, color)\n}\n\nconst (\n\tdpi      = 72\n\tfontSize = 11\n)\n\nvar (\n\tdrawer    *badgeDrawer\n\tinitError error\n\tinitOnce  sync.Once\n)\n\nfunc initDrawer() (*badgeDrawer, error) {\n\tinitOnce.Do(func() {\n\t\tfd, err := mustNewFontDrawer(fontSize, dpi)\n\t\tif err != nil {\n\t\t\tinitError = err\n\t\t\treturn\n\t\t}\n\t\tdrawer = &badgeDrawer{\n\t\t\tfd:    fd,\n\t\t\ttmpl:  template.Must(template.New(\"flat-template\").Parse(flatTemplate)),\n\t\t\tmutex: &sync.Mutex{},\n\t\t}\n\t\tinitError = nil\n\t})\n\treturn drawer, initError\n}\n\nfunc mustNewFontDrawer(size, dpi float64) (*font.Drawer, error) {\n\tf, err := sfnt.Parse(fonts.DejaVuSans)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tface, err := opentype.NewFace(f, &opentype.FaceOptions{\n\t\tSize:    size,\n\t\tDPI:     dpi,\n\t\tHinting: font.HintingFull,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &font.Drawer{\n\t\tFace: face,\n\t}, nil\n}\n"
  },
  {
    "path": "server/badges/fonts/dejavusans.go",
    "content": "// Copyright 2023 The narqo/go-badge Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\npackage fonts\n\nimport (\n\t_ \"embed\"\n)\n\n// DejaVuSans is DejaVuSans.ttf font inlined to the bytes slice.\n//\n//go:embed DejaVuSans.ttf\nvar DejaVuSans []byte\n"
  },
  {
    "path": "server/badges/style.go",
    "content": "// Copyright 2023 The narqo/go-badge Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\npackage badges\n\n// cspell:words Verdana\n\nvar flatTemplate = `<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{{.Bounds.Dx}}\" height=\"20\"><linearGradient id=\"smooth\" x2=\"0\" y2=\"100%\"><stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/><stop offset=\"1\" stop-opacity=\".1\"/></linearGradient><mask id=\"round\"><rect width=\"{{.Bounds.Dx}}\" height=\"20\" rx=\"3\" fill=\"#fff\"/></mask><g mask=\"url(#round)\"><rect width=\"{{.Bounds.SubjectDx}}\" height=\"20\" fill=\"#555\"/><rect x=\"{{.Bounds.SubjectDx}}\" width=\"{{.Bounds.StatusDx}}\" height=\"20\" fill=\"{{or .Color \"#4c1\" | html}}\"/><rect width=\"{{.Bounds.Dx}}\" height=\"20\" fill=\"url(#smooth)\"/></g><g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\"><text x=\"{{.Bounds.SubjectX}}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{{.Subject | html}}</text><text x=\"{{.Bounds.SubjectX}}\" y=\"14\">{{.Subject | html}}</text><text x=\"{{.Bounds.StatusX}}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{{.Status | html}}</text><text x=\"{{.Bounds.StatusX}}\" y=\"14\">{{.Status | html}}</text></g></svg>`\n"
  },
  {
    "path": "server/cache/membership.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cache\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/jellydator/ttlcache/v3\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// MembershipService is a service to check for user membership.\ntype MembershipService interface {\n\t// Get returns if the user is a member of the organization.\n\tGet(ctx context.Context, _forge forge.Forge, u *model.User, org string) (*model.OrgPerm, error)\n}\n\ntype membershipCache struct {\n\tcache *ttlcache.Cache[string, *model.OrgPerm]\n\tstore store.Store\n\tttl   time.Duration\n}\n\n// NewMembershipService creates a new membership service.\nfunc NewMembershipService(_store store.Store) MembershipService {\n\treturn &membershipCache{\n\t\tttl:   10 * time.Minute, //nolint:mnd\n\t\tstore: _store,\n\t\tcache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()),\n\t}\n}\n\n// Get returns if the user is a member of the organization.\nfunc (c *membershipCache) Get(ctx context.Context, _forge forge.Forge, u *model.User, org string) (*model.OrgPerm, error) {\n\tkey := fmt.Sprintf(\"%s-%s\", u.ForgeRemoteID, org)\n\titem := c.cache.Get(key)\n\tif item != nil && !item.IsExpired() {\n\t\treturn item.Value(), nil\n\t}\n\n\tperm, err := _forge.OrgMembership(ctx, u, org)\n\tif errors.Is(err, forge_types.ErrNotImplemented) {\n\t\tlog.Debug().Msg(\"Could not check user org membership as forge adapter did not implement it\")\n\t\treturn &model.OrgPerm{}, nil\n\t} else if err != nil {\n\t\treturn nil, err\n\t}\n\tc.cache.Set(key, perm, c.ttl)\n\treturn perm, nil\n}\n"
  },
  {
    "path": "server/ccmenu/cc.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ccmenu\n\nimport (\n\t\"encoding/xml\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// CCMenu displays the pipeline status of projects on a ci server as an item in the Mac's menu bar.\n// It started as part of the CruiseControl project that built the first continuous integration server.\n//\n// http://ccmenu.org/\n\ntype CCProjects struct {\n\tXMLName xml.Name   `xml:\"Projects\"`\n\tProject *CCProject `xml:\"Project\"`\n}\n\ntype CCProject struct {\n\tXMLName         xml.Name `xml:\"Project\"`\n\tName            string   `xml:\"name,attr\"`\n\tActivity        string   `xml:\"activity,attr\"`\n\tLastBuildStatus string   `xml:\"lastBuildStatus,attr\"`\n\tLastBuildLabel  string   `xml:\"lastBuildLabel,attr\"`\n\tLastBuildTime   string   `xml:\"lastBuildTime,attr\"`\n\tWebURL          string   `xml:\"webUrl,attr\"`\n}\n\nfunc New(r *model.Repo, b *model.Pipeline, url string) *CCProjects {\n\tproj := &CCProject{\n\t\tName:            r.FullName,\n\t\tWebURL:          url,\n\t\tActivity:        \"Building\",\n\t\tLastBuildStatus: \"Unknown\",\n\t\tLastBuildLabel:  \"Unknown\",\n\t}\n\n\t// if the pipeline is not currently running then\n\t// we can return the latest pipeline status.\n\tif b.Status != model.StatusPending &&\n\t\tb.Status != model.StatusRunning {\n\t\tproj.Activity = \"Sleeping\"\n\t\tproj.LastBuildTime = time.Unix(b.Started, 0).Format(time.RFC3339)\n\t\tproj.LastBuildLabel = strconv.FormatInt(b.Number, 10)\n\t}\n\n\t// ensure the last pipeline status accepts a valid\n\t// ccmenu enumeration\n\tswitch b.Status {\n\tcase model.StatusError, model.StatusKilled:\n\t\tproj.LastBuildStatus = \"Exception\"\n\tcase model.StatusSuccess:\n\t\tproj.LastBuildStatus = \"Success\"\n\tcase model.StatusFailure:\n\t\tproj.LastBuildStatus = \"Failure\"\n\t}\n\n\treturn &CCProjects{Project: proj}\n}\n"
  },
  {
    "path": "server/ccmenu/cc_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ccmenu\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestCC(t *testing.T) {\n\tt.Run(\"create a project\", func(t *testing.T) {\n\t\tnow := time.Now().Unix()\n\t\tnowFmt := time.Unix(now, 0).Format(time.RFC3339)\n\t\tr := &model.Repo{\n\t\t\tFullName: \"foo/bar\",\n\t\t}\n\t\tb := &model.Pipeline{\n\t\t\tStatus:  model.StatusSuccess,\n\t\t\tNumber:  1,\n\t\t\tStarted: now,\n\t\t}\n\t\tcc := New(r, b, \"http://localhost/foo/bar/1\")\n\n\t\tassert.Equal(t, \"foo/bar\", cc.Project.Name)\n\t\tassert.Equal(t, \"Sleeping\", cc.Project.Activity)\n\t\tassert.Equal(t, \"Success\", cc.Project.LastBuildStatus)\n\t\tassert.Equal(t, \"1\", cc.Project.LastBuildLabel)\n\t\tassert.Equal(t, nowFmt, cc.Project.LastBuildTime)\n\t\tassert.Equal(t, \"http://localhost/foo/bar/1\", cc.Project.WebURL)\n\t})\n\n\tt.Run(\"properly label exceptions\", func(t *testing.T) {\n\t\tr := &model.Repo{FullName: \"foo/bar\"}\n\t\tb := &model.Pipeline{\n\t\t\tStatus:  model.StatusError,\n\t\t\tNumber:  1,\n\t\t\tStarted: 1257894000,\n\t\t}\n\t\tcc := New(r, b, \"http://localhost/foo/bar/1\")\n\t\tassert.Equal(t, \"Exception\", cc.Project.LastBuildStatus)\n\t\tassert.Equal(t, \"Sleeping\", cc.Project.Activity)\n\t})\n\n\tt.Run(\"properly label success\", func(t *testing.T) {\n\t\tr := &model.Repo{FullName: \"foo/bar\"}\n\t\tb := &model.Pipeline{\n\t\t\tStatus:  model.StatusSuccess,\n\t\t\tNumber:  1,\n\t\t\tStarted: 1257894000,\n\t\t}\n\t\tcc := New(r, b, \"http://localhost/foo/bar/1\")\n\t\tassert.Equal(t, \"Success\", cc.Project.LastBuildStatus)\n\t\tassert.Equal(t, \"Sleeping\", cc.Project.Activity)\n\t})\n\n\tt.Run(\"properly label failure\", func(t *testing.T) {\n\t\tr := &model.Repo{FullName: \"foo/bar\"}\n\t\tb := &model.Pipeline{\n\t\t\tStatus:  model.StatusFailure,\n\t\t\tNumber:  1,\n\t\t\tStarted: 1257894000,\n\t\t}\n\t\tcc := New(r, b, \"http://localhost/foo/bar/1\")\n\t\tassert.Equal(t, \"Failure\", cc.Project.LastBuildStatus)\n\t\tassert.Equal(t, \"Sleeping\", cc.Project.Activity)\n\t})\n\n\tt.Run(\"properly label running\", func(t *testing.T) {\n\t\tr := &model.Repo{FullName: \"foo/bar\"}\n\t\tb := &model.Pipeline{\n\t\t\tStatus:  model.StatusRunning,\n\t\t\tNumber:  1,\n\t\t\tStarted: 1257894000,\n\t\t}\n\t\tcc := New(r, b, \"http://localhost/foo/bar/1\")\n\t\tassert.Equal(t, \"Building\", cc.Project.Activity)\n\t\tassert.Equal(t, \"Unknown\", cc.Project.LastBuildStatus)\n\t\tassert.Equal(t, \"Unknown\", cc.Project.LastBuildLabel)\n\t})\n}\n"
  },
  {
    "path": "server/config.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"time\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/cache\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/permissions\"\n)\n\nvar Config = struct {\n\tServices struct {\n\t\tScheduler  scheduler.Scheduler\n\t\tLogs       logging.Log\n\t\tMembership cache.MembershipService\n\t\tManager    services.Manager\n\t\tLogStore   log.Service\n\t}\n\tServer struct {\n\t\tJWTSecret           string\n\t\tKey                 string\n\t\tCert                string\n\t\tOAuthHost           string\n\t\tHost                string\n\t\tWebhookHost         string\n\t\tPort                string\n\t\tPortTLS             string\n\t\tAgentToken          string\n\t\tStatusContext       string\n\t\tStatusContextFormat string\n\t\tSessionExpires      time.Duration\n\t\tRootPath            string\n\t\tCustomCSSFile       string\n\t\tCustomJsFile        string\n\t}\n\tAgent struct {\n\t\tDisableUserRegisteredAgentRegistration bool\n\t}\n\tWebUI struct {\n\t\tEnableSwagger           bool\n\t\tSkipVersionCheck        bool\n\t\tMaxPipelineLogLineCount uint\n\t}\n\tPrometheus struct {\n\t\tAuthToken string\n\t}\n\tPipeline struct {\n\t\tAuthenticatePublicRepos             bool\n\t\tDefaultAllowPullRequests            bool\n\t\tDefaultCancelPreviousPipelineEvents []model.WebhookEvent\n\t\tDefaultApprovalMode                 model.ApprovalMode\n\t\tDefaultWorkflowLabels               map[string]string\n\t\tDefaultClonePlugin                  string\n\t\tTrustedClonePlugins                 []string\n\t\tVolumes                             []string\n\t\tNetworks                            []string\n\t\tPrivilegedPlugins                   []string\n\t\tDefaultTimeout                      int64\n\t\tMaxTimeout                          int64\n\t\tProxy                               struct {\n\t\t\tNo    string\n\t\t\tHTTP  string\n\t\t\tHTTPS string\n\t\t}\n\t\t// TODO: remove with version 4.x\n\t\tForceIgnoreServiceFailure bool\n\t}\n\tPermissions struct {\n\t\tOpen            bool\n\t\tAdmins          *permissions.Admins\n\t\tOrgs            *permissions.Orgs\n\t\tOwnersAllowlist *permissions.OwnersAllowlist\n\t}\n}{}\n"
  },
  {
    "path": "server/cron/cron.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/gdgvda/cron\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nconst (\n\t// Specifies the interval woodpecker checks for new crons to exec.\n\tcheckTime = time.Minute\n\n\t// Specifies the batch size of crons to retrieve per check from database.\n\tcheckItems = 10\n)\n\n// Run starts the cron scheduler loop.\nfunc Run(ctx context.Context, store store.Store) error {\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn nil\n\t\tcase <-time.After(checkTime):\n\t\t\tgo func() {\n\t\t\t\tnow := time.Now()\n\t\t\t\tlog.Trace().Msg(\"cron: fetch next crons\")\n\n\t\t\t\tcrons, err := store.CronListNextExecute(now.Unix(), checkItems)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error().Err(err).Int64(\"now\", now.Unix()).Msg(\"obtain cron list\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tfor _, cron := range crons {\n\t\t\t\t\tif err := runCron(ctx, store, cron, now); err != nil {\n\t\t\t\t\t\tlog.Error().Err(err).Int64(\"cronID\", cron.ID).Msg(\"run cron failed\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t}\n}\n\n// CalcNewNext parses a cron string and calculates the next exec time based on it.\nfunc CalcNewNext(schedule string, now time.Time) (time.Time, error) {\n\t// remove local timezone\n\tnow = now.UTC()\n\n\t// TODO: allow the users / the admin to set a specific timezone\n\n\tparser, err := cron.NewDefaultParser(cron.StandardOptions)\n\tif err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"can't create parser: %w\", err)\n\t}\n\n\tc, err := parser.Parse(schedule)\n\tif err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"cron parse schedule: %w\", err)\n\t}\n\treturn c.Next(now), nil\n}\n\nfunc runCron(ctx context.Context, store store.Store, cron *model.Cron, now time.Time) error {\n\tlog.Trace().Msgf(\"cron: run id[%d]\", cron.ID)\n\n\tnewNext, err := CalcNewNext(cron.Schedule, now)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// try to get lock on cron\n\tgotLock, err := store.CronGetLock(cron, newNext.Unix())\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !gotLock {\n\t\t// another go routine caught it\n\t\treturn nil\n\t}\n\n\trepo, newPipeline, err := CreatePipeline(ctx, store, cron)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = pipeline.Create(ctx, store, repo, newPipeline)\n\treturn err\n}\n\nfunc CreatePipeline(ctx context.Context, store store.Store, cron *model.Cron) (*model.Repo, *model.Pipeline, error) {\n\trepo, err := store.GetRepo(cron.RepoID)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif cron.Branch == \"\" {\n\t\t// fallback to the repos default branch\n\t\tcron.Branch = repo.Branch\n\t}\n\n\trepoUser, err := store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// If the forge has a refresh token, the current access token\n\t// may be stale. Therefore, we should refresh prior to dispatching\n\t// the pipeline.\n\tforge.Refresh(ctx, _forge, store, repoUser)\n\n\tcommit, err := _forge.BranchHead(ctx, repoUser, repo, cron.Branch)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn repo, &model.Pipeline{\n\t\tEvent:               model.EventCron,\n\t\tCommit:              commit.SHA,\n\t\tRef:                 \"refs/heads/\" + cron.Branch,\n\t\tBranch:              cron.Branch,\n\t\tTimestamp:           cron.NextExec,\n\t\tCron:                cron.Name,\n\t\tForgeURL:            commit.ForgeURL,\n\t\tAdditionalVariables: cron.Variables,\n\t}, nil\n}\n"
  },
  {
    "path": "server/cron/cron_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage cron\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestCreatePipeline(t *testing.T) {\n\t_manager := manager_mocks.NewMockManager(t)\n\t_forge := forge_mocks.NewMockForge(t)\n\tstore := store_mocks.NewMockStore(t)\n\tctx := t.Context()\n\n\trepoUser := &model.User{\n\t\tID:    1,\n\t\tLogin: \"user1\",\n\t}\n\trepo1 := &model.Repo{\n\t\tID:       1,\n\t\tName:     \"repo1\",\n\t\tOwner:    \"owner1\",\n\t\tFullName: \"repo1/owner1\",\n\t\tBranch:   \"default\",\n\t\tUserID:   repoUser.ID,\n\t}\n\n\t// mock things\n\tstore.On(\"GetRepo\", mock.Anything).Return(repo1, nil)\n\tstore.On(\"GetUser\", mock.Anything).Return(repoUser, nil)\n\t_forge.On(\"BranchHead\", mock.Anything, repoUser, repo1, \"default\").Return(&model.Commit{\n\t\tForgeURL: \"https://example.com/sha1\",\n\t\tSHA:      \"sha1\",\n\t}, nil)\n\t_manager.On(\"ForgeFromRepo\", repo1).Return(_forge, nil)\n\tserver.Config.Services.Manager = _manager\n\n\t_, pipeline, err := CreatePipeline(ctx, store, &model.Cron{\n\t\tName: \"test\",\n\t})\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, &model.Pipeline{\n\t\tBranch:   \"default\",\n\t\tCommit:   \"sha1\",\n\t\tEvent:    \"cron\",\n\t\tForgeURL: \"https://example.com/sha1\",\n\t\tRef:      \"refs/heads/default\",\n\t\tCron:     \"test\",\n\t}, pipeline)\n}\n\nfunc TestCalcNewNext(t *testing.T) {\n\tnow := time.Unix(1661962369, 0)\n\t_, err := CalcNewNext(\"\", now)\n\tassert.Error(t, err)\n\n\tschedule, err := CalcNewNext(\"@every 5m\", now)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1661962669, schedule.Unix())\n}\n"
  },
  {
    "path": "server/forge/addon/args.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype argumentsRepo struct {\n\tU        *modelUser          `json:\"u\"`\n\tRemoteID model.ForgeRemoteID `json:\"remote_id\"`\n\tOwner    string              `json:\"owner\"`\n\tName     string              `json:\"name\"`\n}\n\ntype argumentsFileDir struct {\n\tU *modelUser      `json:\"u\"`\n\tR *modelRepo      `json:\"r\"`\n\tB *model.Pipeline `json:\"b\"`\n\tF string          `json:\"f\"`\n}\n\ntype argumentsStatus struct {\n\tU *modelUser      `json:\"u\"`\n\tR *modelRepo      `json:\"r\"`\n\tB *model.Pipeline `json:\"b\"`\n\tP *model.Workflow `json:\"p\"`\n}\n\ntype argumentsNetrc struct {\n\tU *modelUser `json:\"u\"`\n\tR *modelRepo `json:\"r\"`\n}\n\ntype argumentsActivateDeactivate struct {\n\tU    *modelUser `json:\"u\"`\n\tR    *modelRepo `json:\"r\"`\n\tLink string     `json:\"link\"`\n}\n\ntype argumentsBranchesPullRequests struct {\n\tU *modelUser         `json:\"u\"`\n\tR *modelRepo         `json:\"r\"`\n\tP *model.ListOptions `json:\"p\"`\n}\n\ntype argumentsBranchHead struct {\n\tU      *modelUser `json:\"u\"`\n\tR      *modelRepo `json:\"r\"`\n\tBranch string     `json:\"branch\"`\n}\n\ntype argumentsOrgMembershipOrg struct {\n\tU   *modelUser `json:\"u\"`\n\tOrg string     `json:\"org\"`\n}\n\ntype argumentsTeams struct {\n\tU *modelUser         `json:\"u\"`\n\tP *model.ListOptions `json:\"p\"`\n}\n\ntype argumentsRepos struct {\n\tU *modelUser         `json:\"u\"`\n\tP *model.ListOptions `json:\"p\"`\n}\n\ntype responseHook struct {\n\tRepo     *modelRepo      `json:\"repo\"`\n\tPipeline *model.Pipeline `json:\"pipeline\"`\n}\n\ntype responseLogin struct {\n\tUser        *modelUser `json:\"user\"`\n\tRedirectURL string     `json:\"redirect_url\"`\n}\n\ntype httpRequest struct {\n\tMethod string              `json:\"method\"`\n\tURL    string              `json:\"url\"`\n\tHeader map[string][]string `json:\"header\"`\n\tForm   map[string][]string `json:\"form\"`\n\tBody   []byte              `json:\"body\"`\n}\n\n// modelUser is an extension of model.User to marshal all fields to JSON.\ntype modelUser struct {\n\tUser *model.User `json:\"user\"`\n\n\tForgeRemoteID model.ForgeRemoteID `json:\"forge_remote_id\"`\n\n\t// Token is the oauth2 token.\n\tToken string `json:\"token\"`\n\n\t// Secret is the oauth2 token secret.\n\tSecret string `json:\"secret\"`\n\n\t// Expiry is the token and secret expiration timestamp.\n\tExpiry int64 `json:\"expiry\"`\n\n\t// Hash is a unique token used to sign tokens.\n\tHash string `json:\"hash\"`\n}\n\nfunc (m *modelUser) asModel() *model.User {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tm.User.ForgeRemoteID = m.ForgeRemoteID\n\tm.User.AccessToken = m.Token\n\tm.User.RefreshToken = m.Secret\n\tm.User.Expiry = m.Expiry\n\tm.User.Hash = m.Hash\n\treturn m.User\n}\n\nfunc modelUserFromModel(u *model.User) *modelUser {\n\tif u == nil {\n\t\treturn nil\n\t}\n\treturn &modelUser{\n\t\tUser:          u,\n\t\tForgeRemoteID: u.ForgeRemoteID,\n\t\tToken:         u.AccessToken,\n\t\tSecret:        u.RefreshToken,\n\t\tExpiry:        u.Expiry,\n\t\tHash:          u.Hash,\n\t}\n}\n\n// modelRepo is an extension of model.Repo to marshal all fields to JSON.\ntype modelRepo struct {\n\tRepo   *model.Repo `json:\"repo\"`\n\tUserID int64       `json:\"user_id\"`\n\tHash   string      `json:\"hash\"`\n\tPerm   *model.Perm `json:\"perm\"`\n}\n\nfunc (m *modelRepo) asModel() *model.Repo {\n\tif m == nil {\n\t\treturn nil\n\t}\n\tm.Repo.UserID = m.UserID\n\tm.Repo.Hash = m.Hash\n\tm.Repo.Perm = m.Perm\n\treturn m.Repo\n}\n\nfunc modelRepoFromModel(r *model.Repo) *modelRepo {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn &modelRepo{\n\t\tRepo:   r,\n\t\tUserID: r.UserID,\n\t\tHash:   r.Hash,\n\t\tPerm:   r.Perm,\n\t}\n}\n"
  },
  {
    "path": "server/forge/addon/client.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/rpc\"\n\t\"os/exec\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n)\n\n// make sure RPC implements forge.Forge.\nvar _ forge.Forge = new(RPC)\n\nfunc Load(file string) (forge.Forge, error) {\n\tclient := plugin.NewClient(&plugin.ClientConfig{\n\t\tHandshakeConfig: HandshakeConfig,\n\t\tPlugins: map[string]plugin.Plugin{\n\t\t\tpluginKey: &Plugin{},\n\t\t},\n\t\tCmd: exec.Command(file),\n\t\tLogger: &logger.AddonClientLogger{\n\t\t\tLogger: log.With().Str(\"addon\", file).Logger(),\n\t\t},\n\t})\n\t// TODO: defer client.Kill()\n\n\trpcClient, err := client.Client()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\traw, err := rpcClient.Dispense(pluginKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\textension, _ := raw.(forge.Forge)\n\treturn extension, nil\n}\n\ntype RPC struct {\n\tclient *rpc.Client\n}\n\nfunc (g *RPC) Name() string {\n\tvar resp string\n\terr := g.client.Call(\"Plugin.Name\", []byte{}, &resp)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"addon Plugin.Name call failed\")\n\t}\n\treturn resp\n}\n\nfunc (g *RPC) URL() string {\n\tvar resp string\n\terr := g.client.Call(\"Plugin.URL\", []byte{}, &resp)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"addon Plugin.URL call failed\")\n\t}\n\treturn resp\n}\n\nfunc (g *RPC) Login(_ context.Context, r *types.OAuthRequest) (*model.User, string, error) {\n\targs, err := json.Marshal(r)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Login\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tvar resp responseLogin\n\terr = json.Unmarshal(jsonResp, &resp)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn resp.User.asModel(), resp.RedirectURL, nil\n}\n\nfunc (g *RPC) Teams(_ context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\targs, err := json.Marshal(&argumentsTeams{\n\t\tU: modelUserFromModel(u),\n\t\tP: p,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Teams\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar resp []*model.Team\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) Repo(_ context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\targs, err := json.Marshal(&argumentsRepo{\n\t\tU:        modelUserFromModel(u),\n\t\tRemoteID: remoteID,\n\t\tOwner:    owner,\n\t\tName:     name,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Repo\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar resp modelRepo\n\terr = json.Unmarshal(jsonResp, &resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resp.asModel(), nil\n}\n\nfunc (g *RPC) Repos(_ context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\targs, err := json.Marshal(&argumentsRepos{\n\t\tU: modelUserFromModel(u),\n\t\tP: p,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Repos\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar resp []*modelRepo\n\terr = json.Unmarshal(jsonResp, &resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar modelRepos []*model.Repo\n\tfor _, repo := range resp {\n\t\tmodelRepos = append(modelRepos, repo.asModel())\n\t}\n\treturn modelRepos, nil\n}\n\nfunc (g *RPC) File(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {\n\targs, err := json.Marshal(&argumentsFileDir{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t\tB: b,\n\t\tF: f,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp []byte\n\treturn resp, g.client.Call(\"Plugin.File\", args, &resp)\n}\n\nfunc (g *RPC) Dir(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*types.FileMeta, error) {\n\targs, err := json.Marshal(&argumentsFileDir{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t\tB: b,\n\t\tF: f,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Dir\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp []*types.FileMeta\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) Status(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error {\n\targs, err := json.Marshal(&argumentsStatus{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t\tB: b,\n\t\tP: p,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar jsonResp []byte\n\treturn g.client.Call(\"Plugin.Status\", args, &jsonResp)\n}\n\nfunc (g *RPC) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\targs, err := json.Marshal(&argumentsNetrc{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Netrc\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp *model.Netrc\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) Activate(_ context.Context, u *model.User, r *model.Repo, link string) error {\n\targs, err := json.Marshal(&argumentsActivateDeactivate{\n\t\tU:    modelUserFromModel(u),\n\t\tR:    modelRepoFromModel(r),\n\t\tLink: link,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar jsonResp []byte\n\treturn g.client.Call(\"Plugin.Activate\", args, &jsonResp)\n}\n\nfunc (g *RPC) Deactivate(_ context.Context, u *model.User, r *model.Repo, link string) error {\n\targs, err := json.Marshal(&argumentsActivateDeactivate{\n\t\tU:    modelUserFromModel(u),\n\t\tR:    modelRepoFromModel(r),\n\t\tLink: link,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar jsonResp []byte\n\treturn g.client.Call(\"Plugin.Deactivate\", args, &jsonResp)\n}\n\nfunc (g *RPC) Branches(_ context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\targs, err := json.Marshal(&argumentsBranchesPullRequests{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t\tP: p,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Branches\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp []string\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) BranchHead(_ context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\targs, err := json.Marshal(&argumentsBranchHead{\n\t\tU:      modelUserFromModel(u),\n\t\tR:      modelRepoFromModel(r),\n\t\tBranch: branch,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.BranchHead\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp *model.Commit\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) PullRequests(_ context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\targs, err := json.Marshal(&argumentsBranchesPullRequests{\n\t\tU: modelUserFromModel(u),\n\t\tR: modelRepoFromModel(r),\n\t\tP: p,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.PullRequests\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp []*model.PullRequest\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) Hook(_ context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\targs, err := json.Marshal(&httpRequest{\n\t\tMethod: r.Method,\n\t\tURL:    r.URL.String(),\n\t\tHeader: r.Header,\n\t\tForm:   r.Form,\n\t\tBody:   body,\n\t})\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Hook\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tvar resp responseHook\n\terr = json.Unmarshal(jsonResp, &resp)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\treturn resp.Repo.asModel(), resp.Pipeline, nil\n}\n\nfunc (g *RPC) OrgMembership(_ context.Context, u *model.User, org string) (*model.OrgPerm, error) {\n\targs, err := json.Marshal(&argumentsOrgMembershipOrg{\n\t\tU:   modelUserFromModel(u),\n\t\tOrg: org,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.OrgMembership\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp *model.OrgPerm\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n\nfunc (g *RPC) Org(_ context.Context, u *model.User, org string) (*model.Org, error) {\n\targs, err := json.Marshal(&argumentsOrgMembershipOrg{\n\t\tU:   modelUserFromModel(u),\n\t\tOrg: org,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.Org\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar resp *model.Org\n\treturn resp, json.Unmarshal(jsonResp, &resp)\n}\n"
  },
  {
    "path": "server/forge/addon/plugin.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"net/rpc\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n)\n\nconst pluginKey = \"forge\"\n\nvar HandshakeConfig = plugin.HandshakeConfig{\n\tProtocolVersion:  1,\n\tMagicCookieKey:   \"WOODPECKER_FORGE_ADDON_PLUGIN\",\n\tMagicCookieValue: \"woodpecker-plugin-magic-cookie-value\",\n}\n\ntype Plugin struct {\n\tImpl forge.Forge\n}\n\nfunc (p *Plugin) Server(*plugin.MuxBroker) (any, error) {\n\treturn &RPCServer{Impl: p.Impl}, nil\n}\n\nfunc (*Plugin) Client(_ *plugin.MuxBroker, c *rpc.Client) (any, error) {\n\treturn &RPC{client: c}, nil\n}\n"
  },
  {
    "path": "server/forge/addon/server.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n)\n\nfunc Serve(impl forge.Forge) {\n\tplugin.Serve(&plugin.ServeConfig{\n\t\tHandshakeConfig: HandshakeConfig,\n\t\tPlugins: map[string]plugin.Plugin{\n\t\t\tpluginKey: &Plugin{Impl: impl},\n\t\t},\n\t})\n}\n\nfunc mkCtx() context.Context {\n\treturn context.Background()\n}\n\ntype RPCServer struct {\n\tImpl forge.Forge\n}\n\nfunc (s *RPCServer) Name(_ []byte, resp *string) error {\n\t*resp = s.Impl.Name()\n\treturn nil\n}\n\nfunc (s *RPCServer) URL(_ []byte, resp *string) error {\n\t*resp = s.Impl.URL()\n\treturn nil\n}\n\nfunc (s *RPCServer) Teams(args []byte, resp *[]byte) error {\n\tvar a argumentsTeams\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tteams, err := s.Impl.Teams(mkCtx(), a.U.asModel(), a.P)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(teams)\n\treturn err\n}\n\nfunc (s *RPCServer) Repo(args []byte, resp *[]byte) error {\n\tvar a argumentsRepo\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepos, err := s.Impl.Repo(mkCtx(), a.U.asModel(), a.RemoteID, a.Owner, a.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(modelRepoFromModel(repos))\n\treturn err\n}\n\nfunc (s *RPCServer) Repos(args []byte, resp *[]byte) error {\n\tvar a argumentsRepos\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\trepos, err := s.Impl.Repos(mkCtx(), a.U.asModel(), a.P)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar modelRepos []*modelRepo\n\tfor _, repo := range repos {\n\t\tmodelRepos = append(modelRepos, modelRepoFromModel(repo))\n\t}\n\t*resp, err = json.Marshal(modelRepos)\n\treturn err\n}\n\nfunc (s *RPCServer) File(args []byte, resp *[]byte) error {\n\tvar a argumentsFileDir\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = s.Impl.File(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.F)\n\treturn err\n}\n\nfunc (s *RPCServer) Dir(args []byte, resp *[]byte) error {\n\tvar a argumentsFileDir\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmeta, err := s.Impl.Dir(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.F)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(meta)\n\treturn err\n}\n\nfunc (s *RPCServer) Status(args []byte, resp *[]byte) error {\n\tvar a argumentsStatus\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\treturn s.Impl.Status(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.P)\n}\n\nfunc (s *RPCServer) Netrc(args []byte, resp *[]byte) error {\n\tvar a argumentsNetrc\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tnetrc, err := s.Impl.Netrc(a.U.asModel(), a.R.asModel())\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(netrc)\n\treturn err\n}\n\nfunc (s *RPCServer) Activate(args []byte, resp *[]byte) error {\n\tvar a argumentsActivateDeactivate\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\treturn s.Impl.Activate(mkCtx(), a.U.asModel(), a.R.asModel(), a.Link)\n}\n\nfunc (s *RPCServer) Deactivate(args []byte, resp *[]byte) error {\n\tvar a argumentsActivateDeactivate\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\treturn s.Impl.Deactivate(mkCtx(), a.U.asModel(), a.R.asModel(), a.Link)\n}\n\nfunc (s *RPCServer) Branches(args []byte, resp *[]byte) error {\n\tvar a argumentsBranchesPullRequests\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbranches, err := s.Impl.Branches(mkCtx(), a.U.asModel(), a.R.asModel(), a.P)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(branches)\n\treturn err\n}\n\nfunc (s *RPCServer) BranchHead(args []byte, resp *[]byte) error {\n\tvar a argumentsBranchHead\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcommit, err := s.Impl.BranchHead(mkCtx(), a.U.asModel(), a.R.asModel(), a.Branch)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(commit)\n\treturn err\n}\n\nfunc (s *RPCServer) PullRequests(args []byte, resp *[]byte) error {\n\tvar a argumentsBranchesPullRequests\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tprs, err := s.Impl.PullRequests(mkCtx(), a.U.asModel(), a.R.asModel(), a.P)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(prs)\n\treturn err\n}\n\nfunc (s *RPCServer) OrgMembership(args []byte, resp *[]byte) error {\n\tvar a argumentsOrgMembershipOrg\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\torg, err := s.Impl.OrgMembership(mkCtx(), a.U.asModel(), a.Org)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(org)\n\treturn err\n}\n\nfunc (s *RPCServer) Org(args []byte, resp *[]byte) error {\n\tvar a argumentsOrgMembershipOrg\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\torg, err := s.Impl.Org(mkCtx(), a.U.asModel(), a.Org)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(org)\n\treturn err\n}\n\nfunc (s *RPCServer) Hook(args []byte, resp *[]byte) error {\n\tvar a httpRequest\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := http.NewRequest(a.Method, a.URL, bytes.NewBuffer(a.Body))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header = a.Header\n\treq.Form = a.Form\n\trepo, pipeline, err := s.Impl.Hook(mkCtx(), req)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(&responseHook{\n\t\tRepo:     modelRepoFromModel(repo),\n\t\tPipeline: pipeline,\n\t})\n\treturn err\n}\n\nfunc (s *RPCServer) Login(args []byte, resp *[]byte) error {\n\tvar a types.OAuthRequest\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuser, red, err := s.Impl.Login(mkCtx(), &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(&responseLogin{\n\t\tUser:        modelUserFromModel(user),\n\t\tRedirectURL: red,\n\t})\n\treturn err\n}\n"
  },
  {
    "path": "server/forge/bitbucket/bitbucket.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// Bitbucket cloud endpoints.\nconst (\n\tDefaultAPI = \"https://api.bitbucket.org\"\n\tDefaultURL = \"https://bitbucket.org\"\n\tpageSize   = 100\n)\n\n// Opts are forge options for bitbucket.\ntype Opts struct {\n\tOAuthClientID     string\n\tOAuthClientSecret string\n}\n\ntype config struct {\n\tforgeID       int64\n\tapi           string\n\turl           string\n\toAuthClientID string\n\toAuthSecret   string\n}\n\n// New returns a new forge Configuration for integrating with the Bitbucket\n// repository hosting service at https://bitbucket.org\nfunc New(id int64, opts *Opts) (forge.Forge, error) {\n\treturn &config{\n\t\tforgeID:       id,\n\t\tapi:           DefaultAPI,\n\t\turl:           DefaultURL,\n\t\toAuthClientID: opts.OAuthClientID,\n\t\toAuthSecret:   opts.OAuthClientSecret,\n\t}, nil\n\t// TODO: add checks\n}\n\n// Name returns the string name of this driver.\nfunc (c *config) Name() string {\n\treturn \"bitbucket\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (c *config) URL() string {\n\treturn c.url\n}\n\n// Login authenticates an account with Bitbucket using the oauth2 protocol. The\n// Bitbucket account details are returned when the user is successfully authenticated.\nfunc (c *config) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig := c.newOAuth2Config()\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\t// check the OAuth code\n\tif len(req.Code) == 0 {\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(ctx, req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tclient := internal.NewClient(ctx, c.api, config.Client(ctx, token))\n\tcurr, err := client.FindCurrent()\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\temails, err := client.ListEmail()\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tprimaryEmail := \"\"\n\tfor _, e := range emails.Values {\n\t\tif e.IsPrimary {\n\t\t\tprimaryEmail = e.Email\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn convertUser(curr, token, primaryEmail), redirectURL, nil\n}\n\n// Refresh refreshes the Bitbucket oauth2 access token. If the token is\n// refreshed the user is updated and a true value is returned.\nfunc (c *config) Refresh(ctx context.Context, user *model.User) (bool, error) {\n\tconfig := c.newOAuth2Config()\n\tsource := config.TokenSource(\n\t\tctx, &oauth2.Token{RefreshToken: user.RefreshToken})\n\n\ttoken, err := source.Token()\n\tif err != nil || len(token.AccessToken) == 0 {\n\t\treturn false, err\n\t}\n\n\tuser.AccessToken = token.AccessToken\n\tuser.RefreshToken = token.RefreshToken\n\tuser.Expiry = token.Expiry.UTC().Unix()\n\treturn true, nil\n}\n\n// Teams returns a list of all team membership for the Bitbucket account.\nfunc (c *config) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\tsetListOptions(p)\n\n\topts := &internal.ListOpts{\n\t\tPageLen: p.PerPage,\n\t\tPage:    p.Page,\n\t}\n\tresp, err := c.newClient(ctx, u).ListWorkspaces(opts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar workspaces []*internal.Workspace\n\tfor _, access := range resp.Values {\n\t\tif access.Workspace != nil {\n\t\t\tworkspaces = append(workspaces, access.Workspace)\n\t\t}\n\t}\n\treturn convertWorkspaceList(workspaces), nil\n}\n\n// Repo returns the named Bitbucket repository.\nfunc (c *config) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tif remoteID.IsValid() {\n\t\tname = string(remoteID)\n\t}\n\tif owner == \"\" {\n\t\trepos, err := c.Repos(ctx, u, &model.ListOptions{Page: 1})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, repo := range repos {\n\t\t\tif string(repo.ForgeRemoteID) == name {\n\t\t\t\towner = repo.Owner\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tclient := c.newClient(ctx, u)\n\trepo, err := client.FindRepo(owner, name)\n\tif err != nil {\n\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t}\n\tperm, err := client.GetPermission(owner, repo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn convertRepo(repo, perm), nil\n}\n\n// Repos returns a list of all repositories for Bitbucket account, including\n// organization repositories.\nfunc (c *config) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\t// we merge data from different sources\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tsetListOptions(p)\n\n\tclient := c.newClient(ctx, u)\n\n\tresp, err := client.ListWorkspaces(&internal.ListOpts{\n\t\tPage:    p.Page,\n\t\tPageLen: p.PerPage,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar all []*model.Repo\n\tfor _, access := range resp.Values {\n\t\tif access.Workspace == nil {\n\t\t\tcontinue\n\t\t}\n\t\tworkspace := access.Workspace\n\n\t\trepos, err := client.ListReposAll(workspace.Slug)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tuserPermissions, err := client.ListPermissionsAll(workspace.Slug)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tuserPermissionsByRepo := make(map[string]*internal.RepoPerm)\n\t\tfor _, permission := range userPermissions {\n\t\t\tuserPermissionsByRepo[permission.Repo.FullName] = permission\n\t\t}\n\n\t\tfor _, repo := range repos {\n\t\t\tif perm, ok := userPermissionsByRepo[repo.FullName]; ok {\n\t\t\t\tall = append(all, convertRepo(repo, perm))\n\t\t\t}\n\t\t}\n\t}\n\treturn all, nil\n}\n\n// File fetches the file from the Bitbucket repository and returns its contents.\nfunc (c *config) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) {\n\tconfig, err := c.newClient(ctx, u).FindSource(r.Owner, r.Name, p.Commit, f)\n\tif err != nil {\n\t\tvar rspErr internal.Error\n\t\tif ok := errors.As(err, &rspErr); ok && rspErr.Status == http.StatusNotFound {\n\t\t\treturn nil, &forge_types.ErrConfigNotFound{\n\t\t\t\tConfigs: []string{f},\n\t\t\t}\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn []byte(*config), nil\n}\n\n// Dir fetches a folder from the bitbucket repository.\nfunc (c *config) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {\n\tvar page *string\n\trepoPathFiles := []*forge_types.FileMeta{}\n\tclient := c.newClient(ctx, u)\n\tfor {\n\t\tfilesResp, err := client.GetRepoFiles(r.Owner, r.Name, p.Commit, f, page)\n\t\tif err != nil {\n\t\t\tvar rspErr internal.Error\n\t\t\tif ok := errors.As(err, &rspErr); ok && rspErr.Status == http.StatusNotFound {\n\t\t\t\treturn nil, &forge_types.ErrConfigNotFound{\n\t\t\t\t\tConfigs: []string{f},\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, file := range filesResp.Values {\n\t\t\t_, filename := filepath.Split(file.Path)\n\t\t\trepoFile := forge_types.FileMeta{\n\t\t\t\tName: filename,\n\t\t\t}\n\t\t\tif file.Type == \"commit_file\" {\n\t\t\t\tfileData, err := c.newClient(ctx, u).FindSource(r.Owner, r.Name, p.Commit, file.Path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tif fileData != nil {\n\t\t\t\t\trepoFile.Data = []byte(*fileData)\n\t\t\t\t}\n\t\t\t}\n\t\t\trepoPathFiles = append(repoPathFiles, &repoFile)\n\t\t}\n\n\t\t// Check for more results page\n\t\tif filesResp.Next == nil {\n\t\t\tbreak\n\t\t}\n\t\tnextPageURL, err := url.Parse(*filesResp.Next)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tparams, err := url.ParseQuery(nextPageURL.RawQuery)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnextPage := params.Get(\"page\")\n\t\tif len(nextPage) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tpage = &nextPage\n\t}\n\treturn repoPathFiles, nil\n}\n\n// Status creates a pipeline status for the Bitbucket commit.\nfunc (c *config) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tstatus := internal.PipelineStatus{\n\t\tState: convertStatus(workflow.State),\n\t\tDesc:  common.GetPipelineStatusDescription(workflow.State),\n\t\tKey:   common.GetPipelineStatusContext(repo, pipeline, workflow),\n\t\tURL:   common.GetPipelineStatusURL(repo, pipeline, workflow),\n\t}\n\n\tif pipeline.Event == model.EventPush || pipeline.IsPullRequest() {\n\t\tstatus.Refname = pipeline.Branch\n\t}\n\n\treturn c.newClient(ctx, user).CreateStatus(repo.Owner, repo.Name, pipeline.Commit, &status)\n}\n\n// Activate activates the repository by registering repository push hooks with\n// the Bitbucket repository. Prior to registering hook, previously created hooks\n// are deleted.\nfunc (c *config) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\trawURL, err := url.Parse(link)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_ = c.Deactivate(ctx, u, r, link)\n\n\treturn c.newClient(ctx, u).CreateHook(r.Owner, r.Name, &internal.Hook{\n\t\tActive: true,\n\t\tDesc:   rawURL.Host,\n\t\tEvents: []string{\"repo:push\", \"pullrequest:created\", \"pullrequest:updated\", \"pullrequest:fulfilled\", \"pullrequest:rejected\"},\n\t\tURL:    link,\n\t})\n}\n\n// Deactivate deactivates the repository be removing repository push hooks from\n// the Bitbucket repository.\nfunc (c *config) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tclient := c.newClient(ctx, u)\n\n\t// check repo exists\n\tif _, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name); err != nil {\n\t\treturn fmt.Errorf(\"repo online check failed: %w\", err)\n\t}\n\n\thooks, err := shared_utils.Paginate(func(page int) ([]*internal.Hook, error) {\n\t\thooks, err := client.ListHooks(r.Owner, r.Name, &internal.ListOpts{\n\t\t\tPage: page,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn hooks.Values, nil\n\t}, -1)\n\tif err != nil {\n\t\treturn err\n\t}\n\thook := matchingHooks(hooks, link)\n\tif hook != nil {\n\t\treturn client.DeleteHook(r.Owner, r.Name, hook.UUID)\n\t}\n\treturn nil\n}\n\n// Netrc returns a netrc file capable of authenticating Bitbucket requests and\n// cloning Bitbucket repositories.\nfunc (c *config) Netrc(u *model.User, _ *model.Repo) (*model.Netrc, error) {\n\treturn &model.Netrc{\n\t\tMachine:  \"bitbucket.org\",\n\t\tLogin:    \"x-token-auth\",\n\t\tPassword: u.AccessToken,\n\t\tType:     model.ForgeTypeBitbucket,\n\t}, nil\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (c *config) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\tsetListOptions(p)\n\n\topts := internal.ListOpts{Page: p.Page, PageLen: p.PerPage}\n\tbitbucketBranches, err := c.newClient(ctx, u).ListBranches(r.Owner, r.Name, &opts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbranches := make([]string, 0)\n\tfor _, branch := range bitbucketBranches {\n\t\tbranches = append(branches, branch.Name)\n\t}\n\treturn branches, nil\n}\n\n// BranchHead returns the sha of the head (latest commit) of the specified branch.\nfunc (c *config) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\tcommit, err := c.newClient(ctx, u).GetBranchHead(r.Owner, r.Name, branch)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &model.Commit{\n\t\tSHA:      commit.Hash,\n\t\tForgeURL: commit.Links.HTML.Href,\n\t}, nil\n}\n\n// PullRequests returns the pull requests of the named repository.\nfunc (c *config) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\tsetListOptions(p)\n\n\topts := internal.ListOpts{Page: p.Page, PageLen: p.PerPage}\n\tpullRequests, err := c.newClient(ctx, u).ListPullRequests(r.Owner, r.Name, &opts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result []*model.PullRequest\n\tfor _, pullRequest := range pullRequests {\n\t\tresult = append(result, &model.PullRequest{\n\t\t\tIndex: model.ForgeRemoteID(strconv.Itoa(int(pullRequest.ID))),\n\t\t\tTitle: pullRequest.Title,\n\t\t})\n\t}\n\treturn result, nil\n}\n\n// Hook parses the incoming Bitbucket hook and returns the Repository and\n// Pipeline details. If the hook is unsupported nil values are returned.\nfunc (c *config) Hook(ctx context.Context, req *http.Request) (*model.Repo, *model.Pipeline, error) {\n\tpr, repo, pl, err := parseHook(req)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tu, err := common.RepoUserForgeID(ctx, c.forgeID, repo.ForgeRemoteID)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Refresh the OAuth token before making API calls.\n\t// The token may be expired, and without this refresh the API calls below\n\t// would fail with \"OAuth2 access token expired\" error.\n\t_store, ok := store.TryFromContext(ctx)\n\tif ok {\n\t\tforge.Refresh(ctx, c, _store, u)\n\t}\n\n\tswitch pl.Event {\n\tcase model.EventPush:\n\t\t// List only the latest push changes\n\t\tpl.ChangedFiles, err = c.newClient(ctx, u).ListChangedFiles(repo.Owner, repo.Name, pl.Commit)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\tcase model.EventPull:\n\t\tclient := c.newClient(ctx, u)\n\n\t\tif pr == nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"can't run hook against empty PR information\")\n\t\t}\n\n\t\t// List all changes between source & destination branch\n\t\tpl.ChangedFiles, err = client.ListChangedFiles(repo.Owner, repo.Name, fmt.Sprintf(\"%s..%s\", pr.PullRequest.Source.Branch.Name, pr.PullRequest.Dest.Branch.Name))\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t}\n\n\trepo, err = c.Repo(ctx, u, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn repo, pl, nil\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (c *config) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {\n\tperm, err := c.newClient(ctx, u).GetUserWorkspaceMembership(owner, u.Login)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &model.OrgPerm{Member: perm != \"\", Admin: perm == \"owner\"}, nil\n}\n\nfunc (c *config) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {\n\tworkspace, err := c.newClient(ctx, u).GetWorkspace(owner)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Org{\n\t\tName:   workspace.Slug,\n\t\tIsUser: false, // bitbucket uses workspaces (similar to orgs) for teams and single users so we cannot distinguish between them\n\t}, nil\n}\n\n// helper function to return the bitbucket oauth2 client.\nfunc (c *config) newClient(ctx context.Context, u *model.User) *internal.Client {\n\tif u == nil {\n\t\treturn c.newClientToken(ctx, \"\", \"\")\n\t}\n\treturn c.newClientToken(ctx, u.AccessToken, u.RefreshToken)\n}\n\n// helper function to return the bitbucket oauth2 client.\nfunc (c *config) newClientToken(ctx context.Context, accessToken, refreshToken string) *internal.Client {\n\tclient := internal.NewClientToken(\n\t\tctx,\n\t\tc.api,\n\t\taccessToken,\n\t\trefreshToken,\n\t\t&oauth2.Token{\n\t\t\tAccessToken:  accessToken,\n\t\t\tRefreshToken: refreshToken,\n\t\t},\n\t)\n\tclient.Client = httputil.WrapClient(client.Client, \"forge-bitbucket\")\n\treturn client\n}\n\n// helper function to return the bitbucket oauth2 config.\nfunc (c *config) newOAuth2Config() *oauth2.Config {\n\treturn &oauth2.Config{\n\t\tClientID:     c.oAuthClientID,\n\t\tClientSecret: c.oAuthSecret,\n\t\tEndpoint: oauth2.Endpoint{\n\t\t\tAuthURL:  fmt.Sprintf(\"%s/site/oauth2/authorize\", c.url),\n\t\t\tTokenURL: fmt.Sprintf(\"%s/site/oauth2/access_token\", c.url),\n\t\t},\n\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t}\n}\n\n// helper function to return matching hooks.\nfunc matchingHooks(hooks []*internal.Hook, rawURL string) *internal.Hook {\n\tlink, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tfor _, hook := range hooks {\n\t\thookURL, err := url.Parse(hook.URL)\n\t\tif err == nil && hookURL.Host == link.Host {\n\t\t\treturn hook\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc setListOptions(p *model.ListOptions) {\n\tif p.PerPage > pageSize || p.PerPage == 0 {\n\t\tp.PerPage = pageSize\n\t}\n}\n"
  },
  {
    "path": "server/forge/bitbucket/bitbucket_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestNew(t *testing.T) {\n\tforge, _ := New(1, &Opts{OAuthClientID: \"4vyW6b49Z\", OAuthClientSecret: \"a5012f6c6\"})\n\n\tf, _ := forge.(*config)\n\tassert.Equal(t, DefaultURL, f.url)\n\tassert.Equal(t, DefaultAPI, f.api)\n\tassert.Equal(t, \"4vyW6b49Z\", f.oAuthClientID)\n\tassert.Equal(t, \"a5012f6c6\", f.oAuthSecret)\n}\n\nfunc TestBitbucket(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ts := httptest.NewServer(fixtures.Handler())\n\tdefer s.Close()\n\tc := &config{url: s.URL, api: s.URL}\n\n\tctx := t.Context()\n\n\tforge, _ := New(1, &Opts{})\n\tnetrc, _ := forge.Netrc(fakeUser, fakeRepo)\n\tassert.Equal(t, \"bitbucket.org\", netrc.Machine)\n\tassert.Equal(t, \"x-token-auth\", netrc.Login)\n\tassert.Equal(t, fakeUser.AccessToken, netrc.Password)\n\tassert.Equal(t, model.ForgeTypeBitbucket, netrc.Type)\n\n\tuser, _, err := c.Login(ctx, &types.OAuthRequest{})\n\tassert.NoError(t, err)\n\tassert.Nil(t, user)\n\n\tu, _, err := c.Login(ctx, &types.OAuthRequest{\n\t\tCode: \"code\",\n\t})\n\tassert.NoError(t, err)\n\tassert.Equal(t, fakeUser.Login, u.Login)\n\tassert.Equal(t, \"2YotnFZFEjr1zCsicMWpAA\", u.AccessToken)\n\tassert.Equal(t, \"tGzv3JOkF0XG5Qx2TlKWIA\", u.RefreshToken)\n\n\t_, _, err = c.Login(ctx, &types.OAuthRequest{\n\t\tCode: \"code_bad_request\",\n\t})\n\tassert.Error(t, err)\n\n\t_, _, err = c.Login(ctx, &types.OAuthRequest{\n\t\tCode: \"code_user_not_found\",\n\t})\n\tassert.Error(t, err)\n\n\tok, err := c.Refresh(ctx, fakeUserRefresh)\n\tassert.NoError(t, err)\n\tassert.True(t, ok)\n\tassert.Equal(t, \"2YotnFZFEjr1zCsicMWpAA\", fakeUserRefresh.AccessToken)\n\tassert.Equal(t, \"tGzv3JOkF0XG5Qx2TlKWIA\", fakeUserRefresh.RefreshToken)\n\n\tok, err = c.Refresh(ctx, fakeUserRefreshEmpty)\n\tassert.Error(t, err)\n\tassert.False(t, ok)\n\n\tok, err = c.Refresh(ctx, fakeUserRefreshFail)\n\tassert.Error(t, err)\n\tassert.False(t, ok)\n\n\trepo, err := c.Repo(ctx, fakeUser, \"\", fakeRepo.Owner, fakeRepo.Name)\n\tassert.NoError(t, err)\n\tassert.Equal(t, fakeRepo.FullName, repo.FullName)\n\n\t_, err = c.Repo(ctx, fakeUser, \"\", fakeRepoNotFound.Owner, fakeRepoNotFound.Name)\n\tassert.Error(t, err)\n\n\trepos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10})\n\tassert.NoError(t, err)\n\tassert.Len(t, repos, 1)\n\tassert.Equal(t, fakeRepo.FullName, repos[0].FullName)\n\n\t_, err = c.Repos(ctx, fakeUserNoTeams, &model.ListOptions{Page: 1, PerPage: 10})\n\tassert.Error(t, err)\n\n\t_, err = c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10})\n\tassert.Error(t, err)\n\n\tteams, err := c.Teams(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10})\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"test_name\", teams[0].Login)\n\tassert.Equal(t, \"https://bitbucket.org/workspaces/ueberdev42/avatar/?ts=1658761964\", teams[0].Avatar)\n\n\t_, err = c.Teams(ctx, fakeUserNoTeams, &model.ListOptions{Page: 1, PerPage: 10})\n\tassert.Error(t, err)\n\n\traw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, \"file\")\n\tassert.NoError(t, err)\n\tassert.True(t, len(raw) != 0)\n\n\t_, err = c.File(ctx, fakeUser, fakeRepo, fakePipeline, \"file_not_found\")\n\tassert.Error(t, err)\n\tassert.ErrorIs(t, err, &types.ErrConfigNotFound{})\n\n\tbranchHead, err := c.BranchHead(ctx, fakeUser, fakeRepo, \"branch_name\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"branch_head_name\", branchHead.SHA)\n\tassert.Equal(t, \"https://bitbucket.org/commitlink\", branchHead.ForgeURL)\n\n\t_, err = c.BranchHead(ctx, fakeUser, fakeRepo, \"branch_not_found\")\n\tassert.Error(t, err)\n\n\tlistOpts := model.ListOptions{\n\t\tAll:     false,\n\t\tPage:    1,\n\t\tPerPage: 10,\n\t}\n\n\trepoPRs, err := c.PullRequests(ctx, fakeUser, fakeRepo, &listOpts)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"PRs title\", repoPRs[0].Title)\n\tassert.Equal(t, model.ForgeRemoteID(\"123\"), repoPRs[0].Index)\n\n\t_, err = c.PullRequests(ctx, fakeUser, fakeRepoNotFound, &listOpts)\n\tassert.Error(t, err)\n\n\tfiles, err := c.Dir(ctx, fakeUser, fakeRepo, fakePipeline, \"dir\")\n\tassert.NoError(t, err)\n\tassert.Len(t, files, 3)\n\tassert.Equal(t, \"README.md\", files[0].Name)\n\tassert.Equal(t, \"dummy payload\", string(files[0].Data))\n\n\t_, err = c.Dir(ctx, fakeUser, fakeRepo, fakePipeline, \"dir_not_found\")\n\tassert.Error(t, err)\n\tassert.ErrorIs(t, err, &types.ErrConfigNotFound{})\n\n\terr = c.Activate(ctx, fakeUser, fakeRepo, \"%gh&%ij\")\n\tassert.Error(t, err)\n\n\terr = c.Activate(ctx, fakeUser, fakeRepo, \"http://127.0.0.1\")\n\tassert.NoError(t, err)\n\n\terr = c.Deactivate(ctx, fakeUser, fakeRepoNoHooks, \"http://127.0.0.1\")\n\tassert.Error(t, err)\n\n\terr = c.Deactivate(ctx, fakeUser, fakeRepo, \"http://127.0.0.1\")\n\tassert.NoError(t, err)\n\n\terr = c.Deactivate(ctx, fakeUser, fakeRepoEmptyHook, \"http://127.0.0.1\")\n\tassert.NoError(t, err)\n\n\thooks := []*internal.Hook{\n\t\t{URL: \"http://127.0.0.1/hook\"},\n\t}\n\thook := matchingHooks(hooks, \"http://127.0.0.1/\")\n\tassert.Equal(t, hooks[0], hook)\n\n\thooks = []*internal.Hook{\n\t\t{URL: \"http://localhost/hook\"},\n\t}\n\thook = matchingHooks(hooks, \"http://127.0.0.1/\")\n\tassert.Nil(t, hook)\n\n\thooks = nil\n\thook = matchingHooks(hooks, \"%gh&%ij\")\n\tassert.Nil(t, hook)\n\n\terr = c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow)\n\tassert.NoError(t, err)\n\n\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\treq.Header = http.Header{}\n\treq.Header.Set(hookEvent, hookPush)\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tctx = store.InjectToContext(ctx, mockStore)\n\tmockStore.On(\"GetUser\", mock.Anything).Return(fakeUser, nil)\n\tmockStore.On(\"GetRepoForgeID\", mock.Anything, mock.Anything).Return(fakeRepoFromHook, nil)\n\n\tr, b, err := c.Hook(ctx, req)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"martinherren1984/publictestrepo\", r.FullName)\n\tassert.Equal(t, \"master\", r.Branch)\n\tassert.Equal(t, \"c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\", b.Commit)\n\tassert.Equal(t, []string{\"main.go\"}, b.ChangedFiles)\n}\n\nvar (\n\tfakeUser = &model.User{\n\t\tLogin:       \"superman\",\n\t\tAccessToken: \"cfcd2084\",\n\t}\n\n\tfakeUserRefresh = &model.User{\n\t\tLogin:        \"superman\",\n\t\tRefreshToken: \"cfcd2084\",\n\t}\n\n\tfakeUserRefreshFail = &model.User{\n\t\tLogin:        \"superman\",\n\t\tRefreshToken: \"refresh_token_not_found\",\n\t}\n\n\tfakeUserRefreshEmpty = &model.User{\n\t\tLogin:        \"superman\",\n\t\tRefreshToken: \"refresh_token_is_empty\",\n\t}\n\n\tfakeUserNoTeams = &model.User{\n\t\tLogin:       \"superman\",\n\t\tAccessToken: \"teams_not_found\",\n\t}\n\n\tfakeUserNoRepos = &model.User{\n\t\tLogin:       \"superman\",\n\t\tAccessToken: \"repos_not_found\",\n\t}\n\n\tfakeRepo = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"repo_name\",\n\t\tFullName: \"test_name/repo_name\",\n\t}\n\n\tfakeRepoNotFound = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"repo_not_found\",\n\t\tFullName: \"test_name/repo_not_found\",\n\t}\n\n\tfakeRepoNoHooks = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"hooks_not_found\",\n\t\tFullName: \"test_name/hooks_not_found\",\n\t}\n\n\tfakeRepoEmptyHook = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"hook_empty\",\n\t\tFullName: \"test_name/hook_empty\",\n\t}\n\n\tfakeRepoFromHook = &model.Repo{\n\t\tOwner:    \"martinherren1984\",\n\t\tName:     \"publictestrepo\",\n\t\tFullName: \"martinherren1984/publictestrepo\",\n\t\tUserID:   1,\n\t}\n\n\tfakePipeline = &model.Pipeline{\n\t\tCommit: \"9ecad50\",\n\t}\n\n\tfakeWorkflow = &model.Workflow{\n\t\tName:  \"test\",\n\t\tState: model.StatusSuccess,\n\t}\n)\n"
  },
  {
    "path": "server/forge/bitbucket/convert.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\tstatusPending = \"INPROGRESS\" // cspell:disable-line\n\tstatusSuccess = \"SUCCESSFUL\"\n\tstatusFailure = \"FAILED\"\n)\n\n// convertStatus is a helper function used to convert a Woodpecker status to a\n// Bitbucket commit status.\nfunc convertStatus(status model.StatusValue) string {\n\tswitch status {\n\tcase model.StatusPending, model.StatusRunning, model.StatusBlocked:\n\t\treturn statusPending\n\tcase model.StatusSuccess:\n\t\treturn statusSuccess\n\tdefault:\n\t\treturn statusFailure\n\t}\n}\n\n// convertRepo is a helper function used to convert a Bitbucket repository\n// structure to the common Woodpecker repository structure.\nfunc convertRepo(from *internal.Repo, perm *internal.RepoPerm) *model.Repo {\n\trepo := model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(from.UUID),\n\t\tClone:         cloneLink(from),\n\t\tCloneSSH:      sshCloneLink(from),\n\t\tOwner:         strings.Split(from.FullName, \"/\")[0],\n\t\tName:          strings.Split(from.FullName, \"/\")[1],\n\t\tFullName:      from.FullName,\n\t\tForgeURL:      from.Links.HTML.Href,\n\t\tIsSCMPrivate:  from.IsPrivate,\n\t\tAvatar:        from.Owner.Links.Avatar.Href,\n\t\tBranch:        from.MainBranch.Name,\n\t\tPerm:          convertPerm(perm),\n\t\tPREnabled:     true,\n\t}\n\treturn &repo\n}\n\nfunc convertPerm(from *internal.RepoPerm) *model.Perm {\n\tperms := new(model.Perm)\n\tswitch from.Permission {\n\tcase \"admin\":\n\t\tperms.Admin = true\n\t\tfallthrough\n\tcase \"write\":\n\t\tperms.Push = true\n\t\tfallthrough\n\tdefault:\n\t\tperms.Pull = true\n\t}\n\treturn perms\n}\n\n// cloneLink is a helper function that tries to extract the clone url from the\n// repository object.\nfunc cloneLink(repo *internal.Repo) string {\n\tvar clone string\n\n\t// above we manually constructed the repository clone url. below we will\n\t// iterate through the list of clone links and attempt to instead use the\n\t// clone url provided by bitbucket.\n\tfor _, link := range repo.Links.Clone {\n\t\tif link.Name == \"https\" {\n\t\t\tclone = link.Href\n\t\t}\n\t}\n\n\t// if no repository name is provided, we use the Html link. this excludes the\n\t// .git suffix, but will still clone the repo.\n\tif len(clone) == 0 {\n\t\tclone = repo.Links.HTML.Href\n\t}\n\n\t// if bitbucket tries to automatically populate the user in the url we must\n\t// strip it out.\n\tcloneURL, err := url.Parse(clone)\n\tif err == nil {\n\t\tcloneURL.User = nil\n\t\tclone = cloneURL.String()\n\t}\n\n\treturn clone\n}\n\n// cloneLink is a helper function that tries to extract the clone url from the\n// repository object.\nfunc sshCloneLink(repo *internal.Repo) string {\n\tfor _, link := range repo.Links.Clone {\n\t\tif link.Name == \"ssh\" {\n\t\t\treturn link.Href\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// convertUser is a helper function used to convert a Bitbucket user account\n// structure to the Woodpecker User structure.\nfunc convertUser(from *internal.Account, token *oauth2.Token, email string) *model.User {\n\treturn &model.User{\n\t\tLogin:         from.Login,\n\t\tAccessToken:   token.AccessToken,\n\t\tRefreshToken:  token.RefreshToken,\n\t\tExpiry:        token.Expiry.UTC().Unix(),\n\t\tAvatar:        from.Links.Avatar.Href,\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.UUID)),\n\t\tEmail:         email,\n\t}\n}\n\n// convertWorkspaceList is a helper function used to convert a Bitbucket team list\n// structure to the Woodpecker Team structure.\nfunc convertWorkspaceList(from []*internal.Workspace) []*model.Team {\n\tvar teams []*model.Team\n\tfor _, workspace := range from {\n\t\tteams = append(teams, convertWorkspace(workspace))\n\t}\n\treturn teams\n}\n\n// convertWorkspace is a helper function used to convert a Bitbucket team account\n// structure to the Woodpecker Team structure.\nfunc convertWorkspace(from *internal.Workspace) *model.Team {\n\treturn &model.Team{\n\t\tLogin:  from.Slug,\n\t\tAvatar: from.Links.Avatar.Href,\n\t}\n}\n\n// convertPullHook is a helper function used to convert a Bitbucket pull request\n// hook to the Woodpecker pipeline struct holding commit information.\nfunc convertPullHook(from *internal.PullRequestHook) *model.Pipeline {\n\tevent := model.EventPull\n\tif from.PullRequest.State == stateClosed || from.PullRequest.State == stateDeclined {\n\t\tevent = model.EventPullClosed\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:  event,\n\t\tCommit: from.PullRequest.Source.Commit.Hash,\n\t\tRef:    fmt.Sprintf(\"refs/pull-requests/%d/from\", from.PullRequest.ID),\n\t\tRefspec: fmt.Sprintf(\"%s:%s\",\n\t\t\tfrom.PullRequest.Source.Branch.Name,\n\t\t\tfrom.PullRequest.Dest.Branch.Name,\n\t\t),\n\t\tForgeURL:  from.PullRequest.Links.HTML.Href,\n\t\tBranch:    from.PullRequest.Source.Branch.Name,\n\t\tMessage:   from.PullRequest.Title,\n\t\tAvatar:    from.Actor.Links.Avatar.Href,\n\t\tAuthor:    from.Actor.Login,\n\t\tSender:    from.Actor.Login,\n\t\tTimestamp: from.PullRequest.Updated.UTC().Unix(),\n\t\tFromFork:  from.PullRequest.Source.Repo.UUID != from.PullRequest.Dest.Repo.UUID,\n\t}\n\n\tif from.PullRequest.State == stateClosed {\n\t\tpipeline.Commit = from.PullRequest.MergeCommit.Hash\n\t\tpipeline.Ref = fmt.Sprintf(\"refs/heads/%s\", from.PullRequest.Dest.Branch.Name)\n\t\tpipeline.Branch = from.PullRequest.Dest.Branch.Name\n\t}\n\n\treturn pipeline\n}\n\n// convertPushHook is a helper function used to convert a Bitbucket push\n// hook to the Woodpecker pipeline struct holding commit information.\nfunc convertPushHook(hook *internal.PushHook, change *internal.Change) *model.Pipeline {\n\tpipeline := &model.Pipeline{\n\t\tCommit:    change.New.Target.Hash,\n\t\tForgeURL:  change.New.Target.Links.HTML.Href,\n\t\tBranch:    change.New.Name,\n\t\tMessage:   change.New.Target.Message,\n\t\tAvatar:    hook.Actor.Links.Avatar.Href,\n\t\tAuthor:    hook.Actor.Login,\n\t\tSender:    hook.Actor.Login,\n\t\tTimestamp: change.New.Target.Date.UTC().Unix(),\n\t}\n\tswitch change.New.Type {\n\tcase \"tag\", \"annotated_tag\", \"bookmark\":\n\t\tpipeline.Event = model.EventTag\n\t\tpipeline.Ref = fmt.Sprintf(\"refs/tags/%s\", change.New.Name)\n\tdefault:\n\t\tpipeline.Event = model.EventPush\n\t\tpipeline.Ref = fmt.Sprintf(\"refs/heads/%s\", change.New.Name)\n\t}\n\tif len(change.New.Target.Author.Raw) != 0 {\n\t\tpipeline.Email = extractEmail(change.New.Target.Author.Raw)\n\t}\n\treturn pipeline\n}\n\n// regex for git author fields (r.g. \"name <name@mail.tld>\").\nvar reGitMail = regexp.MustCompile(\"<(.*)>\")\n\n// extracts the email from a git commit author string.\nfunc extractEmail(gitAuthor string) (author string) {\n\tmatches := reGitMail.FindAllStringSubmatch(gitAuthor, -1)\n\tif len(matches) == 1 {\n\t\tauthor = matches[0][1]\n\t}\n\treturn author\n}\n"
  },
  {
    "path": "server/forge/bitbucket/convert_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_convertStatus(t *testing.T) {\n\tassert.Equal(t, statusSuccess, convertStatus(model.StatusSuccess))\n\tassert.Equal(t, statusPending, convertStatus(model.StatusPending))\n\tassert.Equal(t, statusPending, convertStatus(model.StatusRunning))\n\tassert.Equal(t, statusFailure, convertStatus(model.StatusFailure))\n\tassert.Equal(t, statusFailure, convertStatus(model.StatusKilled))\n\tassert.Equal(t, statusFailure, convertStatus(model.StatusError))\n}\n\nfunc Test_convertRepo(t *testing.T) {\n\tfrom := &internal.Repo{\n\t\tFullName:  \"octocat/hello-world\",\n\t\tIsPrivate: true,\n\t\tScm:       \"git\",\n\t}\n\tfrom.Owner.Links.Avatar.Href = \"http://...\"\n\tfrom.Links.HTML.Href = \"https://bitbucket.org/foo/bar\"\n\tfrom.MainBranch.Name = \"default\"\n\tfromPerm := &internal.RepoPerm{\n\t\tPermission: \"write\",\n\t}\n\n\tto := convertRepo(from, fromPerm)\n\tassert.Equal(t, from.Owner.Links.Avatar.Href, to.Avatar)\n\tassert.Equal(t, from.FullName, to.FullName)\n\tassert.Equal(t, \"octocat\", to.Owner)\n\tassert.Equal(t, \"hello-world\", to.Name)\n\tassert.Equal(t, \"default\", to.Branch)\n\tassert.Equal(t, from.IsPrivate, to.IsSCMPrivate)\n\tassert.Equal(t, from.Links.HTML.Href, to.Clone)\n\tassert.Equal(t, from.Links.HTML.Href, to.ForgeURL)\n\tassert.True(t, to.Perm.Push)\n\tassert.False(t, to.Perm.Admin)\n}\n\nfunc Test_convertWorkspace(t *testing.T) {\n\tfrom := &internal.Workspace{Slug: \"octocat\"}\n\tfrom.Links.Avatar.Href = \"http://...\"\n\tto := convertWorkspace(from)\n\tassert.Equal(t, from.Links.Avatar.Href, to.Avatar)\n\tassert.Equal(t, from.Slug, to.Login)\n}\n\nfunc Test_convertWorkspaceList(t *testing.T) {\n\tfrom := &internal.Workspace{Slug: \"octocat\"}\n\tfrom.Links.Avatar.Href = \"http://...\"\n\tto := convertWorkspaceList([]*internal.Workspace{from})\n\tassert.Equal(t, from.Links.Avatar.Href, to[0].Avatar)\n\tassert.Equal(t, from.Slug, to[0].Login)\n}\n\nfunc Test_convertUser(t *testing.T) {\n\ttoken := &oauth2.Token{\n\t\tAccessToken:  \"foo\",\n\t\tRefreshToken: \"bar\",\n\t\tExpiry:       time.Now(),\n\t}\n\tuser := &internal.Account{Login: \"octocat\"}\n\tuser.Links.Avatar.Href = \"http://...\"\n\n\tresult := convertUser(user, token, \"test@example.com\")\n\tassert.Equal(t, user.Links.Avatar.Href, result.Avatar)\n\tassert.Equal(t, user.Login, result.Login)\n\tassert.Equal(t, \"test@example.com\", result.Email)\n\tassert.Equal(t, token.AccessToken, result.AccessToken)\n\tassert.Equal(t, token.RefreshToken, result.RefreshToken)\n\tassert.Equal(t, token.Expiry.UTC().Unix(), result.Expiry)\n}\n\nfunc Test_cloneLink(t *testing.T) {\n\trepo := &internal.Repo{}\n\trepo.Links.Clone = append(repo.Links.Clone, internal.Link{\n\t\tName: \"https\",\n\t\tHref: \"https://bitbucket.org/foo/bar.git\",\n\t})\n\tlink := cloneLink(repo)\n\tassert.Equal(t, repo.Links.Clone[0].Href, link)\n\n\trepo = &internal.Repo{}\n\trepo.Links.HTML.Href = \"https://foo:bar@bitbucket.org/foo/bar.git\"\n\tlink = cloneLink(repo)\n\tassert.Equal(t, \"https://bitbucket.org/foo/bar.git\", link)\n}\n\nfunc Test_convertPullHook(t *testing.T) {\n\thook := &internal.PullRequestHook{}\n\thook.Actor.Login = \"octocat\"\n\thook.Actor.Links.Avatar.Href = \"https://...\"\n\thook.PullRequest.Dest.Commit.Hash = \"73f9c44d\"\n\thook.PullRequest.Dest.Branch.Name = \"main\"\n\thook.PullRequest.Dest.Repo.Links.HTML.Href = \"https://bitbucket.org/foo/bar\"\n\thook.PullRequest.Source.Branch.Name = \"change\"\n\thook.PullRequest.Source.Repo.FullName = \"baz/bar\"\n\thook.PullRequest.Source.Commit.Hash = \"c8411d7\"\n\thook.PullRequest.Links.HTML.Href = \"https://bitbucket.org/foo/bar/pulls/5\"\n\thook.PullRequest.Title = \"updated README\"\n\thook.PullRequest.Updated = time.Now()\n\thook.PullRequest.ID = 1\n\n\tpipeline := convertPullHook(hook)\n\tassert.Equal(t, model.EventPull, pipeline.Event)\n\tassert.Equal(t, hook.Actor.Login, pipeline.Author)\n\tassert.Equal(t, hook.Actor.Links.Avatar.Href, pipeline.Avatar)\n\tassert.Equal(t, hook.PullRequest.Source.Commit.Hash, pipeline.Commit)\n\tassert.Equal(t, hook.PullRequest.Source.Branch.Name, pipeline.Branch)\n\tassert.Equal(t, hook.PullRequest.Links.HTML.Href, pipeline.ForgeURL)\n\tassert.Equal(t, \"refs/pull-requests/1/from\", pipeline.Ref)\n\tassert.Equal(t, \"change:main\", pipeline.Refspec)\n\tassert.Equal(t, hook.PullRequest.Title, pipeline.Message)\n\tassert.Equal(t, hook.PullRequest.Updated.Unix(), pipeline.Timestamp)\n}\n\nfunc Test_convertPushHook(t *testing.T) {\n\tchange := internal.Change{}\n\tchange.New.Target.Hash = \"73f9c44d\"\n\tchange.New.Name = \"main\"\n\tchange.New.Target.Links.HTML.Href = \"https://bitbucket.org/foo/bar/commits/73f9c44d\"\n\tchange.New.Target.Message = \"updated README\"\n\tchange.New.Target.Date = time.Now()\n\tchange.New.Target.Author.Raw = \"Test <test@domain.tld>\"\n\n\thook := internal.PushHook{}\n\thook.Actor.Login = \"octocat\"\n\thook.Actor.Links.Avatar.Href = \"https://...\"\n\n\tpipeline := convertPushHook(&hook, &change)\n\tassert.Equal(t, model.EventPush, pipeline.Event)\n\tassert.Equal(t, \"test@domain.tld\", pipeline.Email)\n\tassert.Equal(t, hook.Actor.Login, pipeline.Author)\n\tassert.Equal(t, hook.Actor.Links.Avatar.Href, pipeline.Avatar)\n\tassert.Equal(t, change.New.Target.Hash, pipeline.Commit)\n\tassert.Equal(t, change.New.Name, pipeline.Branch)\n\tassert.Equal(t, change.New.Target.Links.HTML.Href, pipeline.ForgeURL)\n\tassert.Equal(t, \"refs/heads/main\", pipeline.Ref)\n\tassert.Equal(t, change.New.Target.Message, pipeline.Message)\n\tassert.Equal(t, change.New.Target.Date.Unix(), pipeline.Timestamp)\n}\n\nfunc Test_convertPushHookTag(t *testing.T) {\n\tchange := internal.Change{}\n\tchange.New.Name = \"v1.0.0\"\n\tchange.New.Type = \"tag\"\n\n\thook := internal.PushHook{}\n\n\tpipeline := convertPushHook(&hook, &change)\n\tassert.Equal(t, model.EventTag, pipeline.Event)\n\tassert.Equal(t, \"refs/tags/v1.0.0\", pipeline.Ref)\n}\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/HookPull.json",
    "content": "{\n  \"actor\": {\n    \"username\": \"emmap1\",\n    \"links\": {\n      \"avatar\": {\n        \"href\": \"https://bitbucket-api-assetroot.s3.amazonaws.com/c/photos/2015/Feb/26/3613917261-0-emmap1-avatar_avatar.png\"\n      }\n    }\n  },\n  \"pullrequest\": {\n    \"id\": 1,\n    \"title\": \"Title of pull request\",\n    \"description\": \"Description of pull request\",\n    \"state\": \"OPEN\",\n    \"author\": {\n      \"username\": \"emmap1\",\n      \"links\": {\n        \"avatar\": {\n          \"href\": \"https://bitbucket-api-assetroot.s3.amazonaws.com/c/photos/2015/Feb/26/3613917261-0-emmap1-avatar_avatar.png\"\n        }\n      }\n    },\n    \"source\": {\n      \"branch\": {\n        \"name\": \"branch2\"\n      },\n      \"commit\": {\n        \"hash\": \"d3022fc0ca3d\"\n      },\n      \"repository\": {\n        \"links\": {\n          \"html\": {\n            \"href\": \"https://api.bitbucket.org/team_name/repo_name\"\n          },\n          \"avatar\": {\n            \"href\": \"https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png\"\n          }\n        },\n        \"full_name\": \"user_name/repo_name\",\n        \"scm\": \"git\",\n        \"is_private\": true\n      }\n    },\n    \"destination\": {\n      \"branch\": {\n        \"name\": \"main\"\n      },\n      \"commit\": {\n        \"hash\": \"ce5965ddd289\"\n      },\n      \"repository\": {\n        \"links\": {\n          \"html\": {\n            \"href\": \"https://api.bitbucket.org/team_name/repo_name\"\n          },\n          \"avatar\": {\n            \"href\": \"https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png\"\n          }\n        },\n        \"full_name\": \"user_name/repo_name\",\n        \"scm\": \"git\",\n        \"is_private\": true\n      }\n    },\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/api/2.0/pullrequests/pullrequest_id\"\n      },\n      \"html\": {\n        \"href\": \"https://api.bitbucket.org/pullrequest_id\"\n      }\n    }\n  },\n  \"repository\": {\n    \"links\": {\n      \"html\": {\n        \"href\": \"https://api.bitbucket.org/team_name/repo_name\"\n      },\n      \"avatar\": {\n        \"href\": \"https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png\"\n      }\n    },\n    \"full_name\": \"user_name/repo_name\",\n    \"scm\": \"git\",\n    \"is_private\": true\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/HookPullRequestDeclined.json",
    "content": "{\n  \"repository\": {\n    \"type\": \"repository\",\n    \"full_name\": \"anbraten/test-2\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/anbraten/test-2\"\n      },\n      \"avatar\": {\n        \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n      }\n    },\n    \"name\": \"test-2\",\n    \"scm\": \"git\",\n    \"website\": null,\n    \"owner\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"workspace\": {\n      \"type\": \"workspace\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"name\": \"Anbraten\",\n      \"slug\": \"anbraten\",\n      \"links\": {\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/workspaces/anbraten/avatar/?ts=1651865281\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/anbraten/\"\n        },\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/anbraten\"\n        }\n      }\n    },\n    \"is_private\": true,\n    \"project\": {\n      \"type\": \"project\",\n      \"key\": \"TEST\",\n      \"uuid\": \"{3fa6429f-95e1-4c5a-875c-1753abcd8ace}\",\n      \"name\": \"test\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/anbraten/projects/TEST\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/anbraten/workspace/projects/TEST\"\n        },\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/account/user/anbraten/projects/TEST/avatar/32?ts=1690725373\"\n        }\n      }\n    },\n    \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\",\n    \"parent\": null\n  },\n  \"actor\": {\n    \"display_name\": \"Anbraten\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n      },\n      \"avatar\": {\n        \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n      }\n    },\n    \"type\": \"user\",\n    \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n    \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n    \"nickname\": \"Anbraten\"\n  },\n  \"pullrequest\": {\n    \"comment_count\": 0,\n    \"task_count\": 0,\n    \"type\": \"pullrequest\",\n    \"id\": 2,\n    \"title\": \"CHANGELOG.md created online with Bitbucket\",\n    \"description\": \"CHANGELOG.md created online with Bitbucket\",\n    \"rendered\": {\n      \"title\": {\n        \"type\": \"rendered\",\n        \"raw\": \"CHANGELOG.md created online with Bitbucket\",\n        \"markup\": \"markdown\",\n        \"html\": \"<p>CHANGELOG.md created online with Bitbucket</p>\"\n      },\n      \"description\": {\n        \"type\": \"rendered\",\n        \"raw\": \"CHANGELOG.md created online with Bitbucket\",\n        \"markup\": \"markdown\",\n        \"html\": \"<p>CHANGELOG.md created online with Bitbucket</p>\"\n      }\n    },\n    \"state\": \"DECLINED\",\n    \"merge_commit\": null,\n    \"close_source_branch\": false,\n    \"closed_by\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"author\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"reason\": \"\",\n    \"created_on\": \"2023-12-05T18:36:27.667680+00:00\",\n    \"updated_on\": \"2023-12-05T18:36:57.260672+00:00\",\n    \"destination\": {\n      \"branch\": {\n        \"name\": \"main\"\n      },\n      \"commit\": {\n        \"type\": \"commit\",\n        \"hash\": \"006704dbeab2\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/006704dbeab2\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2/commits/006704dbeab2\"\n          }\n        }\n      },\n      \"repository\": {\n        \"type\": \"repository\",\n        \"full_name\": \"anbraten/test-2\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2\"\n          },\n          \"avatar\": {\n            \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n          }\n        },\n        \"name\": \"test-2\",\n        \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\"\n      }\n    },\n    \"source\": {\n      \"branch\": {\n        \"name\": \"patch-2\"\n      },\n      \"commit\": {\n        \"type\": \"commit\",\n        \"hash\": \"f90e18fc9d45\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/f90e18fc9d45\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2/commits/f90e18fc9d45\"\n          }\n        }\n      },\n      \"repository\": {\n        \"type\": \"repository\",\n        \"full_name\": \"anbraten/test-2\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2\"\n          },\n          \"avatar\": {\n            \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n          }\n        },\n        \"name\": \"test-2\",\n        \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\"\n      }\n    },\n    \"reviewers\": [],\n    \"participants\": [],\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/anbraten/test-2/pull-requests/2\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/commits\"\n      },\n      \"approve\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/approve\"\n      },\n      \"request-changes\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/request-changes\"\n      },\n      \"diff\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diff/anbraten/test-2:f90e18fc9d45%0D006704dbeab2?from_pullrequest_id=2&topic=true\"\n      },\n      \"diffstat\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diffstat/anbraten/test-2:f90e18fc9d45%0D006704dbeab2?from_pullrequest_id=2&topic=true\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/comments\"\n      },\n      \"activity\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/activity\"\n      },\n      \"merge\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/merge\"\n      },\n      \"decline\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/decline\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/statuses\"\n      }\n    },\n    \"summary\": {\n      \"type\": \"rendered\",\n      \"raw\": \"CHANGELOG.md created online with Bitbucket\",\n      \"markup\": \"markdown\",\n      \"html\": \"<p>CHANGELOG.md created online with Bitbucket</p>\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"repository\": {\n    \"type\": \"repository\",\n    \"full_name\": \"anbraten/test-2\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/anbraten/test-2\"\n      },\n      \"avatar\": {\n        \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n      }\n    },\n    \"name\": \"test-2\",\n    \"scm\": \"git\",\n    \"website\": null,\n    \"owner\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"workspace\": {\n      \"type\": \"workspace\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"name\": \"Anbraten\",\n      \"slug\": \"anbraten\",\n      \"links\": {\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/workspaces/anbraten/avatar/?ts=1651865281\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/anbraten/\"\n        },\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/anbraten\"\n        }\n      }\n    },\n    \"is_private\": true,\n    \"project\": {\n      \"type\": \"project\",\n      \"key\": \"TEST\",\n      \"uuid\": \"{3fa6429f-95e1-4c5a-875c-1753abcd8ace}\",\n      \"name\": \"test\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/anbraten/projects/TEST\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/anbraten/workspace/projects/TEST\"\n        },\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/account/user/anbraten/projects/TEST/avatar/32?ts=1690725373\"\n        }\n      }\n    },\n    \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\",\n    \"parent\": null\n  },\n  \"actor\": {\n    \"display_name\": \"Anbraten\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n      },\n      \"avatar\": {\n        \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n      }\n    },\n    \"type\": \"user\",\n    \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n    \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n    \"nickname\": \"Anbraten\"\n  },\n  \"pullrequest\": {\n    \"comment_count\": 0,\n    \"task_count\": 0,\n    \"type\": \"pullrequest\",\n    \"id\": 1,\n    \"title\": \"README.md created online with Bitbucket\",\n    \"description\": \"README.md created online with Bitbucket\",\n    \"rendered\": {\n      \"title\": {\n        \"type\": \"rendered\",\n        \"raw\": \"README.md created online with Bitbucket\",\n        \"markup\": \"markdown\",\n        \"html\": \"<p>README.md created online with Bitbucket</p>\"\n      },\n      \"description\": {\n        \"type\": \"rendered\",\n        \"raw\": \"README.md created online with Bitbucket\",\n        \"markup\": \"markdown\",\n        \"html\": \"<p>README.md created online with Bitbucket</p>\"\n      }\n    },\n    \"state\": \"MERGED\",\n    \"merge_commit\": {\n      \"type\": \"commit\",\n      \"hash\": \"006704dbeab2\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/006704dbeab2\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/anbraten/test-2/commits/006704dbeab2\"\n        }\n      }\n    },\n    \"close_source_branch\": true,\n    \"closed_by\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"author\": {\n      \"display_name\": \"Anbraten\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n      \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n      \"nickname\": \"Anbraten\"\n    },\n    \"reason\": \"\",\n    \"created_on\": \"2023-12-05T18:28:16.861881+00:00\",\n    \"updated_on\": \"2023-12-05T18:29:44.785393+00:00\",\n    \"destination\": {\n      \"branch\": {\n        \"name\": \"main\"\n      },\n      \"commit\": {\n        \"type\": \"commit\",\n        \"hash\": \"6c5f0bc9b2aa\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/6c5f0bc9b2aa\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2/commits/6c5f0bc9b2aa\"\n          }\n        }\n      },\n      \"repository\": {\n        \"type\": \"repository\",\n        \"full_name\": \"anbraten/test-2\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2\"\n          },\n          \"avatar\": {\n            \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n          }\n        },\n        \"name\": \"test-2\",\n        \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\"\n      }\n    },\n    \"source\": {\n      \"branch\": {\n        \"name\": \"patch-2\"\n      },\n      \"commit\": {\n        \"type\": \"commit\",\n        \"hash\": \"668218c13e04\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/668218c13e04\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2/commits/668218c13e04\"\n          }\n        }\n      },\n      \"repository\": {\n        \"type\": \"repository\",\n        \"full_name\": \"anbraten/test-2\",\n        \"links\": {\n          \"self\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/anbraten/test-2\"\n          },\n          \"avatar\": {\n            \"href\": \"https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default\"\n          }\n        },\n        \"name\": \"test-2\",\n        \"uuid\": \"{26554729-595f-47d1-aedd-302625cb4a97}\"\n      }\n    },\n    \"reviewers\": [],\n    \"participants\": [\n      {\n        \"type\": \"participant\",\n        \"user\": {\n          \"display_name\": \"Anbraten\",\n          \"links\": {\n            \"self\": {\n              \"href\": \"https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D\"\n            },\n            \"avatar\": {\n              \"href\": \"https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128\"\n            },\n            \"html\": {\n              \"href\": \"https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/\"\n            }\n          },\n          \"type\": \"user\",\n          \"uuid\": \"{b1b7beef-77ca-452d-b059-fa092504ebd7}\",\n          \"account_id\": \"70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e\",\n          \"nickname\": \"Anbraten\"\n        },\n        \"role\": \"PARTICIPANT\",\n        \"approved\": true,\n        \"state\": \"approved\",\n        \"participated_on\": \"2023-12-05T18:29:25.611876+00:00\"\n      }\n    ],\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/anbraten/test-2/pull-requests/1\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/commits\"\n      },\n      \"approve\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/approve\"\n      },\n      \"request-changes\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/request-changes\"\n      },\n      \"diff\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diff/anbraten/test-2:668218c13e04%0D6c5f0bc9b2aa?from_pullrequest_id=1&topic=true\"\n      },\n      \"diffstat\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diffstat/anbraten/test-2:668218c13e04%0D6c5f0bc9b2aa?from_pullrequest_id=1&topic=true\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/comments\"\n      },\n      \"activity\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/activity\"\n      },\n      \"merge\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/merge\"\n      },\n      \"decline\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/decline\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/statuses\"\n      }\n    },\n    \"summary\": {\n      \"type\": \"rendered\",\n      \"raw\": \"README.md created online with Bitbucket\",\n      \"markup\": \"markdown\",\n      \"html\": \"<p>README.md created online with Bitbucket</p>\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/HookPush.json",
    "content": "{\n  \"actor\": {\n    \"display_name\": \"Martin Herren\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D\"\n      },\n      \"avatar\": {\n        \"href\": \"https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/\"\n      }\n    },\n    \"type\": \"user\",\n    \"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n    \"account_id\": \"5cf8e3a9678ca90f8e7cc8a8\",\n    \"nickname\": \"Martin Herren\"\n  },\n  \"repository\": {\n    \"type\": \"repository\",\n    \"full_name\": \"martinherren1984/publictestrepo\",\n    \"links\": {\n      \"self\": {\n        \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo\"\n      },\n      \"html\": {\n        \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo\"\n      },\n      \"avatar\": {\n        \"href\": \"https://bytebucket.org/ravatar/%7B898477b2-a080-4089-b385-597a783db392%7D?ts=default\"\n      }\n    },\n    \"name\": \"PublicTestRepo\",\n    \"scm\": \"git\",\n    \"website\": null,\n    \"owner\": {\n      \"display_name\": \"Martin Herren\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D\"\n        },\n        \"avatar\": {\n          \"href\": \"https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/\"\n        }\n      },\n      \"type\": \"user\",\n      \"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n      \"account_id\": \"5cf8e3a9678ca90f8e7cc8a8\",\n      \"nickname\": \"Martin Herren\"\n    },\n    \"workspace\": {\n      \"type\": \"workspace\",\n      \"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n      \"name\": \"Martin Herren\",\n      \"slug\": \"martinherren1984\",\n      \"links\": {\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/workspaces/martinherren1984/avatar/?ts=1658761964\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/martinherren1984/\"\n        },\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/martinherren1984\"\n        }\n      }\n    },\n    \"is_private\": false,\n    \"project\": {\n      \"type\": \"project\",\n      \"key\": \"PUB\",\n      \"uuid\": \"{2cede481-f59e-49ec-88d0-a85629b7925d}\",\n      \"name\": \"PublicTestProject\",\n      \"links\": {\n        \"self\": {\n          \"href\": \"https://api.bitbucket.org/2.0/workspaces/martinherren1984/projects/PUB\"\n        },\n        \"html\": {\n          \"href\": \"https://bitbucket.org/martinherren1984/workspace/projects/PUB\"\n        },\n        \"avatar\": {\n          \"href\": \"https://bitbucket.org/account/user/martinherren1984/projects/PUB/avatar/32?ts=1658768453\"\n        }\n      }\n    },\n    \"uuid\": \"{898477b2-a080-4089-b385-597a783db392}\"\n  },\n  \"push\": {\n    \"changes\": [\n      {\n        \"old\": {\n          \"name\": \"main\",\n          \"target\": {\n            \"type\": \"commit\",\n            \"hash\": \"a51241ae1f00cbe728930db48e890b18fd527f99\",\n            \"date\": \"2022-08-17T15:24:29+00:00\",\n            \"author\": {\n              \"type\": \"author\",\n              \"raw\": \"Martin Herren <martin.herren@xxx.com>\",\n              \"user\": {\n                \"display_name\": \"Martin Herren\",\n                \"links\": {\n                  \"self\": {\n                    \"href\": \"https://api.bitbucket.org/2.0/users/%7B69cc59f2-706b-4a9c-b99c-eac2ace320da%7D\"\n                  },\n                  \"avatar\": {\n                    \"href\": \"https://secure.gravatar.com/avatar/7b2e50690b4ab7bb9e1db18ea3b8ae95?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-5.png\"\n                  },\n                  \"html\": {\n                    \"href\": \"https://bitbucket.org/%7B69cc59f2-706b-4a9c-b99c-eac2ace320da%7D/\"\n                  }\n                },\n                \"type\": \"user\",\n                \"uuid\": \"{69cc59f2-706b-4a9c-b99c-eac2ace320da}\",\n                \"account_id\": \"5d286e857133f10c17e026cb\",\n                \"nickname\": \"Martin Herren\"\n              }\n            },\n            \"message\": \"Add test .woodpecker.yml\\n\",\n            \"summary\": {\n              \"type\": \"rendered\",\n              \"raw\": \"Add test .woodpecker.yml\\n\",\n              \"markup\": \"markdown\",\n              \"html\": \"<p>Add test .woodpecker.yml</p>\"\n            },\n            \"links\": {\n              \"self\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99\"\n              },\n              \"html\": {\n                \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99\"\n              }\n            },\n            \"parents\": [],\n            \"rendered\": {},\n            \"properties\": {}\n          },\n          \"links\": {\n            \"self\": {\n              \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches/main\"\n            },\n            \"commits\": {\n              \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits/main\"\n            },\n            \"html\": {\n              \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/branch/main\"\n            }\n          },\n          \"type\": \"branch\",\n          \"merge_strategies\": [\"merge_commit\", \"squash\", \"fast_forward\"],\n          \"default_merge_strategy\": \"merge_commit\"\n        },\n        \"new\": {\n          \"name\": \"main\",\n          \"target\": {\n            \"type\": \"commit\",\n            \"hash\": \"c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\",\n            \"date\": \"2022-09-07T20:19:25+00:00\",\n            \"author\": {\n              \"type\": \"author\",\n              \"raw\": \"Martin Herren <martin.herren@yyy.com>\",\n              \"user\": {\n                \"display_name\": \"Martin Herren\",\n                \"links\": {\n                  \"self\": {\n                    \"href\": \"https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D\"\n                  },\n                  \"avatar\": {\n                    \"href\": \"https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png\"\n                  },\n                  \"html\": {\n                    \"href\": \"https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/\"\n                  }\n                },\n                \"type\": \"user\",\n                \"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n                \"account_id\": \"5cf8e3a9678ca90f8e7cc8a8\",\n                \"nickname\": \"Martin Herren\"\n              }\n            },\n            \"message\": \"a\\n\",\n            \"summary\": {\n              \"type\": \"rendered\",\n              \"raw\": \"a\\n\",\n              \"markup\": \"markdown\",\n              \"html\": \"<p>a</p>\"\n            },\n            \"links\": {\n              \"self\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              },\n              \"html\": {\n                \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/commits/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              }\n            },\n            \"parents\": [\n              {\n                \"type\": \"commit\",\n                \"hash\": \"a51241ae1f00cbe728930db48e890b18fd527f99\",\n                \"links\": {\n                  \"self\": {\n                    \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99\"\n                  },\n                  \"html\": {\n                    \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99\"\n                  }\n                }\n              }\n            ],\n            \"rendered\": {},\n            \"properties\": {}\n          },\n          \"links\": {\n            \"self\": {\n              \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches/main\"\n            },\n            \"commits\": {\n              \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits/main\"\n            },\n            \"html\": {\n              \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/branch/main\"\n            }\n          },\n          \"type\": \"branch\",\n          \"merge_strategies\": [\"merge_commit\", \"squash\", \"fast_forward\"],\n          \"default_merge_strategy\": \"merge_commit\"\n        },\n        \"truncated\": false,\n        \"created\": false,\n        \"forced\": false,\n        \"closed\": false,\n        \"links\": {\n          \"commits\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits?include=c14c1bb05dfb1fdcdf06b31485fff61b0ea44277&exclude=a51241ae1f00cbe728930db48e890b18fd527f99\"\n          },\n          \"diff\": {\n            \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/diff/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277..a51241ae1f00cbe728930db48e890b18fd527f99\"\n          },\n          \"html\": {\n            \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/branches/compare/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277..a51241ae1f00cbe728930db48e890b18fd527f99\"\n          }\n        },\n        \"commits\": [\n          {\n            \"type\": \"commit\",\n            \"hash\": \"c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\",\n            \"date\": \"2022-09-07T20:19:25+00:00\",\n            \"author\": {\n              \"type\": \"author\",\n              \"raw\": \"Martin Herren <martin.herren@yyy.com>\",\n              \"user\": {\n                \"display_name\": \"Martin Herren\",\n                \"links\": {\n                  \"self\": {\n                    \"href\": \"https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D\"\n                  },\n                  \"avatar\": {\n                    \"href\": \"https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png\"\n                  },\n                  \"html\": {\n                    \"href\": \"https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/\"\n                  }\n                },\n                \"type\": \"user\",\n                \"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n                \"account_id\": \"5cf8e3a9678ca90f8e7cc8a8\",\n                \"nickname\": \"Martin Herren\"\n              }\n            },\n            \"message\": \"a\\n\",\n            \"summary\": {\n              \"type\": \"rendered\",\n              \"raw\": \"a\\n\",\n              \"markup\": \"markdown\",\n              \"html\": \"<p>a</p>\"\n            },\n            \"links\": {\n              \"self\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              },\n              \"html\": {\n                \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/commits/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              },\n              \"diff\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/diff/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              },\n              \"approve\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/approve\"\n              },\n              \"comments\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/comments\"\n              },\n              \"statuses\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/statuses\"\n              },\n              \"patch\": {\n                \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/patch/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\"\n              }\n            },\n            \"parents\": [\n              {\n                \"type\": \"commit\",\n                \"hash\": \"a51241ae1f00cbe728930db48e890b18fd527f99\",\n                \"links\": {\n                  \"self\": {\n                    \"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99\"\n                  },\n                  \"html\": {\n                    \"href\": \"https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99\"\n                  }\n                }\n              }\n            ],\n            \"rendered\": {},\n            \"properties\": {}\n          }\n        ]\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/handler.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler returns an http.Handler that is capable of handling a variety of mock\n// Bitbucket requests and returning mock responses.\nfunc Handler() http.Handler {\n\tgin.SetMode(gin.TestMode)\n\n\te := gin.New()\n\te.POST(\"/site/oauth2/access_token\", getOauth)\n\te.GET(\"/2.0/user/workspaces/\", getWorkspaces)\n\te.GET(\"/2.0/repositories/:owner/:name\", getRepo)\n\te.GET(\"/2.0/repositories/:owner/:name/hooks\", getRepoHooks)\n\te.GET(\"/2.0/repositories/:owner/:name/src/:commit/:file\", getRepoFile)\n\te.DELETE(\"/2.0/repositories/:owner/:name/hooks/:hook\", deleteRepoHook)\n\te.POST(\"/2.0/repositories/:owner/:name/hooks\", createRepoHook)\n\te.POST(\"/2.0/repositories/:owner/:name/commit/:commit/statuses/build\", createRepoStatus)\n\te.GET(\"/2.0/repositories/:owner\", getUserRepos)\n\te.GET(\"/2.0/user/\", getUser)\n\te.GET(\"/2.0/user/emails\", getEmails)\n\te.GET(\"/2.0/user/workspaces/:workspace/permissions/repositories\", getPermissions)\n\te.GET(\"/2.0/repositories/:owner/:name/commits/:commit\", getBranchHead)\n\te.GET(\"/2.0/repositories/:owner/:name/pullrequests\", getPullRequests)\n\te.GET(\"/2.0/repositories/:owner/:name/diffstat/:commit\", getCommitDiffstat)\n\treturn e\n}\n\nfunc getCommitDiffstat(c *gin.Context) {\n\tc.String(http.StatusOK, diffStatPayload)\n}\n\nfunc getOauth(c *gin.Context) {\n\tif c.PostForm(\"error\") == \"invalid_scope\" {\n\t\tc.String(http.StatusInternalServerError, \"\")\n\t}\n\n\tswitch c.PostForm(\"code\") {\n\tcase \"code_bad_request\":\n\t\tc.String(http.StatusInternalServerError, \"\")\n\t\treturn\n\tcase \"code_user_not_found\":\n\t\tc.String(http.StatusOK, tokenNotFoundPayload)\n\t\treturn\n\t}\n\tswitch c.PostForm(\"refresh_token\") {\n\tcase \"refresh_token_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tcase \"refresh_token_is_empty\":\n\t\tc.Header(\"Content-Type\", \"application/json\")\n\t\tc.String(http.StatusOK, \"{}\")\n\tdefault:\n\t\tc.Header(\"Content-Type\", \"application/json\")\n\t\tc.String(http.StatusOK, tokenPayload)\n\t}\n}\n\nfunc getWorkspaces(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"Bearer teams_not_found\", \"Bearer c81e728d\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tif c.Query(\"page\") == \"\" || c.Query(\"page\") == \"1\" {\n\t\t\tc.String(http.StatusOK, workspacesPayload)\n\t\t} else {\n\t\t\tc.String(http.StatusOK, \"{\\\"values\\\":[]}\")\n\t\t}\n\t}\n}\n\nfunc getRepo(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"not_found\", \"repo_unknown\", \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tcase \"permission_read\", \"permission_write\", \"permission_admin\":\n\t\tc.String(http.StatusOK, fmt.Sprintf(permissionRepoPayload, c.Param(\"name\")))\n\tcase \"{898477b2-a080-4089-b385-597a783db392}\":\n\t\tc.String(http.StatusOK, repoPayloadFromHook)\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc getRepoHooks(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"hooks_not_found\", \"repo_no_hooks\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tcase \"hook_empty\":\n\t\tc.String(http.StatusOK, \"{}\")\n\tdefault:\n\t\tif c.Query(\"page\") == \"\" || c.Query(\"page\") == \"1\" {\n\t\t\tc.String(http.StatusOK, repoHookPayload)\n\t\t} else {\n\t\t\tc.String(http.StatusOK, \"{\\\"values\\\":[]}\")\n\t\t}\n\t}\n}\n\nfunc getRepoFile(c *gin.Context) {\n\tswitch c.Param(\"file\") {\n\tcase \"dir\":\n\t\tc.String(http.StatusOK, repoDirPayload)\n\tcase \"dir_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tcase \"file_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoFilePayload)\n\t}\n}\n\nfunc getBranchHead(c *gin.Context) {\n\tswitch c.Param(\"commit\") {\n\tcase \"branch_name\":\n\t\tc.String(http.StatusOK, branchCommitsPayload)\n\tdefault:\n\t\tc.String(http.StatusNotFound, \"\")\n\t}\n}\n\nfunc getPullRequests(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"repo_name\":\n\t\tc.String(http.StatusOK, pullRequestsPayload)\n\tdefault:\n\t\tc.String(http.StatusNotFound, \"\")\n\t}\n}\n\nfunc createRepoStatus(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, \"\")\n\t}\n}\n\nfunc createRepoHook(c *gin.Context) {\n\tc.String(http.StatusOK, \"\")\n}\n\nfunc deleteRepoHook(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"hook_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, \"\")\n\t}\n}\n\nfunc getUser(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"Bearer user_not_found\", \"Bearer a87ff679\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, userPayload)\n\t}\n}\n\nfunc getEmails(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"Bearer user_not_found\", \"Bearer a87ff679\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, emailsPayload)\n\t}\n}\n\nfunc getUserRepos(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"Bearer repos_not_found\", \"Bearer 70efdf2e\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tif c.Query(\"page\") == \"\" || c.Query(\"page\") == \"1\" {\n\t\t\tc.String(http.StatusOK, userRepoPayload)\n\t\t} else {\n\t\t\tc.String(http.StatusOK, \"{\\\"values\\\":[]}\")\n\t\t}\n\t}\n}\n\nfunc getPermissions(c *gin.Context) {\n\tworkspace := c.Param(\"workspace\")\n\tq := c.Query(\"q\")\n\n\tif c.Query(\"page\") == \"\" || c.Query(\"page\") == \"1\" {\n\t\tswitch workspace {\n\t\tcase \"test_name\":\n\t\t\t// Handle query for specific repo (new GetPermission format)\n\t\t\tif q == \"repository.full_name=\\\"test_name/repo_name\\\"\" {\n\t\t\t\tc.String(http.StatusOK, permissionPayLoad)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Handle listing all permissions (ListPermissionsAll)\n\t\t\tif q == \"\" {\n\t\t\t\tc.String(http.StatusOK, permissionsPayLoad)\n\t\t\t\treturn\n\t\t\t}\n\t\tcase \"martinherren1984\":\n\t\t\t// Handle hook test cases\n\t\t\tif q == \"repository.full_name=\\\"martinherren1984/publictestrepo\\\"\" {\n\t\t\t\tc.String(http.StatusOK, permissionHookPayLoad)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tc.String(http.StatusOK, \"{\\\"values\\\":[]}\")\n}\n\nconst tokenPayload = `\n{\n\t\"access_token\":\"2YotnFZFEjr1zCsicMWpAA\",\n\t\"refresh_token\":\"tGzv3JOkF0XG5Qx2TlKWIA\",\n\t\"token_type\":\"Bearer\",\n\t\"expires_in\":3600\n}\n`\n\nconst tokenNotFoundPayload = `\n{\n\t\"access_token\":\"user_not_found\",\n\t\"refresh_token\":\"user_not_found\",\n\t\"token_type\":\"Bearer\",\n\t\"expires_in\":3600\n}\n`\n\nconst repoPayload = `\n{\n\t\"full_name\": \"test_name/repo_name\",\n\t\"scm\": \"git\",\n\t\"is_private\": true\n}\n`\n\nconst permissionRepoPayload = `\n{\n\t\"full_name\": \"test_name/%s\",\n\t\"scm\": \"git\",\n\t\"is_private\": true\n}\n`\n\nconst repoPayloadFromHook = `\n{\n\t\"type\": \"repository\",\n\t\"full_name\": \"martinherren1984/publictestrepo\",\n\t\"links\": {\n\t\t\"self\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo\"\n\t\t},\n\t\t\"html\": {\n\t\t\t\"href\": \"https://bitbucket.org/martinherren1984/publictestrepo\"\n\t\t},\n\t\t\"avatar\": {\n\t\t\t\"href\": \"https://bytebucket.org/ravatar/%7B898477b2-a080-4089-b385-597a783db392%7D?ts=default\"\n\t\t},\n\t\t\"pullrequests\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/pullrequests\"\n\t\t},\n\t\t\"commits\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits\"\n\t\t},\n\t\t\"forks\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/forks\"\n\t\t},\n\t\t\"watchers\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/watchers\"\n\t\t},\n\t\t\"branches\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches\"\n\t\t},\n\t\t\"tags\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/tags\"\n\t\t},\n\t\t\"downloads\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/downloads\"\n\t\t},\n\t\t\"source\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/src\"\n\t\t},\n\t\t\"clone\": [\n\t\t\t{\n\t\t\t\t\"name\": \"https\",\n\t\t\t\t\"href\": \"https://bitbucket.org/martinherren1984/publictestrepo.git\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"name\": \"ssh\",\n\t\t\t\t\"href\": \"git@bitbucket.org:martinherren1984/publictestrepo.git\"\n\t\t\t}\n\t\t],\n\t\t\"hooks\": {\n\t\t\t\"href\": \"https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/hooks\"\n\t\t}\n\t},\n\t\"name\": \"PublicTestRepo\",\n\t\"slug\": \"publictestrepo\",\n\t\"description\": \"\",\n\t\"scm\": \"git\",\n\t\"website\": null,\n\t\"owner\": {\n\t\t\"display_name\": \"Martin Herren\",\n\t\t\"links\": {\n\t\t\t\"self\": {\n\t\t\t\t\"href\": \"https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D\"\n\t\t\t},\n\t\t\t\"avatar\": {\n\t\t\t\t\"href\": \"https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png\"\n\t\t\t},\n\t\t\t\"html\": {\n\t\t\t\t\"href\": \"https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/\"\n\t\t\t}\n\t\t},\n\t\t\"type\": \"user\",\n\t\t\"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n\t\t\"account_id\": \"5cf8e3a9678ca90f8e7cc8a8\",\n\t\t\"nickname\": \"Martin Herren\"\n\t},\n\t\"workspace\": {\n\t\t\"type\": \"workspace\",\n\t\t\"uuid\": \"{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}\",\n\t\t\"name\": \"Martin Herren\",\n\t\t\"slug\": \"martinherren1984\",\n\t\t\"links\": {\n\t\t\t\"avatar\": {\n\t\t\t\t\"href\": \"https://bitbucket.org/workspaces/martinherren1984/avatar/?ts=1658761964\"\n\t\t\t},\n\t\t\t\"html\": {\n\t\t\t\t\"href\": \"https://bitbucket.org/martinherren1984/\"\n\t\t\t},\n\t\t\t\"self\": {\n\t\t\t\t\"href\": \"https://api.bitbucket.org/2.0/workspaces/martinherren1984\"\n\t\t\t}\n\t\t}\n\t},\n\t\"is_private\": false,\n\t\"project\": {\n\t\t\"type\": \"project\",\n\t\t\"key\": \"PUB\",\n\t\t\"uuid\": \"{2cede481-f59e-49ec-88d0-a85629b7925d}\",\n\t\t\"name\": \"PublicTestProject\",\n\t\t\"links\": {\n\t\t\t\"self\": {\n\t\t\t\t\"href\": \"https://api.bitbucket.org/2.0/workspaces/martinherren1984/projects/PUB\"\n\t\t\t},\n\t\t\t\"html\": {\n\t\t\t\t\"href\": \"https://bitbucket.org/martinherren1984/workspace/projects/PUB\"\n\t\t\t},\n\t\t\t\"avatar\": {\n\t\t\t\t\"href\": \"https://bitbucket.org/martinherren1984/workspace/projects/PUB/avatar/32?ts=1658768453\"\n\t\t\t}\n\t\t}\n\t},\n\t\"fork_policy\": \"allow_forks\",\n\t\"created_on\": \"2022-07-25T17:01:20.950706+00:00\",\n\t\"updated_on\": \"2022-09-07T20:19:30.622886+00:00\",\n\t\"size\": 85955,\n\t\"language\": \"\",\n\t\"uuid\": \"{898477b2-a080-4089-b385-597a783db392}\",\n\t\"mainbranch\": {\n\t\t\"name\": \"master\",\n\t\t\"type\": \"branch\"\n\t},\n\t\"override_settings\": {\n\t\t\"default_merge_strategy\": true,\n\t\t\"branching_model\": true\n\t},\n\t\"parent\": null,\n\t\"enforced_signed_commits\": null,\n\t\"has_issues\": false,\n\t\"has_wiki\": false\n}\n`\n\nconst repoHookPayload = `\n{\n\t\"pagelen\": 10,\n\t\"values\": [\n\t\t{\n\t\t\t\"uuid\": \"{afe61e14-2c5f-49e8-8b68-ad1fb55fc052}\",\n\t\t\t\"url\": \"http://127.0.0.1\"\n\t\t}\n\t],\n\t\"page\": 1,\n\t\"size\": 1\n}\n`\n\nconst repoFilePayload = \"dummy payload\"\n\nconst repoDirPayload = `\n{\n\t\t\"pagelen\": 10,\n\t\t\"page\": 1,\n\t\t\"values\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"path\": \"README.md\",\n\t\t\t\t\t\t\"type\": \"commit_file\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"path\": \"test\",\n\t\t\t\t\t\t\"type\": \"commit_directory\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"path\": \".gitignore\",\n\t\t\t\t\t\t\"type\": \"commit_file\"\n\t\t\t\t}\n\t\t]\n}\n`\n\nconst branchCommitsPayload = `\n{\n\t\t\"values\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"hash\": \"branch_head_name\",\n\t\t\t\t\t\t\"links\": {\n\t\t\t\t\t\t\t\"html\": {\n\t\t\t\t\t\t\t\t\"href\": \"https://bitbucket.org/commitlink\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"hash\": \"random1\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"hash\": \"random2\"\n\t\t\t\t}\n\t\t]\n}\n`\n\nconst pullRequestsPayload = `\n{\n\t\t\"values\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"id\": 123,\n\t\t\t\t\t\t\"title\": \"PRs title\"\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\t\"id\": 456,\n\t\t\t\t\t\t\"title\": \"Another PRs title\"\n\t\t\t\t}\n\t\t],\n\t\t\"pagelen\": 10,\n\t\t\"size\": 2,\n\t\t\"page\": 1\n}\n`\n\nconst diffStatPayload = `\n{\n\t\t\"values\": [\n\t\t\t\t{\n\t\t\t\t\t\t\"old\": {\n\t\t\t\t\t\t\t\t\"path\": \"main.go\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"new\": {\n\t\t\t\t\t\t\t\t\"path\": \"main.go\"\n\t\t\t\t\t\t}\n\t\t\t\t}\n\t\t]\n}\n`\n\nconst userPayload = `\n{\n\t\"uuid\": \"{4d8c0f46-cd62-4b77-b0cf-faa3e4d932c6}\",\n\t\"username\": \"superman\",\n\t\"links\": {\n\t\t\"avatar\": {\n\t\t\t\"href\": \"http:\\/\\/i.imgur.com\\/ZygP55A.jpg\"\n\t\t}\n\t},\n\t\"type\": \"user\"\n}\n`\n\nconst userRepoPayload = `\n{\n\t\"page\": 1,\n\t\"pagelen\": 10,\n\t\"size\": 1,\n\t\"values\": [\n\t\t{\n\t\t\t\"links\": {\n\t\t\t\t\"avatar\": {\n\t\t\t\t\t\t\"href\": \"http:\\/\\/i.imgur.com\\/ZygP55A.jpg\"\n\t\t\t\t}\n\t\t\t},\n\t\t\t\"full_name\": \"test_name/repo_name\",\n\t\t\t\"scm\": \"git\",\n\t\t\t\"is_private\": true\n\t\t}\n\t]\n}\n`\n\nconst emailsPayload = `\n{\n  \"pagelen\": 10,\n  \"values\": [\n    {\n      \"email\": \"test@example.com\",\n      \"is_confirmed\": true,\n      \"is_primary\": true\n  \t}\n  ],\n  \"page\": 1,\n  \"size\": 1\n}\n`\n\nconst workspacesPayload = `\n{\n\t\"page\": 1,\n\t\"pagelen\": 100,\n\t\"size\": 1,\n\t\"values\": [\n\t\t{\n\t\t\t\"type\": \"workspace_access\",\n\t\t\t\"administrator\": true,\n\t\t\t\"workspace\": {\n\t\t\t\t\"type\": \"workspace_base\",\n\t\t\t\t\"uuid\": \"{c7a04a76-fa20-43e4-dc42-a7506db4c95b}\",\n\t\t\t\t\"slug\": \"test_name\",\n\t\t\t\t\"links\": {\n\t\t\t\t\t\"avatar\": {\n\t\t\t\t\t\t\"href\": \"https://bitbucket.org/workspaces/ueberdev42/avatar/?ts=1658761964\"\n\t\t\t\t\t},\n\t\t\t\t\t\"self\": {\n\t\t\t\t\t\t\"href\": \"https://api.bitbucket.org/2.0/workspaces/ueberdev42\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n}\n`\n\nconst permissionsPayLoad = `\n{\n\t\"pagelen\": 100,\n\t\"page\": 1,\n\t\"values\": [\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"test_name/repo_name\"\n\t\t\t},\n\t\t\t\"permission\": \"read\"\n\t\t},\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"test_name/permission_read\"\n\t\t\t},\n\t\t\t\"permission\": \"read\"\n\t\t},\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"test_name/permission_write\"\n\t\t\t},\n\t\t\t\"permission\": \"write\"\n\t\t},\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"test_name/permission_admin\"\n\t\t\t},\n\t\t\t\"permission\": \"admin\"\n\t\t}\n\t]\n}\n`\n\nconst permissionPayLoad = `\n{\n\t\"pagelen\": 100,\n\t\"page\": 1,\n\t\"values\": [\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"test_name/repo_name\"\n\t\t\t},\n\t\t\t\"permission\": \"read\"\n\t\t}\n\t]\n}\n`\n\nconst permissionHookPayLoad = `\n{\n\t\"pagelen\": 100,\n\t\"page\": 1,\n\t\"values\": [\n\t\t{\n\t\t\t\"repository\": {\n\t\t\t\t\"full_name\": \"martinherren1984/publictestrepo\"\n\t\t\t},\n\t\t\t\"permission\": \"admin\"\n\t\t}\n\t]\n}\n`\n"
  },
  {
    "path": "server/forge/bitbucket/fixtures/hooks.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport _ \"embed\"\n\n//go:embed HookPush.json\nvar HookPush string\n\nconst HookPushEmptyHash = `\n{\n  \"push\": {\n    \"changes\": [\n      {\n        \"new\": {\n          \"type\": \"branch\",\n          \"target\": { \"hash\": \"\" }\n        }\n      }\n    ]\n  }\n}\n`\n\n//go:embed HookPull.json\nvar HookPull string\n\n//go:embed HookPullRequestMerged.json\nvar HookPullRequestMerged string\n\n//go:embed HookPullRequestDeclined.json\nvar HookPullRequestDeclined string\n"
  },
  {
    "path": "server/forge/bitbucket/internal/client.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/bitbucket\"\n\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tpathUser          = \"%s/2.0/user/\"\n\tpathEmails        = \"%s/2.0/user/emails\"\n\tpathPermissions   = \"%s/2.0/user/workspaces/%s/permissions/repositories?%s\"\n\tpathWorkspaces    = \"%s/2.0/user/workspaces/?%s\"\n\tpathWorkspace     = \"%s/2.0/workspaces/%s\"\n\tpathRepo          = \"%s/2.0/repositories/%s/%s\"\n\tpathRepos         = \"%s/2.0/repositories/%s?%s\"\n\tpathHook          = \"%s/2.0/repositories/%s/%s/hooks/%s\"\n\tpathHooks         = \"%s/2.0/repositories/%s/%s/hooks?%s\"\n\tpathSource        = \"%s/2.0/repositories/%s/%s/src/%s/%s\"\n\tpathStatus        = \"%s/2.0/repositories/%s/%s/commit/%s/statuses/build\"\n\tpathBranches      = \"%s/2.0/repositories/%s/%s/refs/branches?%s\"\n\tpathOrgPerms      = \"%s/2.0/workspaces/%s/permissions?%s\"\n\tpathPullRequests  = \"%s/2.0/repositories/%s/%s/pullrequests?%s\"\n\tpathBranchCommits = \"%s/2.0/repositories/%s/%s/commits/%s\"\n\tpathDir           = \"%s/2.0/repositories/%s/%s/src/%s/%s\"\n\tpathDiffStat      = \"%s/2.0/repositories/%s/%s/diffstat/%s?%s\"\n\tpageSize          = 100\n)\n\ntype Client struct {\n\t*http.Client\n\tbase string\n\tctx  context.Context\n}\n\nfunc NewClient(ctx context.Context, url string, client *http.Client) *Client {\n\treturn &Client{\n\t\tClient: client,\n\t\tbase:   url,\n\t\tctx:    ctx,\n\t}\n}\n\nfunc NewClientToken(ctx context.Context, url, client, secret string, token *oauth2.Token) *Client {\n\tconfig := &oauth2.Config{\n\t\tClientID:     client,\n\t\tClientSecret: secret,\n\t\tEndpoint:     bitbucket.Endpoint,\n\t}\n\treturn NewClient(ctx, url, config.Client(ctx, token))\n}\n\nfunc (c *Client) FindCurrent() (*Account, error) {\n\tout := new(Account)\n\turi := fmt.Sprintf(pathUser, c.base)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListEmail() (*EmailResp, error) {\n\tout := new(EmailResp)\n\turi := fmt.Sprintf(pathEmails, c.base)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListWorkspaces(opts *ListOpts) (*WorkspacesResp, error) {\n\tout := new(WorkspacesResp)\n\turi := fmt.Sprintf(pathWorkspaces, c.base, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) FindRepo(owner, name string) (*Repo, error) {\n\tout := new(Repo)\n\turi := fmt.Sprintf(pathRepo, c.base, owner, name)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListRepos(workspace string, opts *ListOpts) (*RepoResp, error) {\n\tout := new(RepoResp)\n\turi := fmt.Sprintf(pathRepos, c.base, workspace, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListReposAll(workspace string) ([]*Repo, error) {\n\treturn shared_utils.Paginate(func(page int) ([]*Repo, error) {\n\t\tresp, err := c.ListRepos(workspace, &ListOpts{Page: page, PageLen: pageSize})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn resp.Values, nil\n\t}, -1)\n}\n\nfunc (c *Client) FindHook(owner, name, id string) (*Hook, error) {\n\tout := new(Hook)\n\turi := fmt.Sprintf(pathHook, c.base, owner, name, id)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListHooks(owner, name string, opts *ListOpts) (*HookResp, error) {\n\tout := new(HookResp)\n\turi := fmt.Sprintf(pathHooks, c.base, owner, name, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) CreateHook(owner, name string, hook *Hook) error {\n\turi := fmt.Sprintf(pathHooks, c.base, owner, name, \"\")\n\t_, err := c.do(uri, http.MethodPost, hook, nil)\n\treturn err\n}\n\nfunc (c *Client) DeleteHook(owner, name, id string) error {\n\turi := fmt.Sprintf(pathHook, c.base, owner, name, id)\n\t_, err := c.do(uri, http.MethodDelete, nil, nil)\n\treturn err\n}\n\nfunc (c *Client) FindSource(owner, name, revision, path string) (*string, error) {\n\turi := fmt.Sprintf(pathSource, c.base, owner, name, revision, path)\n\treturn c.do(uri, http.MethodGet, nil, nil)\n}\n\nfunc (c *Client) CreateStatus(owner, name, revision string, status *PipelineStatus) error {\n\turi := fmt.Sprintf(pathStatus, c.base, owner, name, revision)\n\t_, err := c.do(uri, http.MethodPost, status, nil)\n\treturn err\n}\n\nfunc (c *Client) GetPermission(owner, fullName string) (*RepoPerm, error) {\n\tout := new(RepoPermResp)\n\turi := fmt.Sprintf(pathPermissions, c.base, owner, fmt.Sprintf(\"q=%s\", url.QueryEscape(fmt.Sprintf(\"repository.full_name=%q\", fullName))))\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(out.Values) == 0 {\n\t\treturn nil, fmt.Errorf(\"no permissions in repository %s\", fullName)\n\t}\n\treturn out.Values[0], nil\n}\n\nfunc (c *Client) ListPermissions(workspace string, opts *ListOpts) (*RepoPermResp, error) {\n\tout := new(RepoPermResp)\n\turi := fmt.Sprintf(pathPermissions, c.base, workspace, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) ListPermissionsAll(workspace string) ([]*RepoPerm, error) {\n\treturn shared_utils.Paginate(func(page int) ([]*RepoPerm, error) {\n\t\tresp, err := c.ListPermissions(workspace, &ListOpts{Page: page, PageLen: pageSize})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn resp.Values, nil\n\t}, -1)\n}\n\nfunc (c *Client) ListBranches(owner, name string, opts *ListOpts) ([]*Branch, error) {\n\tout := new(BranchResp)\n\turi := fmt.Sprintf(pathBranches, c.base, owner, name, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out.Values, err\n}\n\nfunc (c *Client) GetBranchHead(owner, name, branch string) (*Commit, error) {\n\tout := new(CommitsResp)\n\turi := fmt.Sprintf(pathBranchCommits, c.base, owner, name, branch)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(out.Values) == 0 {\n\t\treturn nil, fmt.Errorf(\"no commits in branch %s\", branch)\n\t}\n\treturn out.Values[0], nil\n}\n\nfunc (c *Client) GetUserWorkspaceMembership(workspace, user string) (string, error) {\n\tout := new(WorkspaceMembershipResp)\n\topts := &ListOpts{Page: 1, PageLen: pageSize}\n\tfor {\n\t\turi := fmt.Sprintf(pathOrgPerms, c.base, workspace, opts.Encode())\n\t\t_, err := c.do(uri, http.MethodGet, nil, out)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tfor _, m := range out.Values {\n\t\t\tif m.User.Nickname == user {\n\t\t\t\treturn m.Permission, nil\n\t\t\t}\n\t\t}\n\t\tif len(out.Next) == 0 {\n\t\t\tbreak\n\t\t}\n\t\topts.Page++\n\t}\n\treturn \"\", nil\n}\n\nfunc (c *Client) ListPullRequests(owner, name string, opts *ListOpts) ([]*PullRequest, error) {\n\tout := new(PullRequestResp)\n\turi := fmt.Sprintf(pathPullRequests, c.base, owner, name, opts.Encode())\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out.Values, err\n}\n\nfunc (c *Client) ListChangedFiles(owner, name, ref string) (result []string, err error) {\n\tpaths := make(map[string]struct{})\n\topts := &ListOpts{Page: 1, PageLen: pageSize}\n\tfor {\n\t\tvar resp DiffStatResp\n\t\turi := fmt.Sprintf(pathDiffStat, c.base, owner, name, ref, opts.Encode())\n\t\tif _, err = c.do(uri, http.MethodGet, nil, &resp); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, diff := range resp.Values {\n\t\t\tif diff == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif diff.Old != nil {\n\t\t\t\tpaths[diff.Old.Path] = struct{}{}\n\t\t\t}\n\t\t\tif diff.New != nil {\n\t\t\t\tpaths[diff.New.Path] = struct{}{}\n\t\t\t}\n\t\t}\n\n\t\tif resp.Next == nil {\n\t\t\tbreak\n\t\t}\n\t\topts.Page++\n\t}\n\n\tresult = make([]string, 0, len(paths))\n\tfor path := range paths {\n\t\tresult = append(result, path)\n\t}\n\treturn result, err\n}\n\nfunc (c *Client) GetWorkspace(name string) (*Workspace, error) {\n\tout := new(Workspace)\n\turi := fmt.Sprintf(pathWorkspace, c.base, name)\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) GetRepoFiles(owner, name, revision, path string, page *string) (*DirResp, error) {\n\tout := new(DirResp)\n\turi := fmt.Sprintf(pathDir, c.base, owner, name, revision, path)\n\tif page != nil {\n\t\turi += \"?page=\" + *page\n\t}\n\t_, err := c.do(uri, http.MethodGet, nil, out)\n\treturn out, err\n}\n\nfunc (c *Client) do(rawURL, method string, in, out any) (*string, error) {\n\turi, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// if we are posting or putting data, we need to\n\t// write it to the body of the request.\n\tvar buf io.ReadWriter\n\tif in != nil {\n\t\tbuf = new(bytes.Buffer)\n\t\terr := json.NewEncoder(buf).Encode(in)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// creates a new http request to bitbucket.\n\treq, err := http.NewRequestWithContext(c.ctx, method, uri.String(), buf)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif in != nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t}\n\n\tresp, err := c.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\t// if an error is encountered, parse and return the\n\t// error response.\n\tif resp.StatusCode > http.StatusPartialContent {\n\t\terr := Error{}\n\t\t_ = json.NewDecoder(resp.Body).Decode(&err)\n\t\terr.Status = resp.StatusCode\n\t\treturn nil, err\n\t}\n\n\t// if a json response is expected, parse and return\n\t// the json response.\n\tif out != nil {\n\t\treturn nil, json.NewDecoder(resp.Body).Decode(out)\n\t}\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbodyString := string(bodyBytes)\n\n\treturn &bodyString, nil\n}\n"
  },
  {
    "path": "server/forge/bitbucket/internal/types.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// cspell:words pagelen\n\ntype Account struct {\n\tUUID  string `json:\"uuid\"`\n\tLogin string `json:\"username\"`\n\tName  string `json:\"display_name\"`\n\tType  string `json:\"type\"`\n\tLinks Links  `json:\"links\"`\n}\n\ntype Workspace struct {\n\tUUID  string `json:\"uuid\"`\n\tSlug  string `json:\"slug\"`\n\tType  string `json:\"type\"`\n\tLinks Links  `json:\"links\"`\n}\n\ntype WorkspaceAccess struct {\n\tType          string     `json:\"type\"`\n\tAdministrator bool       `json:\"administrator\"`\n\tWorkspace     *Workspace `json:\"workspace\"`\n}\n\ntype WorkspacesResp struct {\n\tPage   int                `json:\"page\"`\n\tPages  int                `json:\"pagelen\"`\n\tSize   int                `json:\"size\"`\n\tNext   string             `json:\"next\"`\n\tValues []*WorkspaceAccess `json:\"values\"`\n}\n\ntype PipelineStatus struct {\n\tState   string `json:\"state\"`\n\tKey     string `json:\"key\"`\n\tName    string `json:\"name,omitempty\"`\n\tURL     string `json:\"url\"`\n\tDesc    string `json:\"description,omitempty\"`\n\tRefname string `json:\"refname,omitempty\"`\n}\n\ntype Email struct {\n\tEmail       string `json:\"email\"`\n\tIsConfirmed bool   `json:\"is_confirmed\"`\n\tIsPrimary   bool   `json:\"is_primary\"`\n}\n\ntype EmailResp struct {\n\tPage   int      `json:\"page\"`\n\tPages  int      `json:\"pagelen\"`\n\tSize   int      `json:\"size\"`\n\tNext   string   `json:\"next\"`\n\tValues []*Email `json:\"values\"`\n}\n\ntype Hook struct {\n\tUUID   string   `json:\"uuid,omitempty\"`\n\tDesc   string   `json:\"description\"`\n\tURL    string   `json:\"url\"`\n\tEvents []string `json:\"events\"`\n\tActive bool     `json:\"active\"`\n}\n\ntype HookResp struct {\n\tPage   int     `json:\"page\"`\n\tPages  int     `json:\"pagelen\"`\n\tSize   int     `json:\"size\"`\n\tNext   string  `json:\"next\"`\n\tValues []*Hook `json:\"values\"`\n}\n\ntype Links struct {\n\tSelf   Link   `json:\"self\"`\n\tAvatar Link   `json:\"avatar\"`\n\tHTML   Link   `json:\"html\"`\n\tClone  []Link `json:\"clone\"`\n}\n\ntype Link struct {\n\tHref string `json:\"href\"`\n\tName string `json:\"name\"`\n}\n\ntype LinkClone struct {\n\tLink\n}\n\ntype Repo struct {\n\tUUID       string  `json:\"uuid\"`\n\tOwner      Account `json:\"owner\"`\n\tName       string  `json:\"name\"`\n\tFullName   string  `json:\"full_name\"`\n\tLanguage   string  `json:\"language\"`\n\tIsPrivate  bool    `json:\"is_private\"`\n\tScm        string  `json:\"scm\"`\n\tDesc       string  `json:\"desc\"`\n\tLinks      Links   `json:\"links\"`\n\tMainBranch struct {\n\t\tType string `json:\"type\"`\n\t\tName string `json:\"name\"`\n\t} `json:\"mainbranch\"` // cspell:ignore mainbranch\n}\n\ntype RepoResp struct {\n\tPage   int     `json:\"page\"`\n\tPages  int     `json:\"pagelen\"`\n\tSize   int     `json:\"size\"`\n\tNext   string  `json:\"next\"`\n\tValues []*Repo `json:\"values\"`\n}\n\ntype Change struct {\n\tNew struct {\n\t\tType   string `json:\"type\"`\n\t\tName   string `json:\"name\"`\n\t\tTarget struct {\n\t\t\tType    string    `json:\"type\"`\n\t\t\tHash    string    `json:\"hash\"`\n\t\t\tMessage string    `json:\"message\"`\n\t\t\tDate    time.Time `json:\"date\"`\n\t\t\tLinks   Links     `json:\"links\"`\n\t\t\tAuthor  struct {\n\t\t\t\tRaw  string  `json:\"raw\"`\n\t\t\t\tUser Account `json:\"user\"`\n\t\t\t} `json:\"author\"`\n\t\t} `json:\"target\"`\n\t} `json:\"new\"`\n}\n\ntype PushHook struct {\n\tActor Account `json:\"actor\"`\n\tRepo  Repo    `json:\"repository\"`\n\tPush  struct {\n\t\tChanges []Change `json:\"changes\"`\n\t} `json:\"push\"`\n}\n\ntype PullRequestHook struct {\n\tActor       Account `json:\"actor\"`\n\tRepo        Repo    `json:\"repository\"`\n\tPullRequest struct {\n\t\tID      int       `json:\"id\"`\n\t\tType    string    `json:\"type\"`\n\t\tReason  string    `json:\"reason\"`\n\t\tDesc    string    `json:\"description\"`\n\t\tTitle   string    `json:\"title\"`\n\t\tState   string    `json:\"state\"`\n\t\tLinks   Links     `json:\"links\"`\n\t\tCreated time.Time `json:\"created_on\"`\n\t\tUpdated time.Time `json:\"updated_on\"`\n\n\t\tMergeCommit struct {\n\t\t\tHash string `json:\"hash\"`\n\t\t} `json:\"merge_commit\"`\n\n\t\tSource struct {\n\t\t\tRepo   Repo `json:\"repository\"`\n\t\t\tCommit struct {\n\t\t\t\tHash  string `json:\"hash\"`\n\t\t\t\tLinks Links  `json:\"links\"`\n\t\t\t} `json:\"commit\"`\n\t\t\tBranch struct {\n\t\t\t\tName string `json:\"name\"`\n\t\t\t} `json:\"branch\"`\n\t\t} `json:\"source\"`\n\n\t\tDest struct {\n\t\t\tRepo   Repo `json:\"repository\"`\n\t\t\tCommit struct {\n\t\t\t\tHash  string `json:\"hash\"`\n\t\t\t\tLinks Links  `json:\"links\"`\n\t\t\t} `json:\"commit\"`\n\t\t\tBranch struct {\n\t\t\t\tName string `json:\"name\"`\n\t\t\t} `json:\"branch\"`\n\t\t} `json:\"destination\"`\n\t} `json:\"pullrequest\"`\n}\n\ntype WorkspaceMembershipResp struct {\n\tPage   int    `json:\"page\"`\n\tPages  int    `json:\"pagelen\"`\n\tSize   int    `json:\"size\"`\n\tNext   string `json:\"next\"`\n\tValues []struct {\n\t\tPermission string `json:\"permission\"`\n\t\tUser       struct {\n\t\t\tNickname string `json:\"nickname\"`\n\t\t}\n\t} `json:\"values\"`\n}\n\ntype ListOpts struct {\n\tPage    int\n\tPageLen int\n}\n\nfunc (o *ListOpts) Encode() string {\n\tparams := url.Values{}\n\tif o.Page != 0 {\n\t\tparams.Set(\"page\", strconv.Itoa(o.Page))\n\t}\n\tif o.PageLen != 0 {\n\t\tparams.Set(\"pagelen\", strconv.Itoa(o.PageLen))\n\t}\n\treturn params.Encode()\n}\n\ntype Error struct {\n\tStatus int\n\tBody   struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\nfunc (e Error) Error() string {\n\treturn e.Body.Message\n}\n\ntype RepoPermResp struct {\n\tPage   int         `json:\"page\"`\n\tPages  int         `json:\"pagelen\"`\n\tValues []*RepoPerm `json:\"values\"`\n}\n\ntype RepoPerm struct {\n\tPermission string `json:\"permission\"`\n\tRepo       Repo   `json:\"repository\"`\n}\n\ntype BranchResp struct {\n\tValues []*Branch `json:\"values\"`\n}\n\ntype Branch struct {\n\tName string `json:\"name\"`\n}\n\ntype PullRequestResp struct {\n\tPage    uint           `json:\"page\"`\n\tPageLen uint           `json:\"pagelen\"`\n\tSize    uint           `json:\"size\"`\n\tValues  []*PullRequest `json:\"values\"`\n}\n\ntype PullRequest struct {\n\tID    uint   `json:\"id\"`\n\tTitle string `json:\"title\"`\n}\n\ntype CommitsResp struct {\n\tValues []*Commit `json:\"values\"`\n}\n\ntype Commit struct {\n\tHash  string `json:\"hash\"`\n\tLinks struct {\n\t\tHTML struct {\n\t\t\tHref string `json:\"href\"`\n\t\t} `json:\"html\"`\n\t} `json:\"links\"`\n}\n\ntype DirResp struct {\n\tPage    uint    `json:\"page\"`\n\tPageLen uint    `json:\"pagelen\"`\n\tNext    *string `json:\"next\"`\n\tValues  []*Dir  `json:\"values\"`\n}\n\ntype Dir struct {\n\tPath string `json:\"path\"`\n\tType string `json:\"type\"`\n\tSize uint   `json:\"size\"`\n}\n\ntype DiffStatResp struct {\n\tNext   *string `json:\"next\"`\n\tValues []*Diff `json:\"values\"`\n}\n\ntype Diff struct {\n\tOld *DiffFile `json:\"old\"`\n\tNew *DiffFile `json:\"new\"`\n}\n\ntype DiffFile struct {\n\tPath string `json:\"path\"`\n}\n"
  },
  {
    "path": "server/forge/bitbucket/parse.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\thookEvent        = \"X-Event-Key\"\n\thookPush         = \"repo:push\"\n\thookPullCreated  = \"pullrequest:created\"\n\thookPullUpdated  = \"pullrequest:updated\"\n\thookPullMerged   = \"pullrequest:fulfilled\"\n\thookPullDeclined = \"pullrequest:rejected\"\n\tstateClosed      = \"MERGED\"\n\tstateDeclined    = \"DECLINED\"\n)\n\n// parseHook parses a Bitbucket hook from an http.Request request and returns Pull Request,\n// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.\nfunc parseHook(r *http.Request) (*internal.PullRequestHook, *model.Repo, *model.Pipeline, error) {\n\tpayload, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\thookType := r.Header.Get(hookEvent)\n\tswitch hookType {\n\tcase hookPush:\n\t\tr, pl, err := parsePushHook(payload)\n\t\treturn nil, r, pl, err\n\tcase hookPullCreated, hookPullUpdated, hookPullMerged, hookPullDeclined:\n\t\treturn parsePullHook(payload)\n\tdefault:\n\t\treturn nil, nil, nil, &types.ErrIgnoreEvent{Event: hookType}\n\t}\n}\n\n// parsePushHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported it returns an ErrIgnoreEvent error.\nfunc parsePushHook(payload []byte) (*model.Repo, *model.Pipeline, error) {\n\thook := internal.PushHook{}\n\n\terr := json.Unmarshal(payload, &hook)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tfor _, change := range hook.Push.Changes {\n\t\tif change.New.Target.Hash == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn convertRepo(&hook.Repo, &internal.RepoPerm{}), convertPushHook(&hook, &change), nil\n\t}\n\treturn nil, nil, &types.ErrIgnoreEvent{Event: \"push\", Reason: \"BB reports no Changes\"}\n}\n\n// parsePullHook parses a pull request hook and returns the Pull Request, Repo and Pipeline\n// details.\nfunc parsePullHook(payload []byte) (*internal.PullRequestHook, *model.Repo, *model.Pipeline, error) {\n\thook := internal.PullRequestHook{}\n\n\tif err := json.Unmarshal(payload, &hook); err != nil {\n\t\treturn nil, nil, nil, err\n\t}\n\n\treturn &hook, convertRepo(&hook.Repo, &internal.RepoPerm{}), convertPullHook(&hook), nil\n}\n"
  },
  {
    "path": "server/forge/bitbucket/parse_test.go",
    "content": "// // Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucket\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_parseHook(t *testing.T) {\n\tt.Run(\"unsupported hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, \"issue:created\")\n\n\t\t_, r, b, err := parseHook(req)\n\t\tassert.Nil(t, r)\n\t\tassert.Nil(t, b)\n\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t})\n\n\tt.Run(\"malformed pull-request hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(\"[]\")\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullCreated)\n\n\t\t_, _, _, err := parseHook(req)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"pull-request\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPull)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullCreated)\n\n\t\tpr, r, b, err := parseHook(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, pr)\n\t\tassert.Equal(t, \"user_name/repo_name\", r.FullName)\n\t\tassert.Equal(t, model.EventPull, b.Event)\n\t\tassert.Equal(t, \"d3022fc0ca3d\", b.Commit)\n\t})\n\n\tt.Run(\"pull-request merged\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequestMerged)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullMerged)\n\n\t\tpr, r, b, err := parseHook(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, pr)\n\t\tassert.Equal(t, \"anbraten/test-2\", r.FullName)\n\t\tassert.Equal(t, model.EventPullClosed, b.Event)\n\t\tassert.Equal(t, \"006704dbeab2\", b.Commit)\n\t})\n\n\tt.Run(\"pull-request closed\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequestDeclined)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullDeclined)\n\n\t\tpr, r, b, err := parseHook(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, pr)\n\t\tassert.Equal(t, \"anbraten/test-2\", r.FullName)\n\t\tassert.Equal(t, model.EventPullClosed, b.Event)\n\t\tassert.Equal(t, \"f90e18fc9d45\", b.Commit)\n\t})\n\n\tt.Run(\"malformed push\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(\"[]\")\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPush)\n\n\t\t_, _, _, err := parseHook(req)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"missing commit sha\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPushEmptyHash)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPush)\n\n\t\t_, r, b, err := parseHook(req)\n\t\tassert.Nil(t, r)\n\t\tassert.Nil(t, b)\n\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t})\n\n\tt.Run(\"push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPush)\n\n\t\tpr, r, b, err := parseHook(req)\n\t\tassert.NoError(t, err)\n\t\tassert.Nil(t, pr)\n\t\tassert.Equal(t, \"martinherren1984/publictestrepo\", r.FullName)\n\t\tassert.Equal(t, \"https://bitbucket.org/martinherren1984/publictestrepo\", r.Clone)\n\t\tassert.Equal(t, \"c14c1bb05dfb1fdcdf06b31485fff61b0ea44277\", b.Commit)\n\t\tassert.Equal(t, \"a\\n\", b.Message)\n\t})\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/bitbucketdatacenter.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\t\"github.com/rs/zerolog/log\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/internal\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n)\n\nconst (\n\tlistLimit            = 250\n\tmillisecondsInSecond = 1000\n)\n\n// Opts defines configuration options.\ntype Opts struct {\n\tURL                          string // Bitbucket server url for API access.\n\tUsername                     string // Git machine account username.\n\tPassword                     string // Git machine account password.\n\tOAuthClientID                string // OAuth 2.0 client id\n\tOAuthClientSecret            string // OAuth 2.0 client secret\n\tOAuthHost                    string // OAuth 2.0 host\n\tOAuthEnableProjectAdminScope bool   // Whether to enable project admin scope. Should be set as default in the next major version.\n}\n\ntype client struct {\n\tforgeID                      int64\n\turl                          string\n\turlAPI                       string\n\tclientID                     string\n\tclientSecret                 string\n\toauthHost                    string\n\tusername                     string\n\tpassword                     string\n\toauthEnableProjectAdminScope bool\n}\n\n// New returns a Forge implementation that integrates with Bitbucket DataCenter/Server,\n// the on-premise edition of Bitbucket Cloud, formerly known as Stash.\nfunc New(id int64, opts Opts) (forge.Forge, error) {\n\tconfig := &client{\n\t\tforgeID:                      id,\n\t\turl:                          opts.URL,\n\t\turlAPI:                       fmt.Sprintf(\"%s/rest\", opts.URL),\n\t\tclientID:                     opts.OAuthClientID,\n\t\tclientSecret:                 opts.OAuthClientSecret,\n\t\toauthHost:                    opts.OAuthHost,\n\t\tusername:                     opts.Username,\n\t\tpassword:                     opts.Password,\n\t\toauthEnableProjectAdminScope: opts.OAuthEnableProjectAdminScope,\n\t}\n\n\tswitch {\n\tcase opts.Username == \"\":\n\t\treturn nil, fmt.Errorf(\"must have a git machine account username\")\n\tcase opts.Password == \"\":\n\t\treturn nil, fmt.Errorf(\"must have a git machine account password\")\n\tcase opts.OAuthClientID == \"\":\n\t\treturn nil, fmt.Errorf(\"must have an oauth 2.0 client id\")\n\tcase opts.OAuthClientSecret == \"\":\n\t\treturn nil, fmt.Errorf(\"must have an oauth 2.0 client secret\")\n\t}\n\n\treturn config, nil\n}\n\n// Name returns the string name of this driver.\nfunc (c *client) Name() string {\n\treturn \"bitbucket_dc\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (c *client) URL() string {\n\treturn c.url\n}\n\nfunc (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig := c.newOAuth2Config()\n\n\t// TODO: Use pkce flow (https://oauth.net/2/pkce/) ...\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\tif len(req.Code) == 0 {\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(ctx, req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tclient := internal.NewClientWithToken(ctx, config.TokenSource(ctx, &oauth2.Token{\n\t\tAccessToken: token.AccessToken,\n\t}), c.url)\n\tuserSlug, err := client.FindCurrentUser(ctx)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tbc, err := c.newClient(ctx, &model.User{AccessToken: token.AccessToken})\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\tuser, _, err := bc.Users.GetUser(ctx, userSlug)\n\tif err != nil {\n\t\treturn nil, \"\", fmt.Errorf(\"unable to query for user: %w\", err)\n\t}\n\n\tu := convertUser(user, c.url)\n\tupdateUserCredentials(u, token)\n\treturn u, \"\", nil\n}\n\nfunc (c *client) Refresh(ctx context.Context, u *model.User) (bool, error) {\n\tconfig := c.newOAuth2Config()\n\tt := &oauth2.Token{\n\t\tRefreshToken: u.RefreshToken,\n\t}\n\tts := config.TokenSource(ctx, t)\n\n\ttok, err := ts.Token()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"unable to refresh OAuth 2.0 token from bitbucket datacenter: %w\", err)\n\t}\n\tupdateUserCredentials(u, tok)\n\treturn true, nil\n}\n\nfunc (c *client) Repo(ctx context.Context, u *model.User, rID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\tvar repo *bitbucket.Repository\n\tif rID.IsValid() {\n\t\topts := &bitbucket.RepositorySearchOptions{Name: name, ProjectKey: owner, Permission: bitbucket.PermissionRepoWrite, ListOptions: bitbucket.ListOptions{Limit: listLimit}}\n\t\tfor {\n\t\t\trepos, resp, err := bc.Projects.SearchRepositories(ctx, opts)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"unable to search repositories: %w\", err)\n\t\t\t}\n\t\t\tfor _, r := range repos {\n\t\t\t\tif rID == convertID(r.ID) {\n\t\t\t\t\trepo = r\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif resp.LastPage {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\topts.Start = resp.NextPageStart\n\t\t}\n\t\tif repo == nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: unable to find repository with id: %s\", forge_types.ErrRepoNotFound, rID)\n\t\t}\n\t} else {\n\t\trepo, _, err = bc.Projects.GetRepository(ctx, owner, name)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: unable to get repository: %w\", forge_types.ErrRepoNotFound, err)\n\t\t}\n\t}\n\n\tb, _, err := bc.Projects.GetDefaultBranch(ctx, repo.Project.Key, repo.Slug)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to fetch default branch: %w\", err)\n\t}\n\n\tif b.DisplayID == \"\" {\n\t\treturn nil, errors.New(\"default branch setting does not exist\")\n\t}\n\n\tperms := &model.Perm{Pull: true, Push: true}\n\t_, _, err = bc.Projects.ListWebhooks(ctx, repo.Project.Key, repo.Slug, &bitbucket.ListOptions{})\n\tif err == nil {\n\t\tperms.Admin = true\n\t}\n\n\treturn convertRepo(repo, perms, b.DisplayID), nil\n}\n\nfunc (c *client) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\t// we do not support pagination as we merge different responses together\n\t// so first page returns all and we paginate here\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\topts := &bitbucket.RepositorySearchOptions{\n\t\tPermission:  bitbucket.PermissionRepoWrite,\n\t\tListOptions: bitbucket.ListOptions{Limit: listLimit},\n\t}\n\tall := make([]*model.Repo, 0)\n\tfor {\n\t\trepos, resp, err := bc.Projects.SearchRepositories(ctx, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to search repositories: %w\", err)\n\t\t}\n\t\tfor _, r := range repos {\n\t\t\tperms := &model.Perm{Pull: true, Push: true, Admin: false}\n\t\t\tall = append(all, convertRepo(r, perms, \"\"))\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\t// Add admin permissions to relevant repositories\n\topts = &bitbucket.RepositorySearchOptions{Permission: bitbucket.PermissionRepoAdmin, ListOptions: bitbucket.ListOptions{Limit: listLimit}}\n\tfor {\n\t\trepos, resp, err := bc.Projects.SearchRepositories(ctx, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to search repositories: %w\", err)\n\t\t}\n\t\tfor _, r := range repos {\n\t\t\tfor i, c := range all {\n\t\t\t\tif c.ForgeRemoteID == convertID(r.ID) {\n\t\t\t\t\tall[i].Perm = &model.Perm{Pull: true, Push: true, Admin: true}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn all, nil\n}\n\nfunc (c *client) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\tb, resp, err := bc.Projects.GetTextFileContent(ctx, r.Owner, r.Name, f, p.Commit)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\t// requested directory might not exist\n\t\t\treturn nil, &forge_types.ErrConfigNotFound{\n\t\t\t\tConfigs: []string{f},\n\t\t\t}\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn b, nil\n}\n\nfunc (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, path string) ([]*forge_types.FileMeta, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\topts := &bitbucket.FilesListOptions{At: p.Commit}\n\tall := make([]*forge_types.FileMeta, 0)\n\tfor {\n\t\tlist, resp, err := bc.Projects.ListFiles(ctx, r.Owner, r.Name, path, opts)\n\t\tif err != nil {\n\t\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\t\t// requested directory might not exist\n\t\t\t\treturn nil, &forge_types.ErrConfigNotFound{\n\t\t\t\t\tConfigs: []string{path},\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, f := range list {\n\t\t\tfullPath := fmt.Sprintf(\"%s/%s\", path, f)\n\t\t\tdata, err := c.File(ctx, u, r, p, fullPath)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tall = append(all, &forge_types.FileMeta{Name: fullPath, Data: data})\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\treturn all, nil\n}\n\nfunc (c *client) Status(ctx context.Context, u *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\tstatus := &bitbucket.BuildStatus{\n\t\tState:       convertStatus(workflow.State),\n\t\tURL:         common.GetPipelineStatusURL(repo, pipeline, workflow),\n\t\tKey:         common.GetPipelineStatusContext(repo, pipeline, workflow),\n\t\tDescription: common.GetPipelineStatusDescription(workflow.State),\n\t\tDuration:    uint64((pipeline.Finished - pipeline.Started) * millisecondsInSecond),\n\t\tParent:      common.GetPipelineStatusContext(repo, pipeline, workflow),\n\t\tDateAdded:   bitbucket.DateTime(time.Unix(pipeline.Started, 0)),\n\t\tRef:         fmt.Sprintf(\"refs/heads/%s\", pipeline.Branch),\n\t}\n\t_, err = bc.Projects.CreateBuildStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, status)\n\treturn err\n}\n\nfunc (c *client) Netrc(_ *model.User, r *model.Repo) (*model.Netrc, error) {\n\thost, err := common.ExtractHostFromCloneURL(r.Clone)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\treturn &model.Netrc{\n\t\tLogin:    c.username,\n\t\tPassword: c.password,\n\t\tMachine:  host,\n\t\tType:     model.ForgeTypeBitbucketDatacenter,\n\t}, nil\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\topts := &bitbucket.BranchSearchOptions{ListOptions: convertListOptions(p)}\n\tall := make([]string, 0, p.PerPage)\n\tfor {\n\t\tbranches, resp, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to list branches: %w\", err)\n\t\t}\n\t\tfor _, b := range branches {\n\t\t\tall = append(all, b.DisplayID)\n\t\t}\n\t\tif !p.All || resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn all, nil\n}\n\nfunc (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, b string) (*model.Commit, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\tbranches, _, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, &bitbucket.BranchSearchOptions{Filter: b})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(branches) == 0 {\n\t\treturn nil, fmt.Errorf(\"no matching branches returned\")\n\t}\n\tfor _, branch := range branches {\n\t\tif branch.DisplayID == b {\n\t\t\treturn &model.Commit{\n\t\t\t\tSHA:      branch.LatestCommit,\n\t\t\t\tForgeURL: fmt.Sprintf(\"%s/commits/%s\", strings.TrimSuffix(r.ForgeURL, \"/browse\"), branch.LatestCommit),\n\t\t\t}, nil\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"no matching branches found\")\n}\n\nfunc (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\topts := &bitbucket.PullRequestSearchOptions{ListOptions: convertListOptions(p)}\n\tall := make([]*model.PullRequest, 0)\n\tfor {\n\t\tprs, resp, err := bc.Projects.SearchPullRequests(ctx, r.Owner, r.Name, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to list pull-requests: %w\", err)\n\t\t}\n\t\tfor _, pr := range prs {\n\t\t\tall = append(all, &model.PullRequest{Index: convertID(pr.ID), Title: pr.Title})\n\t\t}\n\t\tif !p.All || resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn all, nil\n}\n\nfunc (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\terr = c.Deactivate(ctx, u, r, link)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to deactivate old webhooks: %w\", err)\n\t}\n\n\twebhook := &bitbucket.Webhook{\n\t\tName:   \"Woodpecker\",\n\t\tURL:    link,\n\t\tEvents: []bitbucket.EventKey{bitbucket.EventKeyRepoRefsChanged, bitbucket.EventKeyPullRequestFrom, bitbucket.EventKeyPullRequestMerged, bitbucket.EventKeyPullRequestDeclined, bitbucket.EventKeyPullRequestDeleted, bitbucket.EventKeyPullRequestOpened},\n\t\tActive: true,\n\t\tConfig: &bitbucket.WebhookConfiguration{\n\t\t\tSecret: r.Hash,\n\t\t},\n\t}\n\t_, _, err = bc.Projects.CreateWebhook(ctx, r.Owner, r.Name, webhook)\n\treturn err\n}\n\nfunc (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\t// check repo exists\n\tif _, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name); err != nil {\n\t\treturn fmt.Errorf(\"repo online check failed: %w\", err)\n\t}\n\n\tlu, err := url.Parse(link)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\topts := &bitbucket.ListOptions{}\n\tvar ids []uint64\n\tfor {\n\t\thooks, resp, err := bc.Projects.ListWebhooks(ctx, r.Owner, r.Name, opts)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, h := range hooks {\n\t\t\thu, err := url.Parse(h.URL)\n\t\t\tif err == nil && hu.Host == lu.Host {\n\t\t\t\tids = append(ids, h.ID)\n\t\t\t}\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\tfor _, id := range ids {\n\t\t_, err = bc.Projects.DeleteWebhook(ctx, r.Owner, r.Name, id)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\thook, currCommit, prevCommit, err := parseHook(r, c.url)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"unable to parse hook: %w\", err)\n\t}\n\n\tuser, repo, err := c.getUserAndRepo(ctx, hook.Repo)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to get user and repo: %w\", err)\n\t}\n\n\terr = bitbucket.ValidateSignature(r, hook.Payload, []byte(repo.Hash))\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"unable to validate signature on incoming webhook payload: %w\", err)\n\t}\n\n\tvar pipe *model.Pipeline\n\n\tswitch e := hook.Event.(type) {\n\tcase *bitbucket.RepositoryPushEvent:\n\t\tpipe, err = c.updatePipelineFromCommits(ctx, user, repo, hook.Pipeline, currCommit, prevCommit)\n\tcase *bitbucket.PullRequestEvent:\n\t\tpipe, err = c.updatePipelineFromPullRequest(ctx, user, repo, hook.Pipeline, e.PullRequest.ID)\n\t}\n\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to update pipeline: %w\", err)\n\t}\n\n\tif pipe == nil {\n\t\treturn nil, nil, nil\n\t}\n\n\treturn repo, pipe, nil\n}\n\nfunc (c *client) getUserAndRepo(ctx context.Context, r *model.Repo) (*model.User, *model.Repo, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn nil, nil, fmt.Errorf(\"unable to get store from context\")\n\t}\n\n\trepo, err := _store.GetRepoForgeID(c.forgeID, r.ForgeRemoteID)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"unable to get repo: %w\", err)\n\t}\n\tlog.Trace().Any(\"repo\", repo).Msg(\"got repo\")\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"unable to get user: %w\", err)\n\t}\n\tlog.Trace().Any(\"user\", user).Msg(\"got user\")\n\n\tforge.Refresh(ctx, c, _store, user)\n\n\treturn user, repo, nil\n}\n\nfunc (c *client) updatePipelineFromCommits(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, currCommit, prevCommit string) (*model.Pipeline, error) {\n\tif p == nil {\n\t\treturn nil, nil\n\t}\n\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\tcommit, _, err := bc.Projects.GetCommit(ctx, r.Owner, r.Name, p.Commit)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to read commit: %w\", err)\n\t}\n\n\t// In Bitbucket Data Center, when using annotated tags, the webhook's ToHash is the tag object SHA, not the actual commit SHA.\n\t// Update p.Commit so that build statuses are posted to the correct commit SHA.\n\tif p.Event == model.EventTag && commit.ID != \"\" && commit.ID != p.Commit {\n\t\tp.Commit = commit.ID\n\t\tp.ForgeURL = fmt.Sprintf(\"%s/projects/%s/repos/%s/commits/%s\", c.url, r.Owner, r.Name, commit.ID)\n\t}\n\n\tp.Message = commit.Message\n\n\topts := &bitbucket.CompareChangesOptions{}\n\tif currCommit != \"\" {\n\t\topts.From = currCommit\n\t}\n\tif prevCommit != \"\" {\n\t\topts.To = prevCommit\n\t}\n\tfor {\n\t\tchanges, resp, err := bc.Projects.CompareChanges(ctx, r.Owner, r.Name, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to list commit changes: %w\", err)\n\t\t}\n\t\tfor _, ch := range changes {\n\t\t\tp.ChangedFiles = append(p.ChangedFiles, ch.Path.Title)\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn p, nil\n}\n\nfunc (c *client) updatePipelineFromPullRequest(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, pullRequestID uint64) (*model.Pipeline, error) {\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\topts := &bitbucket.ListOptions{}\n\tfor {\n\t\tchanges, resp, err := bc.Projects.ListPullRequestChanges(ctx, r.Owner, r.Name, pullRequestID, opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to list changes in pull request: %w\", err)\n\t\t}\n\t\tfor _, ch := range changes {\n\t\t\tp.ChangedFiles = append(p.ChangedFiles, ch.Path.Title)\n\t\t}\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn p, nil\n}\n\n// Teams fetches all the projects for a given user and converts them into teams.\nfunc (c *client) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\tif p.Page != 1 {\n\t\treturn make([]*model.Team, 0), nil\n\t}\n\n\topts := convertListOptions(p)\n\tallProjects := make([]*bitbucket.Project, 0)\n\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create client: %w\", err)\n\t}\n\n\tfor {\n\t\tprojects, resp, err := bc.Projects.ListProjects(ctx, &opts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to fetch projects: %w\", err)\n\t\t}\n\n\t\tallProjects = append(allProjects, projects...)\n\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn convertProjectsToTeams(allProjects, bc), nil\n}\n\n// TeamPerm is not supported.\nfunc (*client) TeamPerm(_ *model.User, _ string) (*model.Perm, error) {\n\treturn nil, nil\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (c *client) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {\n\tif !c.oauthEnableProjectAdminScope {\n\t\t// This method cannot be implemented without the PROJECT_ADMIN scope included in the OAuth2 configuration\n\t\treturn nil, nil\n\t}\n\tbc, err := c.newClient(ctx, u)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"unable to create bitbucket client: %w\", err)\n\t}\n\n\t// Check if the user is Bitbucket project admin\n\tif c.hasProjectAdminAccess(ctx, bc, org) {\n\t\treturn &model.OrgPerm{Member: true, Admin: true}, nil\n\t}\n\n\t// User is not Bitbucket project admin, check if they have write access to any repositories in the Bitbucket project.\n\t// If they have, they are considered to be an organization member.\n\thasMembership, err := c.hasRepositoryWriteAccess(ctx, org, bc)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to check repository access: %w\", err)\n\t}\n\n\tif hasMembership {\n\t\treturn &model.OrgPerm{Member: true, Admin: false}, nil\n\t}\n\n\treturn &model.OrgPerm{Member: false, Admin: false}, nil\n}\n\nfunc (c *client) hasProjectAdminAccess(ctx context.Context, client *bitbucket.Client, org string) bool {\n\t// If the user can access project permissions, the user has project admin access in the Bitbucket\n\tperms, _, err := client.Projects.SearchProjectPermissions(ctx, org, &bitbucket.ProjectPermissionSearchOptions{})\n\tif err == nil && len(perms) > 0 {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (c *client) hasRepositoryWriteAccess(ctx context.Context, org string, client *bitbucket.Client) (bool, error) {\n\topts := &bitbucket.RepositorySearchOptions{\n\t\tArchived:   \"ACTIVE\",\n\t\tProjectKey: org,\n\t\tPermission: bitbucket.PermissionRepoWrite,\n\t}\n\n\tfor {\n\t\trepos, resp, err := client.Projects.SearchRepositories(ctx, opts)\n\t\tif err != nil {\n\t\t\treturn false, fmt.Errorf(\"failed to search repositories: %w\", err)\n\t\t}\n\n\t\t// If we find any repositories with write access, user has membership\n\t\tif len(repos) > 0 {\n\t\t\treturn true, nil\n\t\t}\n\n\t\tif resp.LastPage {\n\t\t\tbreak\n\t\t}\n\t\topts.Start = resp.NextPageStart\n\t}\n\n\treturn false, nil\n}\n\n// Org fetches the organization from the forge by name. If the name is a user an org with type user is returned.\nfunc (c *client) Org(_ context.Context, _ *model.User, owner string) (*model.Org, error) {\n\tif strings.HasPrefix(owner, \"~\") {\n\t\treturn &model.Org{\n\t\t\tName:   owner,\n\t\t\tIsUser: true,\n\t\t}, nil\n\t}\n\treturn &model.Org{\n\t\tName:   owner,\n\t\tIsUser: false,\n\t}, nil\n}\n\nfunc (c *client) newOAuth2Config() *oauth2.Config {\n\tpublicOAuthURL := c.oauthHost\n\tif publicOAuthURL == \"\" {\n\t\tpublicOAuthURL = c.urlAPI\n\t}\n\n\tscopes := []string{\n\t\tstring(bitbucket.PermissionRepoRead),\n\t\tstring(bitbucket.PermissionRepoWrite),\n\t\tstring(bitbucket.PermissionRepoAdmin),\n\t}\n\n\t// TODO: Remove this feature flag in the next major version and always include project admin scope\n\tif c.oauthEnableProjectAdminScope {\n\t\tscopes = append(scopes, string(bitbucket.PermissionProjectAdmin))\n\t}\n\n\treturn &oauth2.Config{\n\t\tClientID:     c.clientID,\n\t\tClientSecret: c.clientSecret,\n\t\tEndpoint: oauth2.Endpoint{\n\t\t\tAuthURL:  fmt.Sprintf(\"%s/oauth2/latest/authorize\", publicOAuthURL),\n\t\t\tTokenURL: fmt.Sprintf(\"%s/oauth2/latest/token\", c.urlAPI),\n\t\t},\n\t\tScopes:      scopes,\n\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t}\n}\n\nfunc (c *client) newClient(ctx context.Context, u *model.User) (*bitbucket.Client, error) {\n\tconfig := c.newOAuth2Config()\n\tt := &oauth2.Token{\n\t\tAccessToken: u.AccessToken,\n\t}\n\tclient := config.Client(ctx, t)\n\tclient = httputil.WrapClient(client, \"forge-bitbucketdatacenter\")\n\treturn bitbucket.NewClient(c.urlAPI, client)\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/bitbucketdatacenter_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestNew(t *testing.T) {\n\tforge, err := New(1, Opts{\n\t\tURL:               \"http://localhost:8080\",\n\t\tUsername:          \"0ZXh0IjoiI\",\n\t\tPassword:          \"I1NiIsInR5\",\n\t\tOAuthClientID:     \"client-id\",\n\t\tOAuthClientSecret: \"client-secret\",\n\t})\n\tassert.NoError(t, err)\n\tassert.NotNil(t, forge)\n\tcl, ok := forge.(*client)\n\tassert.True(t, ok)\n\tassert.Equal(t, &client{\n\t\tforgeID:      1,\n\t\turl:          \"http://localhost:8080\",\n\t\turlAPI:       \"http://localhost:8080/rest\",\n\t\tusername:     \"0ZXh0IjoiI\",\n\t\tpassword:     \"I1NiIsInR5\",\n\t\tclientID:     \"client-id\",\n\t\tclientSecret: \"client-secret\",\n\t}, cl)\n}\n\nfunc TestBitbucketDC(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ts := fixtures.Server()\n\tdefer s.Close()\n\tc := &client{\n\t\turlAPI: s.URL,\n\t}\n\n\tserver.Config.Server.StatusContext = \"ci/woodpecker\"\n\tserver.Config.Server.StatusContextFormat = \"{{ .context }}/{{ .event }}/{{ .workflow }}\"\n\n\tctx := t.Context()\n\n\trepo, err := c.Repo(ctx, fakeUser, model.ForgeRemoteID(\"1234\"), \"PRJ\", \"repo-slug\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, &model.Repo{\n\t\tName:          \"repo-slug-2\",\n\t\tOwner:         \"PRJ\",\n\t\tPerm:          &model.Perm{Pull: true, Push: true},\n\t\tBranch:        \"main\",\n\t\tIsSCMPrivate:  true,\n\t\tPREnabled:     true,\n\t\tForgeRemoteID: model.ForgeRemoteID(\"1234\"),\n\t\tFullName:      \"PRJ/repo-slug-2\",\n\t}, repo)\n\n\t// org\n\torg, err := c.Org(ctx, fakeUser, \"ORG\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, &model.Org{\n\t\tName:   \"ORG\",\n\t\tIsUser: false,\n\t}, org)\n\n\t// user\n\torg, err = c.Org(ctx, fakeUser, \"~ORG\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, &model.Org{\n\t\tName:   \"~ORG\",\n\t\tIsUser: true,\n\t}, org)\n\n\t// Execute the Status method\n\terr = c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow)\n\tassert.NoError(t, err)\n}\n\nvar (\n\tfakeUser = &model.User{\n\t\tAccessToken: \"fake\",\n\t\tExpiry:      time.Now().Add(1 * time.Hour).Unix(),\n\t}\n\n\tfakeRepo = &model.Repo{\n\t\tID:     1,\n\t\tOwner:  \"test-owner\",\n\t\tName:   \"test-repo\",\n\t\tBranch: \"main\",\n\t}\n\n\tfakePipeline = &model.Pipeline{\n\t\tID:       1,\n\t\tNumber:   42,\n\t\tCommit:   \"3ce383490b3d90d79460c60f67ba2580acc6cc59\",\n\t\tStarted:  1759825800,\n\t\tFinished: 1759825883,\n\t\tBranch:   \"feature-branch\",\n\t\tRef:      \"refs/pull-requests/123/from\",\n\t\tEvent:    model.EventPush,\n\t}\n\n\tfakeWorkflow = &model.Workflow{\n\t\tID:    1,\n\t\tPID:   1,\n\t\tName:  \"build\",\n\t\tState: model.StatusSuccess,\n\t}\n)\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/convert.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc convertStatus(status model.StatusValue) bitbucket.BuildStatusState {\n\tswitch status {\n\tcase model.StatusPending, model.StatusRunning:\n\t\treturn bitbucket.BuildStatusStateInProgress\n\tcase model.StatusSuccess:\n\t\treturn bitbucket.BuildStatusStateSuccessful\n\tdefault:\n\t\treturn bitbucket.BuildStatusStateFailed\n\t}\n}\n\nfunc convertID(id uint64) model.ForgeRemoteID {\n\treturn model.ForgeRemoteID(fmt.Sprintf(\"%d\", id))\n}\n\nfunc anonymizeLink(link string) (href string) {\n\tparsed, err := url.Parse(link)\n\tif err != nil {\n\t\treturn link\n\t}\n\tparsed.User = nil\n\treturn parsed.String()\n}\n\nfunc convertRepo(from *bitbucket.Repository, perm *model.Perm, branch string) *model.Repo {\n\tr := &model.Repo{\n\t\tForgeRemoteID: convertID(from.ID),\n\t\tName:          from.Slug,\n\t\tOwner:         from.Project.Key,\n\t\tBranch:        branch,\n\t\tIsSCMPrivate:  true, // Since we have to use Netrc it has to always be private :/ TODO: Is this really true?\n\t\tFullName:      fmt.Sprintf(\"%s/%s\", from.Project.Key, from.Slug),\n\t\tPerm:          perm,\n\t\tPREnabled:     true,\n\t}\n\n\tfor _, l := range from.Links[\"clone\"] {\n\t\tif l.Name == \"http\" {\n\t\t\tr.Clone = anonymizeLink(l.Href)\n\t\t}\n\t}\n\n\tif l, ok := from.Links[\"self\"]; ok && len(l) > 0 {\n\t\tr.ForgeURL = l[0].Href\n\t}\n\n\treturn r\n}\n\nfunc convertRepositoryPushEvent(ev *bitbucket.RepositoryPushEvent, baseURL string) *model.Pipeline {\n\tif len(ev.Changes) == 0 {\n\t\treturn nil\n\t}\n\tchange := ev.Changes[0]\n\tif change.ToHash == \"0000000000000000000000000000000000000000\" {\n\t\t// No ToHash present - could be \"DELETE\"\n\t\treturn nil\n\t}\n\tif change.Type == bitbucket.RepositoryPushEventChangeTypeDelete {\n\t\treturn nil\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tCommit:    change.ToHash,\n\t\tBranch:    change.Ref.DisplayID,\n\t\tMessage:   \"\",\n\t\tAvatar:    bitbucketAvatarURL(baseURL, ev.Actor.Slug),\n\t\tAuthor:    authorLabel(ev.Actor.Name),\n\t\tEmail:     ev.Actor.Email,\n\t\tTimestamp: time.Time(ev.Date).UTC().Unix(),\n\t\tRef:       ev.Changes[0].RefId,\n\t\tForgeURL:  fmt.Sprintf(\"%s/projects/%s/repos/%s/commits/%s\", baseURL, ev.Repository.Project.Key, ev.Repository.Slug, change.ToHash),\n\t}\n\n\tif strings.HasPrefix(ev.Changes[0].RefId, \"refs/tags/\") {\n\t\tpipeline.Event = model.EventTag\n\t} else {\n\t\tpipeline.Event = model.EventPush\n\t}\n\n\treturn pipeline\n}\n\nfunc convertGetCommitRange(ev *bitbucket.RepositoryPushEvent) (currCommit, prevCommit string) {\n\tif len(ev.Changes) == 0 {\n\t\treturn \"\", \"\"\n\t}\n\tchange := ev.Changes[0]\n\tif change.FromHash == \"0000000000000000000000000000000000000000\" {\n\t\treturn change.ToHash, \"\"\n\t} else if change.ToHash == \"0000000000000000000000000000000000000000\" {\n\t\treturn \"\", change.FromHash\n\t}\n\treturn change.ToHash, change.FromHash\n}\n\nfunc convertPullRequestEvent(ev *bitbucket.PullRequestEvent, baseURL string) *model.Pipeline {\n\tpipeline := &model.Pipeline{\n\t\tCommit:    ev.PullRequest.Source.Latest,\n\t\tBranch:    ev.PullRequest.Source.DisplayID,\n\t\tTitle:     ev.PullRequest.Title,\n\t\tMessage:   ev.PullRequest.Title,\n\t\tAvatar:    bitbucketAvatarURL(baseURL, ev.Actor.Slug),\n\t\tAuthor:    authorLabel(ev.Actor.Name),\n\t\tEmail:     ev.Actor.Email,\n\t\tTimestamp: time.Time(ev.Date).UTC().Unix(),\n\t\tRef:       fmt.Sprintf(\"refs/pull-requests/%d/from\", ev.PullRequest.ID),\n\t\tForgeURL:  fmt.Sprintf(\"%s/projects/%s/repos/%s/commits/%s\", baseURL, ev.PullRequest.Source.Repository.Project.Key, ev.PullRequest.Source.Repository.Slug, ev.PullRequest.Source.Latest),\n\t\tRefspec:   fmt.Sprintf(\"%s:%s\", ev.PullRequest.Source.DisplayID, ev.PullRequest.Target.DisplayID),\n\t\tFromFork:  ev.PullRequest.Source.Repository.ID != ev.PullRequest.Target.Repository.ID,\n\t}\n\n\tif ev.EventKey == bitbucket.EventKeyPullRequestMerged || ev.EventKey == bitbucket.EventKeyPullRequestDeclined || ev.EventKey == bitbucket.EventKeyPullRequestDeleted {\n\t\tpipeline.Event = model.EventPullClosed\n\t} else {\n\t\tpipeline.Event = model.EventPull\n\t}\n\n\treturn pipeline\n}\n\nfunc authorLabel(name string) string {\n\tvar result string\n\n\tconst maxNameLength = 40\n\n\tif len(name) > maxNameLength {\n\t\tresult = name[0:37] + \"...\"\n\t} else {\n\t\tresult = name\n\t}\n\treturn result\n}\n\nfunc convertUser(user *bitbucket.User, baseURL string) *model.User {\n\treturn &model.User{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprintf(\"%d\", user.ID)),\n\t\tLogin:         user.Slug,\n\t\tEmail:         user.Email,\n\t\tAvatar:        bitbucketAvatarURL(baseURL, user.Slug),\n\t}\n}\n\nfunc bitbucketAvatarURL(baseURL, slug string) string {\n\treturn fmt.Sprintf(\"%s/users/%s/avatar.png\", baseURL, slug)\n}\n\nfunc convertListOptions(p *model.ListOptions) bitbucket.ListOptions {\n\tif p.All {\n\t\treturn bitbucket.ListOptions{}\n\t}\n\treturn bitbucket.ListOptions{Limit: uint(p.PerPage), Start: uint((p.Page - 1) * p.PerPage)}\n}\n\nfunc updateUserCredentials(u *model.User, t *oauth2.Token) {\n\tu.AccessToken = t.AccessToken\n\tu.RefreshToken = t.RefreshToken\n\tu.Expiry = t.Expiry.UTC().Unix()\n}\n\nfunc convertProjectsToTeams(projects []*bitbucket.Project, client *bitbucket.Client) []*model.Team {\n\tteams := make([]*model.Team, 0)\n\tfor _, project := range projects {\n\t\tteam := &model.Team{\n\t\t\tLogin:  project.Key,\n\t\t\tAvatar: fmt.Sprintf(\"%s/projects/%s/avatar.png\", client.BaseURL, project.Key),\n\t\t}\n\t\tteams = append(teams, team)\n\t}\n\treturn teams\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/convert_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_convertStatus(t *testing.T) {\n\ttests := []struct {\n\t\tfrom model.StatusValue\n\t\tto   bitbucket.BuildStatusState\n\t}{\n\t\t{\n\t\t\tfrom: model.StatusPending,\n\t\t\tto:   bitbucket.BuildStatusStateInProgress,\n\t\t},\n\t\t{\n\t\t\tfrom: model.StatusRunning,\n\t\t\tto:   bitbucket.BuildStatusStateInProgress,\n\t\t},\n\t\t{\n\t\t\tfrom: model.StatusSuccess,\n\t\t\tto:   bitbucket.BuildStatusStateSuccessful,\n\t\t},\n\t\t{\n\t\t\tfrom: model.StatusValue(\"other\"),\n\t\t\tto:   bitbucket.BuildStatusStateFailed,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tto := convertStatus(tt.from)\n\t\tassert.Equal(t, tt.to, to)\n\t}\n}\n\nfunc Test_convertRepo(t *testing.T) {\n\tfrom := &bitbucket.Repository{\n\t\tID:   uint64(1234),\n\t\tSlug: \"REPO\",\n\t\tProject: &bitbucket.Project{\n\t\t\tKey: \"PRJ\",\n\t\t},\n\t\tLinks: map[string][]bitbucket.Link{\n\t\t\t\"clone\": {\n\t\t\t\t{\n\t\t\t\t\tName: \"http\",\n\t\t\t\t\tHref: \"https://user@git.domain/clone\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"self\": {\n\t\t\t\t{\n\t\t\t\t\tHref: \"https://git.domain/self\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tperm := &model.Perm{}\n\tto := convertRepo(from, perm, \"main\")\n\n\tassert.Equal(t, &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(\"1234\"),\n\t\tName:          \"REPO\",\n\t\tOwner:         \"PRJ\",\n\t\tBranch:        \"main\",\n\t\tFullName:      \"PRJ/REPO\",\n\t\tPerm:          perm,\n\t\tClone:         \"https://git.domain/clone\",\n\t\tForgeURL:      \"https://git.domain/self\",\n\t\tPREnabled:     true,\n\t\tIsSCMPrivate:  true,\n\t}, to)\n}\n\nfunc Test_convertRepositoryPushEvent(t *testing.T) {\n\tnow := time.Now()\n\ttests := []struct {\n\t\tfrom *bitbucket.RepositoryPushEvent\n\t\tto   *model.Pipeline\n\t}{\n\t\t{\n\t\t\tfrom: &bitbucket.RepositoryPushEvent{},\n\t\t\tto:   nil,\n\t\t},\n\t\t{\n\t\t\tfrom: &bitbucket.RepositoryPushEvent{\n\t\t\t\tChanges: []bitbucket.RepositoryPushEventChange{\n\t\t\t\t\t{\n\t\t\t\t\t\tFromHash: \"1234567890abcdef\",\n\t\t\t\t\t\tToHash:   \"0000000000000000000000000000000000000000\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tto: nil,\n\t\t},\n\t\t{\n\t\t\tfrom: &bitbucket.RepositoryPushEvent{\n\t\t\t\tChanges: []bitbucket.RepositoryPushEventChange{\n\t\t\t\t\t{\n\t\t\t\t\t\tFromHash: \"0000000000000000000000000000000000000000\",\n\t\t\t\t\t\tToHash:   \"1234567890abcdef\",\n\t\t\t\t\t\tType:     bitbucket.RepositoryPushEventChangeTypeDelete,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tto: nil,\n\t\t},\n\t\t{\n\t\t\tfrom: &bitbucket.RepositoryPushEvent{\n\t\t\t\tEvent: bitbucket.Event{\n\t\t\t\t\tDate: bitbucket.ISOTime(now),\n\t\t\t\t\tActor: bitbucket.User{\n\t\t\t\t\t\tName:  \"John Doe\",\n\t\t\t\t\t\tEmail: \"john.doe@mail.com\",\n\t\t\t\t\t\tSlug:  \"john.doe_mail.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRepository: bitbucket.Repository{\n\t\t\t\t\tSlug: \"REPO\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tChanges: []bitbucket.RepositoryPushEventChange{\n\t\t\t\t\t{\n\t\t\t\t\t\tRef: bitbucket.RepositoryPushEventRef{\n\t\t\t\t\t\t\tID:        \"refs/head/branch\",\n\t\t\t\t\t\t\tDisplayID: \"branch\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tRefId:  \"refs/head/branch\",\n\t\t\t\t\t\tToHash: \"1234567890abcdef\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tto: &model.Pipeline{\n\t\t\t\tCommit:    \"1234567890abcdef\",\n\t\t\t\tBranch:    \"branch\",\n\t\t\t\tMessage:   \"\",\n\t\t\t\tAvatar:    \"https://base.url/users/john.doe_mail.com/avatar.png\",\n\t\t\t\tAuthor:    \"John Doe\",\n\t\t\t\tEmail:     \"john.doe@mail.com\",\n\t\t\t\tTimestamp: now.UTC().Unix(),\n\t\t\t\tRef:       \"refs/head/branch\",\n\t\t\t\tForgeURL:  \"https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef\",\n\t\t\t\tEvent:     model.EventPush,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tto := convertRepositoryPushEvent(tt.from, \"https://base.url\")\n\t\tassert.Equal(t, tt.to, to)\n\t}\n}\n\nfunc Test_convertPullRequestEvent(t *testing.T) {\n\tnow := time.Now()\n\tfrom := &bitbucket.PullRequestEvent{\n\t\tEvent: bitbucket.Event{\n\t\t\tDate:     bitbucket.ISOTime(now),\n\t\t\tEventKey: bitbucket.EventKeyPullRequestFrom,\n\t\t\tActor: bitbucket.User{\n\t\t\t\tName:  \"John Doe\",\n\t\t\t\tEmail: \"john.doe@mail.com\",\n\t\t\t\tSlug:  \"john.doe_mail.com\",\n\t\t\t},\n\t\t},\n\t\tPullRequest: bitbucket.PullRequest{\n\t\t\tID:    123,\n\t\t\tTitle: \"my title\",\n\t\t\tSource: bitbucket.PullRequestRef{\n\t\t\t\tID:        \"refs/head/branch\",\n\t\t\t\tDisplayID: \"branch\",\n\t\t\t\tLatest:    \"1234567890abcdef\",\n\t\t\t\tRepository: bitbucket.Repository{\n\t\t\t\t\tSlug: \"REPO\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tTarget: bitbucket.PullRequestRef{\n\t\t\t\tID:        \"refs/head/main\",\n\t\t\t\tDisplayID: \"main\",\n\t\t\t\tLatest:    \"abcdef1234567890\",\n\t\t\t\tRepository: bitbucket.Repository{\n\t\t\t\t\tSlug: \"REPO\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tto := convertPullRequestEvent(from, \"https://base.url\")\n\tassert.Equal(t, &model.Pipeline{\n\t\tCommit:    \"1234567890abcdef\",\n\t\tBranch:    \"branch\",\n\t\tAvatar:    \"https://base.url/users/john.doe_mail.com/avatar.png\",\n\t\tAuthor:    \"John Doe\",\n\t\tEmail:     \"john.doe@mail.com\",\n\t\tTimestamp: now.UTC().Unix(),\n\t\tRef:       \"refs/pull-requests/123/from\",\n\t\tForgeURL:  \"https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef\",\n\t\tEvent:     model.EventPull,\n\t\tRefspec:   \"branch:main\",\n\t\tTitle:     \"my title\",\n\t\tMessage:   \"my title\",\n\t}, to)\n}\n\nfunc Test_convertPullRequestCloseEvent(t *testing.T) {\n\tnow := time.Now()\n\tfrom := &bitbucket.PullRequestEvent{\n\t\tEvent: bitbucket.Event{\n\t\t\tDate:     bitbucket.ISOTime(now),\n\t\t\tEventKey: bitbucket.EventKeyPullRequestMerged,\n\t\t\tActor: bitbucket.User{\n\t\t\t\tName:  \"John Doe\",\n\t\t\t\tEmail: \"john.doe@mail.com\",\n\t\t\t\tSlug:  \"john.doe_mail.com\",\n\t\t\t},\n\t\t},\n\t\tPullRequest: bitbucket.PullRequest{\n\t\t\tID:    123,\n\t\t\tTitle: \"my title\",\n\t\t\tSource: bitbucket.PullRequestRef{\n\t\t\t\tID:        \"refs/head/branch\",\n\t\t\t\tDisplayID: \"branch\",\n\t\t\t\tLatest:    \"1234567890abcdef\",\n\t\t\t\tRepository: bitbucket.Repository{\n\t\t\t\t\tSlug: \"REPO\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tTarget: bitbucket.PullRequestRef{\n\t\t\t\tID:        \"refs/head/main\",\n\t\t\t\tDisplayID: \"main\",\n\t\t\t\tLatest:    \"abcdef1234567890\",\n\t\t\t\tRepository: bitbucket.Repository{\n\t\t\t\t\tSlug: \"REPO\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tto := convertPullRequestEvent(from, \"https://base.url\")\n\tassert.Equal(t, &model.Pipeline{\n\t\tCommit:    \"1234567890abcdef\",\n\t\tBranch:    \"branch\",\n\t\tAvatar:    \"https://base.url/users/john.doe_mail.com/avatar.png\",\n\t\tAuthor:    \"John Doe\",\n\t\tEmail:     \"john.doe@mail.com\",\n\t\tTimestamp: now.UTC().Unix(),\n\t\tRef:       \"refs/pull-requests/123/from\",\n\t\tForgeURL:  \"https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef\",\n\t\tEvent:     model.EventPullClosed,\n\t\tRefspec:   \"branch:main\",\n\t\tTitle:     \"my title\",\n\t\tMessage:   \"my title\",\n\t}, to)\n}\n\nfunc Test_authorLabel(t *testing.T) {\n\ttests := []struct {\n\t\tfrom string\n\t\tto   string\n\t}{\n\t\t{\n\t\t\tfrom: \"Some Short Author\",\n\t\t\tto:   \"Some Short Author\",\n\t\t},\n\t\t{\n\t\t\tfrom: \"Some Very Long Author That May Include Multiple Names Here\",\n\t\t\t//nolint:misspell\n\t\t\tto: \"Some Very Long Author That May Includ...\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tassert.Equal(t, tt.to, authorLabel(tt.from))\n\t}\n}\n\nfunc Test_convertUser(t *testing.T) {\n\tfrom := &bitbucket.User{\n\t\tSlug:  \"slug\",\n\t\tEmail: \"john.doe@mail.com\",\n\t\tID:    1,\n\t}\n\tto := convertUser(from, \"https://base.url\")\n\tassert.Equal(t, &model.User{\n\t\tLogin:         \"slug\",\n\t\tAvatar:        \"https://base.url/users/slug/avatar.png\",\n\t\tEmail:         \"john.doe@mail.com\",\n\t\tForgeRemoteID: \"1\",\n\t}, to)\n}\n\nfunc Test_convertProjectsToTeams(t *testing.T) {\n\ttests := []struct {\n\t\tprojects []*bitbucket.Project\n\t\tbaseURL  string\n\t\texpected []*model.Team\n\t}{\n\t\t{\n\t\t\tprojects: []*bitbucket.Project{\n\t\t\t\t{\n\t\t\t\t\tKey: \"PRJ1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey: \"PRJ2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tbaseURL: \"https://base.url\",\n\t\t\texpected: []*model.Team{\n\t\t\t\t{\n\t\t\t\t\tLogin:  \"PRJ1\",\n\t\t\t\t\tAvatar: \"https://base.url/projects/PRJ1/avatar.png\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tLogin:  \"PRJ2\",\n\t\t\t\t\tAvatar: \"https://base.url/projects/PRJ2/avatar.png\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tprojects: []*bitbucket.Project{},\n\t\t\tbaseURL:  \"https://base.url\",\n\t\t\texpected: []*model.Team{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\t// Parse the baseURL string into a *url.URL\n\t\tparsedURL, err := url.Parse(tt.baseURL)\n\t\tassert.NoError(t, err)\n\n\t\tmockClient := &bitbucket.Client{BaseURL: parsedURL}\n\t\tactual := convertProjectsToTeams(tt.projects, mockClient)\n\n\t\tassert.Equal(t, tt.expected, actual)\n\t}\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"date\": \"2025-09-11T14:53:09+0300\",\n  \"actor\": {\n    \"emailAddress\": \"john.doe@example.com\",\n    \"displayName\": \"EXT Doe John\",\n    \"name\": \"john.doe@example.com\",\n    \"active\": true,\n    \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n    \"id\": 13581,\n    \"type\": \"NORMAL\",\n    \"slug\": \"john.doe_example.com\"\n  },\n  \"eventKey\": \"pr:merged\",\n  \"pullRequest\": {\n    \"author\": {\n      \"approved\": false,\n      \"role\": \"AUTHOR\",\n      \"user\": {\n        \"emailAddress\": \"john.doe@example.com\",\n        \"displayName\": \"EXT Doe John\",\n        \"name\": \"john.doe@example.com\",\n        \"active\": true,\n        \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n        \"id\": 13581,\n        \"type\": \"NORMAL\",\n        \"slug\": \"john.doe_example.com\"\n      },\n      \"status\": \"UNAPPROVED\"\n    },\n    \"description\": \"Updates ArgoCD to version where the CVE is patched.\",\n    \"updatedDate\": 1757591589232,\n    \"title\": \"chore(CVE-2025-55190): bump argocd\",\n    \"version\": 2,\n    \"reviewers\": [\n      {\n        \"approved\": false,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"jane.smith@contractor.com\",\n          \"displayName\": \"EXT Smith Jane\",\n          \"name\": \"jane.smith@contractor.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/jane.smith_contractor.com\" }] },\n          \"id\": 9374,\n          \"type\": \"NORMAL\",\n          \"slug\": \"jane.smith_contractor.com\"\n        },\n        \"status\": \"UNAPPROVED\"\n      },\n      {\n        \"approved\": false,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"mike.johnson@vendor.com\",\n          \"displayName\": \"EXT Johnson Mike\",\n          \"name\": \"mike.johnson@vendor.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/mike.johnson_vendor.com\" }] },\n          \"id\": 15107,\n          \"type\": \"NORMAL\",\n          \"slug\": \"mike.johnson_vendor.com\"\n        },\n        \"status\": \"UNAPPROVED\"\n      },\n      {\n        \"approved\": true,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"alex.brown@freelance.com\",\n          \"displayName\": \"EXT Brown Alex\",\n          \"name\": \"alex.brown@freelance.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/alex.brown_freelance.com\" }] },\n          \"id\": 13360,\n          \"type\": \"NORMAL\",\n          \"slug\": \"alex.brown_freelance.com\"\n        },\n        \"lastReviewedCommit\": \"993203acecdb65ffe947424d0917768b0e5c3903\",\n        \"status\": \"APPROVED\"\n      }\n    ],\n    \"toRef\": {\n      \"latestCommit\": \"2bbf6d0c36db47566a934ab8f8e391e1ee54d392\",\n      \"id\": \"refs/heads/master\",\n      \"displayId\": \"master\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"archived\": false,\n        \"public\": false,\n        \"hierarchyId\": \"da7793ace13b18fa55a5\",\n        \"name\": \"deployment-automation\",\n        \"forkable\": true,\n        \"project\": {\n          \"public\": false,\n          \"name\": \"devops-team\",\n          \"description\": \"DevOps Team\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n          \"id\": 565,\n          \"type\": \"NORMAL\",\n          \"key\": \"DEV\"\n        },\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/dev/deployment-automation.git\"\n            },\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git\"\n            }\n          ],\n          \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse\" }]\n        },\n        \"id\": 1684,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"deployment-automation\",\n        \"statusMessage\": \"Available\"\n      }\n    },\n    \"createdDate\": 1757571094582,\n    \"closedDate\": 1757591589232,\n    \"draft\": false,\n    \"closed\": true,\n    \"fromRef\": {\n      \"latestCommit\": \"993203acecdb65ffe947424d0917768b0e5c3903\",\n      \"id\": \"refs/heads/PROJ-4584\",\n      \"displayId\": \"PROJ-4584\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"archived\": false,\n        \"public\": false,\n        \"hierarchyId\": \"da7793ace13b18fa55a5\",\n        \"name\": \"deployment-automation\",\n        \"forkable\": true,\n        \"project\": {\n          \"public\": false,\n          \"name\": \"devops-team\",\n          \"description\": \"DevOps Team\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n          \"id\": 565,\n          \"type\": \"NORMAL\",\n          \"key\": \"DEV\"\n        },\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/dev/deployment-automation.git\"\n            },\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git\"\n            }\n          ],\n          \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse\" }]\n        },\n        \"id\": 1684,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"deployment-automation\",\n        \"statusMessage\": \"Available\"\n      }\n    },\n    \"links\": {\n      \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/pull-requests/111\" }]\n    },\n    \"id\": 111,\n    \"state\": \"MERGED\",\n    \"locked\": false,\n    \"open\": false,\n    \"properties\": {\n      \"mergeCommit\": {\n        \"id\": \"c690da9e7f6a6d90defe03d57b8802df149c4aff\",\n        \"displayId\": \"c690da9e7f6\"\n      }\n    },\n    \"participants\": []\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/HookPullRequestOpened.json",
    "content": "{\n  \"date\": \"2025-09-19T09:07:23+0300\",\n  \"actor\": {\n    \"emailAddress\": \"mike.johnson@vendor.com\",\n    \"displayName\": \"EXT Johnson Mike\",\n    \"name\": \"mike.johnson@vendor.com\",\n    \"active\": true,\n    \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/mike.johnson_vendor.com\" }] },\n    \"id\": 15107,\n    \"type\": \"NORMAL\",\n    \"slug\": \"mike.johnson_vendor.com\"\n  },\n  \"eventKey\": \"pr:opened\",\n  \"pullRequest\": {\n    \"author\": {\n      \"approved\": false,\n      \"role\": \"AUTHOR\",\n      \"user\": {\n        \"emailAddress\": \"mike.johnson@vendor.com\",\n        \"displayName\": \"EXT Johnson Mike\",\n        \"name\": \"mike.johnson@vendor.com\",\n        \"active\": true,\n        \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/mike.johnson_vendor.com\" }] },\n        \"id\": 15107,\n        \"type\": \"NORMAL\",\n        \"slug\": \"mike.johnson_vendor.com\"\n      },\n      \"status\": \"UNAPPROVED\"\n    },\n    \"description\": \"#### What?\\n\\nGather statistics about active repositories Woodpecker\",\n    \"updatedDate\": 1758262043663,\n    \"title\": \"feat: gather statistics about Woodpecker migration\",\n    \"version\": 0,\n    \"reviewers\": [\n      {\n        \"approved\": false,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"jane.smith@contractor.com\",\n          \"displayName\": \"EXT Smith Jane\",\n          \"name\": \"jane.smith@contractor.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/jane.smith_contractor.com\" }] },\n          \"id\": 9374,\n          \"type\": \"NORMAL\",\n          \"slug\": \"jane.smith_contractor.com\"\n        },\n        \"status\": \"UNAPPROVED\"\n      },\n      {\n        \"approved\": false,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"john.doe@example.com\",\n          \"displayName\": \"EXT Doe John\",\n          \"name\": \"john.doe@example.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n          \"id\": 13581,\n          \"type\": \"NORMAL\",\n          \"slug\": \"john.doe_example.com\"\n        },\n        \"status\": \"UNAPPROVED\"\n      },\n      {\n        \"approved\": false,\n        \"role\": \"REVIEWER\",\n        \"user\": {\n          \"emailAddress\": \"alex.brown@freelance.com\",\n          \"displayName\": \"EXT Brown Alex\",\n          \"name\": \"alex.brown@freelance.com\",\n          \"active\": true,\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/alex.brown_freelance.com\" }] },\n          \"id\": 13360,\n          \"type\": \"NORMAL\",\n          \"slug\": \"alex.brown_freelance.com\"\n        },\n        \"status\": \"UNAPPROVED\"\n      }\n    ],\n    \"toRef\": {\n      \"latestCommit\": \"3767a5d2d2223447d03838654baa271fc15d94df\",\n      \"id\": \"refs/heads/main\",\n      \"displayId\": \"main\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"hierarchyId\": \"618693f8805af6d8f5c7\",\n        \"description\": \"Network Monitor\",\n        \"project\": {\n          \"public\": false,\n          \"name\": \"devops-team\",\n          \"description\": \"DevOps Team\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n          \"id\": 565,\n          \"type\": \"NORMAL\",\n          \"key\": \"DEV\"\n        },\n        \"statusMessage\": \"Available\",\n        \"archived\": false,\n        \"public\": false,\n        \"name\": \"network-monitor\",\n        \"forkable\": true,\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/dev/network-monitor.git\"\n            },\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/dev/network-monitor.git\"\n            }\n          ],\n          \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/network-monitor/browse\" }]\n        },\n        \"id\": 1079,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"network-monitor\"\n      }\n    },\n    \"createdDate\": 1758262043663,\n    \"draft\": false,\n    \"closed\": false,\n    \"fromRef\": {\n      \"latestCommit\": \"1c7589876bc8b5e83122b1656925d679915193d4\",\n      \"id\": \"refs/heads/PROJ-4596\",\n      \"displayId\": \"PROJ-4596\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"hierarchyId\": \"618693f8805af6d8f5c7\",\n        \"description\": \"Network Monitor\",\n        \"project\": {\n          \"public\": false,\n          \"name\": \"devops-team\",\n          \"description\": \"DevOps Team\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n          \"id\": 565,\n          \"type\": \"NORMAL\",\n          \"key\": \"DEV\"\n        },\n        \"statusMessage\": \"Available\",\n        \"archived\": false,\n        \"public\": false,\n        \"name\": \"network-monitor\",\n        \"forkable\": true,\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/dev/network-monitor.git\"\n            },\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/dev/network-monitor.git\"\n            }\n          ],\n          \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/network-monitor/browse\" }]\n        },\n        \"id\": 1079,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"network-monitor\"\n      }\n    },\n    \"links\": {\n      \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/network-monitor/pull-requests/125\" }]\n    },\n    \"id\": 125,\n    \"state\": \"OPEN\",\n    \"locked\": false,\n    \"open\": true,\n    \"participants\": []\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/HookPullRequestOpenedFromFork.json",
    "content": "{\n  \"date\": \"2025-09-22T13:36:11+0300\",\n  \"actor\": {\n    \"emailAddress\": \"john.doe@example.com\",\n    \"displayName\": \"EXT Doe John\",\n    \"name\": \"john.doe@example.com\",\n    \"active\": true,\n    \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n    \"id\": 13581,\n    \"type\": \"NORMAL\",\n    \"slug\": \"john.doe_example.com\"\n  },\n  \"eventKey\": \"pr:opened\",\n  \"pullRequest\": {\n    \"author\": {\n      \"approved\": false,\n      \"role\": \"AUTHOR\",\n      \"user\": {\n        \"emailAddress\": \"john.doe@example.com\",\n        \"displayName\": \"EXT Doe John\",\n        \"name\": \"john.doe@example.com\",\n        \"active\": true,\n        \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n        \"id\": 13581,\n        \"type\": \"NORMAL\",\n        \"slug\": \"john.doe_example.com\"\n      },\n      \"status\": \"UNAPPROVED\"\n    },\n    \"updatedDate\": 1758537371875,\n    \"title\": \"testing PROJ-4600\",\n    \"version\": 0,\n    \"reviewers\": [],\n    \"toRef\": {\n      \"latestCommit\": \"8c49fecb1363fffdf00456cedaaff6a50613725a\",\n      \"id\": \"refs/heads/master\",\n      \"displayId\": \"master\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"archived\": false,\n        \"public\": false,\n        \"hierarchyId\": \"da7793ace13b18fa55a5\",\n        \"name\": \"deployment-automation\",\n        \"forkable\": true,\n        \"project\": {\n          \"public\": false,\n          \"name\": \"devops-team\",\n          \"description\": \"DevOps Team\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n          \"id\": 565,\n          \"type\": \"NORMAL\",\n          \"key\": \"DEV\"\n        },\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/dev/deployment-automation.git\"\n            },\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git\"\n            }\n          ],\n          \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse\" }]\n        },\n        \"id\": 1684,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"deployment-automation\",\n        \"statusMessage\": \"Available\"\n      }\n    },\n    \"createdDate\": 1758537371875,\n    \"draft\": false,\n    \"closed\": false,\n    \"fromRef\": {\n      \"latestCommit\": \"716e510cecbe203618609cf103c54e040b949739\",\n      \"id\": \"refs/heads/master\",\n      \"displayId\": \"master\",\n      \"type\": \"BRANCH\",\n      \"repository\": {\n        \"hierarchyId\": \"da7793ace13b18fa55a5\",\n        \"origin\": {\n          \"archived\": false,\n          \"public\": false,\n          \"hierarchyId\": \"da7793ace13b18fa55a5\",\n          \"name\": \"deployment-automation\",\n          \"forkable\": true,\n          \"project\": {\n            \"public\": false,\n            \"name\": \"devops-team\",\n            \"description\": \"DevOps Team\",\n            \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n            \"id\": 565,\n            \"type\": \"NORMAL\",\n            \"key\": \"DEV\"\n          },\n          \"links\": {\n            \"clone\": [\n              {\n                \"name\": \"http\",\n                \"href\": \"https://bitbucket.example.com/scm/dev/deployment-automation.git\"\n              },\n              {\n                \"name\": \"ssh\",\n                \"href\": \"ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git\"\n              }\n            ],\n            \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse\" }]\n          },\n          \"id\": 1684,\n          \"scmId\": \"git\",\n          \"state\": \"AVAILABLE\",\n          \"slug\": \"deployment-automation\",\n          \"statusMessage\": \"Available\"\n        },\n        \"project\": {\n          \"owner\": {\n            \"emailAddress\": \"john.doe@example.com\",\n            \"displayName\": \"EXT Doe John\",\n            \"name\": \"john.doe@example.com\",\n            \"active\": true,\n            \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n            \"id\": 13581,\n            \"type\": \"NORMAL\",\n            \"slug\": \"john.doe_example.com\"\n          },\n          \"name\": \"EXT Doe John\",\n          \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/john.doe_example.com\" }] },\n          \"id\": 1120,\n          \"type\": \"PERSONAL\",\n          \"key\": \"~JOHN.DOE_EXAMPLE.COM\"\n        },\n        \"statusMessage\": \"Available\",\n        \"archived\": false,\n        \"public\": false,\n        \"name\": \"deployment-automation\",\n        \"forkable\": true,\n        \"links\": {\n          \"clone\": [\n            {\n              \"name\": \"ssh\",\n              \"href\": \"ssh://git@bitbucket.example.com:7999/~john.doe_example.com/deployment-automation.git\"\n            },\n            {\n              \"name\": \"http\",\n              \"href\": \"https://bitbucket.example.com/scm/~john.doe_example.com/deployment-automation.git\"\n            }\n          ],\n          \"self\": [\n            { \"href\": \"https://bitbucket.example.com/users/john.doe_example.com/repos/deployment-automation/browse\" }\n          ]\n        },\n        \"id\": 1856,\n        \"scmId\": \"git\",\n        \"state\": \"AVAILABLE\",\n        \"slug\": \"deployment-automation\"\n      }\n    },\n    \"links\": {\n      \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/pull-requests/114\" }]\n    },\n    \"id\": 114,\n    \"state\": \"OPEN\",\n    \"locked\": false,\n    \"open\": true,\n    \"participants\": []\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/HookPush.json",
    "content": "{\n  \"date\": \"2025-09-23T03:15:55+0300\",\n  \"actor\": {\n    \"emailAddress\": \"renovatebot@example.com\",\n    \"displayName\": \"Renovate Bot\",\n    \"name\": \"renovatebot\",\n    \"active\": true,\n    \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/renovatebot\" }] },\n    \"id\": 14570,\n    \"type\": \"NORMAL\",\n    \"slug\": \"renovatebot\"\n  },\n  \"toCommit\": {\n    \"committer\": {\n      \"emailAddress\": \"renovatebot@example.com\",\n      \"displayName\": \"Renovate Bot\",\n      \"name\": \"renovatebot\",\n      \"active\": true,\n      \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/renovatebot\" }] },\n      \"id\": 14570,\n      \"type\": \"NORMAL\",\n      \"slug\": \"renovatebot\"\n    },\n    \"committerTimestamp\": 1758586555000,\n    \"author\": {\n      \"emailAddress\": \"renovatebot@example.com\",\n      \"displayName\": \"Renovate Bot\",\n      \"name\": \"renovatebot\",\n      \"active\": true,\n      \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/renovatebot\" }] },\n      \"id\": 14570,\n      \"type\": \"NORMAL\",\n      \"slug\": \"renovatebot\"\n    },\n    \"authorTimestamp\": 1758586555000,\n    \"id\": \"76797d54bca87db6d1e3e82ee40622c7908aa514\",\n    \"displayId\": \"76797d54bca\",\n    \"message\": \"chore(deps): update all\",\n    \"parents\": [\n      {\n        \"committer\": {\n          \"emailAddress\": \"john.doe@example.com\",\n          \"name\": \"John Doe\"\n        },\n        \"committerTimestamp\": 1757592099000,\n        \"author\": {\n          \"emailAddress\": \"john.doe@example.com\",\n          \"name\": \"John Doe\"\n        },\n        \"authorTimestamp\": 1757592099000,\n        \"id\": \"8c49fecb1363fffdf00456cedaaff6a50613725a\",\n        \"displayId\": \"8c49fecb136\",\n        \"message\": \"chore: bump deployment automation version\",\n        \"parents\": [\n          {\n            \"id\": \"c690da9e7f6a6d90defe03d57b8802df149c4aff\",\n            \"displayId\": \"c690da9e7f6\"\n          }\n        ]\n      }\n    ]\n  },\n  \"eventKey\": \"repo:refs_changed\",\n  \"changes\": [\n    {\n      \"ref\": {\n        \"id\": \"refs/heads/renovate-all\",\n        \"displayId\": \"renovate-all\",\n        \"type\": \"BRANCH\"\n      },\n      \"fromHash\": \"e0e15221b987fd8296141c0faa6a79f7c86ca4ce\",\n      \"toHash\": \"76797d54bca87db6d1e3e82ee40622c7908aa514\",\n      \"refId\": \"refs/heads/renovate-all\",\n      \"type\": \"UPDATE\"\n    }\n  ],\n  \"commits\": [\n    {\n      \"committer\": {\n        \"emailAddress\": \"renovatebot@example.com\",\n        \"displayName\": \"Renovate Bot\",\n        \"name\": \"renovatebot\",\n        \"active\": true,\n        \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/renovatebot\" }] },\n        \"id\": 14570,\n        \"type\": \"NORMAL\",\n        \"slug\": \"renovatebot\"\n      },\n      \"committerTimestamp\": 1758586555000,\n      \"author\": {\n        \"emailAddress\": \"renovatebot@example.com\",\n        \"displayName\": \"Renovate Bot\",\n        \"name\": \"renovatebot\",\n        \"active\": true,\n        \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/users/renovatebot\" }] },\n        \"id\": 14570,\n        \"type\": \"NORMAL\",\n        \"slug\": \"renovatebot\"\n      },\n      \"authorTimestamp\": 1758586555000,\n      \"id\": \"76797d54bca87db6d1e3e82ee40622c7908aa514\",\n      \"displayId\": \"76797d54bca\",\n      \"message\": \"chore(deps): update all\",\n      \"parents\": [\n        {\n          \"id\": \"8c49fecb1363fffdf00456cedaaff6a50613725a\",\n          \"displayId\": \"8c49fecb136\"\n        }\n      ]\n    }\n  ],\n  \"repository\": {\n    \"archived\": false,\n    \"public\": false,\n    \"hierarchyId\": \"da7793ace13b18fa55a5\",\n    \"name\": \"deployment-automation\",\n    \"forkable\": true,\n    \"project\": {\n      \"public\": false,\n      \"name\": \"devops-team\",\n      \"description\": \"DevOps Team\",\n      \"links\": { \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV\" }] },\n      \"id\": 565,\n      \"type\": \"NORMAL\",\n      \"key\": \"DEV\"\n    },\n    \"links\": {\n      \"clone\": [\n        {\n          \"name\": \"http\",\n          \"href\": \"https://bitbucket.example.com/scm/dev/deployment-automation.git\"\n        },\n        {\n          \"name\": \"ssh\",\n          \"href\": \"ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git\"\n        }\n      ],\n      \"self\": [{ \"href\": \"https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse\" }]\n    },\n    \"id\": 1684,\n    \"scmId\": \"git\",\n    \"state\": \"AVAILABLE\",\n    \"slug\": \"deployment-automation\",\n    \"statusMessage\": \"Available\"\n  }\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/expected/PostBuildStatus.json",
    "content": "{\n  \"key\": \"ci/woodpecker/push/build\",\n  \"state\": \"SUCCESSFUL\",\n  \"url\": \"/repos/1/pipeline/42/1\",\n  \"dateAdded\": 1759825800000,\n  \"description\": \"Pipeline was successful\",\n  \"duration\": 83000,\n  \"parent\": \"ci/woodpecker/push/build\",\n  \"ref\": \"refs/heads/feature-branch\"\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/handler.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\t\"github.com/neticdk/go-bitbucket/mock\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar (\n\t//go:embed expected/*\n\tembeddedFixtures embed.FS\n\tPostBuildStatus  = mock.EndpointPattern{Pattern: \"/api/latest/projects/:projectKey/repos/:repositorySlug/commits/:commitId/builds\", Method: \"POST\"}\n)\n\ntype ResponseContent map[string]any\n\nfunc Server() *httptest.Server {\n\treturn mock.NewMockServer(\n\t\tmock.WithRequestMatch(mock.SearchRepositories, bitbucket.RepositoryList{\n\t\t\tListResponse: bitbucket.ListResponse{\n\t\t\t\tLastPage: true,\n\t\t\t},\n\t\t\tRepositories: []*bitbucket.Repository{\n\t\t\t\t{\n\t\t\t\t\tID:   uint64(123),\n\t\t\t\t\tSlug: \"repo-slug-1\",\n\t\t\t\t\tName: \"REPO Name 1\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tID:  uint64(456),\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tID:   uint64(1234),\n\t\t\t\t\tSlug: \"repo-slug-2\",\n\t\t\t\t\tName: \"REPO Name 2\",\n\t\t\t\t\tProject: &bitbucket.Project{\n\t\t\t\t\t\tID:  uint64(456),\n\t\t\t\t\t\tKey: \"PRJ\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}),\n\t\tmock.WithRequestMatch(mock.GetRepository, bitbucket.Repository{\n\t\t\tID:   uint64(123),\n\t\t\tSlug: \"repo-slug\",\n\t\t\tName: \"REPO Name\",\n\t\t\tProject: &bitbucket.Project{\n\t\t\t\tID:  uint64(456),\n\t\t\t\tKey: \"PRJ\",\n\t\t\t},\n\t\t}),\n\t\tmock.WithRequestMatch(mock.GetDefaultBranch, bitbucket.Branch{\n\t\t\tID:        \"refs/head/main\",\n\t\t\tDisplayID: \"main\",\n\t\t\tDefault:   true,\n\t\t}),\n\n\t\tmock.WithRequestMatchHandler(PostBuildStatus, ExpectedContentHandler(\n\t\t\t\"PostBuildStatus.json\",\n\t\t\thttp.StatusNoContent, nil,\n\t\t\thttp.StatusBadRequest, ResponseContent{\n\t\t\t\t\"errors\": []ResponseContent{\n\t\t\t\t\t{\n\t\t\t\t\t\t\"context\":       \"\",\n\t\t\t\t\t\t\"exceptionName\": \"\",\n\t\t\t\t\t\t\"message\":       \"invalid branch was provided\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t)),\n\t)\n}\n\nfunc ExpectedContentHandler(expectedFileName string, successCode int, successContent ResponseContent, failCode int, failContent ResponseContent) http.HandlerFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\texpectedContent, err := loadExpectedContent(expectedFileName)\n\t\tif err != nil {\n\t\t\twriteResponse(w, http.StatusInternalServerError, ResponseContent{\"error\": \"Internal Server Error\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar requestBody ResponseContent\n\t\tif err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {\n\t\t\twriteResponse(w, failCode, failContent)\n\t\t\treturn\n\t\t}\n\n\t\tif !assert.ObjectsAreEqual(requestBody, expectedContent) {\n\t\t\twriteResponse(w, failCode, failContent)\n\t\t\treturn\n\t\t}\n\n\t\twriteResponse(w, successCode, successContent)\n\t}\n}\n\nfunc loadExpectedContent(fileName string) (ResponseContent, error) {\n\tfile, err := embeddedFixtures.Open(filepath.Join(\"expected\", fileName))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tvar content ResponseContent\n\terr = json.NewDecoder(file).Decode(&content)\n\treturn content, err\n}\n\nfunc writeResponse(w http.ResponseWriter, statusCode int, content ResponseContent) {\n\tw.WriteHeader(statusCode)\n\tif content != nil {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tif err := json.NewEncoder(w).Encode(content); err != nil {\n\t\t\thttp.Error(w, \"Failed to encode response\", http.StatusInternalServerError)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/fixtures/hooks.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport _ \"embed\"\n\n//go:embed HookPullRequestOpenedFromFork.json\nvar HookPullFork string\n\n//go:embed HookPush.json\nvar HookPush string\n\n//go:embed HookPullRequestMerged.json\nvar HookPullMerged string\n\n//go:embed HookPullRequestOpened.json\nvar HookPull string\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/internal/client.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"golang.org/x/oauth2\"\n)\n\nconst (\n\tcurrentUserID = \"%s/plugins/servlet/applinks/whoami\" // cspell:disable-line\n)\n\ntype Client struct {\n\tclient *http.Client\n\tbase   string\n}\n\nfunc NewClientWithToken(ctx context.Context, ts oauth2.TokenSource, url string) *Client {\n\treturn &Client{\n\t\tclient: oauth2.NewClient(ctx, ts),\n\t\tbase:   url,\n\t}\n}\n\n// FindCurrentUser is returning the current user id - however it is not really part of the API so it is not part of the Bitbucket go client.\nfunc (c *Client) FindCurrentUser(ctx context.Context) (string, error) {\n\turl := fmt.Sprintf(currentUserID, c.base)\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to create http request: %w\", err)\n\t}\n\n\tresp, err := c.client.Do(req)\n\tif resp != nil {\n\t\tdefer resp.Body.Close()\n\t}\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to query logged in user id: %w\", err)\n\t}\n\n\tbuf, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"unable to read data from user id query: %w\", err)\n\t}\n\tlogin := string(buf)\n\tlogin = strings.ReplaceAll(login, \"@\", \"_\") // Apparently the \"whoami\" endpoint may return the \"wrong\" username - converting to user slug\n\treturn login, nil\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/internal/client_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage internal\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"golang.org/x/oauth2\"\n)\n\nfunc TestCurrentUser(t *testing.T) {\n\ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(`tal@netic.dk`))\n\t}))\n\n\tdefer s.Close()\n\n\tctx := t.Context()\n\tts := mockSource(\"bearer-token\")\n\tclient := NewClientWithToken(ctx, ts, s.URL)\n\tuid, err := client.FindCurrentUser(ctx)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"tal_netic.dk\", uid)\n}\n\ntype mockSource string\n\nfunc (ds mockSource) Token() (*oauth2.Token, error) {\n\treturn &oauth2.Token{AccessToken: string(ds)}, nil\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/parse.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype HookResult struct {\n\tRepo     *model.Repo\n\tPipeline *model.Pipeline\n\tEvent    any\n\tPayload  []byte\n}\n\nfunc parseHook(r *http.Request, baseURL string) (*HookResult, string, string, error) {\n\tev, payload, err := bitbucket.ParsePayloadWithoutSignature(r)\n\tif err != nil {\n\t\treturn nil, \"\", \"\", fmt.Errorf(\"unable to parse payload from webhook invocation: %w\", err)\n\t}\n\n\tresult := &HookResult{\n\t\tEvent:   ev,\n\t\tPayload: payload,\n\t}\n\n\tswitch e := ev.(type) {\n\tcase *bitbucket.RepositoryPushEvent:\n\t\tresult.Repo = convertRepo(&e.Repository, nil, \"\")\n\t\tresult.Pipeline = convertRepositoryPushEvent(e, baseURL)\n\t\tcurrCommit, prevCommit := convertGetCommitRange(e)\n\t\treturn result, currCommit, prevCommit, nil\n\tcase *bitbucket.PullRequestEvent:\n\t\tresult.Repo = convertRepo(&e.PullRequest.Target.Repository, nil, \"\")\n\t\tresult.Pipeline = convertPullRequestEvent(e, baseURL)\n\t\treturn result, \"\", \"\", nil\n\tdefault:\n\t\treturn nil, \"\", \"\", &types.ErrIgnoreEvent{Event: fmt.Sprintf(\"%T\", e), Reason: \"unsupported webhook event type\"}\n\t}\n}\n"
  },
  {
    "path": "server/forge/bitbucketdatacenter/parse_test.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage bitbucketdatacenter\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/neticdk/go-bitbucket/bitbucket\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_parseHook(t *testing.T) {\n\tt.Run(\"pull-request opened\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPull)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-Event-Key\", \"pr:opened\")\n\n\t\tresult, curCommit, prevCommit, err := parseHook(req, \"https://bitbucket.example.com\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.Empty(t, curCommit)\n\t\tassert.Empty(t, prevCommit)\n\t\tassert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event)\n\t\tassert.NotNil(t, result.Repo)\n\t\tassert.NotNil(t, result.Pipeline)\n\t\tassert.NotNil(t, result.Payload)\n\t\tassert.Equal(t, \"DEV/network-monitor\", result.Repo.FullName)\n\t\tassert.Equal(t, \"1c7589876bc8b5e83122b1656925d679915193d4\", result.Pipeline.Commit)\n\t\tassert.Equal(t, model.EventPull, result.Pipeline.Event)\n\t})\n\n\tt.Run(\"pull-request opened from fork\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullFork)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-Event-Key\", \"pr:opened\")\n\n\t\tresult, curCommit, prevCommit, err := parseHook(req, \"https://bitbucket.example.com\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.Empty(t, curCommit)\n\t\tassert.Empty(t, prevCommit)\n\t\tassert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event)\n\t\tassert.NotNil(t, result.Repo)\n\t\tassert.NotNil(t, result.Pipeline)\n\t\tassert.NotNil(t, result.Payload)\n\t\tassert.Equal(t, \"DEV/deployment-automation\", result.Repo.FullName)\n\t\tassert.Equal(t, \"716e510cecbe203618609cf103c54e040b949739\", result.Pipeline.Commit)\n\t\tassert.Equal(t, model.EventPull, result.Pipeline.Event)\n\t})\n\n\tt.Run(\"push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-Event-Key\", \"repo:refs_changed\")\n\n\t\tresult, curCommit, prevCommit, err := parseHook(req, \"https://bitbucket.example.com\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.IsType(t, &bitbucket.RepositoryPushEvent{}, result.Event)\n\t\tassert.NotNil(t, result.Repo)\n\t\tassert.NotNil(t, result.Pipeline)\n\t\tassert.NotNil(t, result.Payload)\n\t\tassert.Equal(t, curCommit, \"76797d54bca87db6d1e3e82ee40622c7908aa514\")\n\t\tassert.Equal(t, prevCommit, \"e0e15221b987fd8296141c0faa6a79f7c86ca4ce\")\n\t\tassert.Equal(t, \"DEV/deployment-automation\", result.Repo.FullName)\n\t\tassert.Equal(t, \"76797d54bca87db6d1e3e82ee40622c7908aa514\", result.Pipeline.Commit)\n\t\tassert.Equal(t, model.EventPush, result.Pipeline.Event)\n\t})\n\n\tt.Run(\"pull-request merged\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullMerged)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-Event-Key\", \"pr:merged\")\n\n\t\tresult, curCommit, prevCommit, err := parseHook(req, \"https://bitbucket.example.com\")\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, result)\n\t\tassert.Empty(t, curCommit)\n\t\tassert.Empty(t, prevCommit)\n\t\tassert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event)\n\t\tassert.NotNil(t, result.Repo)\n\t\tassert.NotNil(t, result.Pipeline)\n\t\tassert.NotNil(t, result.Payload)\n\t\tassert.Equal(t, \"DEV/deployment-automation\", result.Repo.FullName)\n\t\tassert.Equal(t, \"993203acecdb65ffe947424d0917768b0e5c3903\", result.Pipeline.Commit)\n\t\tassert.Equal(t, model.EventPullClosed, result.Pipeline.Event)\n\t})\n}\n"
  },
  {
    "path": "server/forge/common/event_normalize.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nfunc NormalizeEventReason(in string) string {\n\tswitch in {\n\tcase \"labels_cleared\":\n\t\treturn \"label_cleared\"\n\tcase \"labels_updated\":\n\t\treturn \"label_updated\"\n\tcase \"labels_added\":\n\t\treturn \"label_added\"\n\t}\n\treturn in\n}\n"
  },
  {
    "path": "server/forge/common/status.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"text/template\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc GetPipelineStatusContext(repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) string {\n\tevent := string(pipeline.Event)\n\tif pipeline.Event == model.EventPull {\n\t\tevent = \"pr\"\n\t}\n\n\ttmpl, err := template.New(\"context\").Parse(server.Config.Server.StatusContextFormat)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not create status from template\")\n\t\treturn \"\"\n\t}\n\tvar ctx bytes.Buffer\n\terr = tmpl.Execute(&ctx, map[string]any{\n\t\t\"context\":  server.Config.Server.StatusContext,\n\t\t\"event\":    event,\n\t\t\"workflow\": workflow.Name,\n\t\t\"owner\":    repo.Owner,\n\t\t\"repo\":     repo.Name,\n\t\t\"axis_id\":  workflow.AxisID,\n\t})\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not create status context\")\n\t\treturn \"\"\n\t}\n\n\treturn ctx.String()\n}\n\n// GetPipelineStatusDescription is a helper function that generates a description\n// message for the current pipeline status.\nfunc GetPipelineStatusDescription(status model.StatusValue) string {\n\tswitch status {\n\tcase model.StatusPending:\n\t\treturn \"Pipeline is pending\"\n\tcase model.StatusRunning:\n\t\treturn \"Pipeline is running\"\n\tcase model.StatusSuccess:\n\t\treturn \"Pipeline was successful\"\n\tcase model.StatusFailure, model.StatusError:\n\t\treturn \"Pipeline failed\"\n\tcase model.StatusKilled:\n\t\treturn \"Pipeline was canceled\"\n\tcase model.StatusBlocked:\n\t\treturn \"Pipeline is pending approval\"\n\tcase model.StatusDeclined:\n\t\treturn \"Pipeline was rejected\"\n\tdefault:\n\t\treturn \"unknown status\"\n\t}\n}\n\nfunc GetPipelineStatusURL(repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) string {\n\tif workflow == nil {\n\t\treturn fmt.Sprintf(\"%s/repos/%d/pipeline/%d\", server.Config.Server.Host, repo.ID, pipeline.Number)\n\t}\n\n\treturn fmt.Sprintf(\"%s/repos/%d/pipeline/%d/%d\", server.Config.Server.Host, repo.ID, pipeline.Number, workflow.PID)\n}\n"
  },
  {
    "path": "server/forge/common/status_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestGetPipelineStatusContext(t *testing.T) {\n\torigFormat := server.Config.Server.StatusContextFormat\n\torigCtx := server.Config.Server.StatusContext\n\tdefer func() {\n\t\tserver.Config.Server.StatusContextFormat = origFormat\n\t\tserver.Config.Server.StatusContext = origCtx\n\t}()\n\n\trepo := &model.Repo{Owner: \"user1\", Name: \"repo1\"}\n\tpipeline := &model.Pipeline{Event: model.EventPull}\n\tworkflow := &model.Workflow{Name: \"lint\"}\n\n\tassert.EqualValues(t, \"\", GetPipelineStatusContext(repo, pipeline, workflow))\n\n\tserver.Config.Server.StatusContext = \"ci/woodpecker\"\n\tserver.Config.Server.StatusContextFormat = \"{{ .context }}/{{ .event }}/{{ .workflow }}\"\n\tassert.EqualValues(t, \"ci/woodpecker/pr/lint\", GetPipelineStatusContext(repo, pipeline, workflow))\n\tpipeline.Event = model.EventPush\n\tassert.EqualValues(t, \"ci/woodpecker/push/lint\", GetPipelineStatusContext(repo, pipeline, workflow))\n\n\tserver.Config.Server.StatusContext = \"ci\"\n\tserver.Config.Server.StatusContextFormat = \"{{ .context }}:{{ .owner }}/{{ .repo }}:{{ .event }}:{{ .workflow }}\"\n\tassert.EqualValues(t, \"ci:user1/repo1:push:lint\", GetPipelineStatusContext(repo, pipeline, workflow))\n}\n"
  },
  {
    "path": "server/forge/common/utils.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc ExtractHostFromCloneURL(cloneURL string) (string, error) {\n\tu, err := url.Parse(cloneURL)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif !strings.Contains(u.Host, \":\") {\n\t\treturn u.Host, nil\n\t}\n\n\thost, _, err := net.SplitHostPort(u.Host)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn host, nil\n}\n\nfunc UserToken(ctx context.Context, r *model.Repo, u *model.User) string {\n\tif u != nil {\n\t\treturn u.AccessToken\n\t}\n\n\tuser, err := RepoUser(ctx, r)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not get repo user\")\n\t\treturn \"\"\n\t}\n\treturn user.AccessToken\n}\n\nfunc RepoUser(ctx context.Context, r *model.Repo) (*model.User, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\treturn nil, errors.New(\"could not get store from context\")\n\t}\n\tif r == nil {\n\t\tlog.Error().Msg(\"cannot get user token by empty repo\")\n\t\treturn nil, errors.New(\"cannot get user token by empty repo\")\n\t}\n\tuser, err := _store.GetUser(r.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn user, nil\n}\n\nfunc RepoUserForgeID(ctx context.Context, forgeID int64, remoteID model.ForgeRemoteID) (*model.User, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\treturn nil, errors.New(\"could not get store from context\")\n\t}\n\tr, err := _store.GetRepoForgeID(forgeID, remoteID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn RepoUser(ctx, r)\n}\n"
  },
  {
    "path": "server/forge/common/utils_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage common_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n)\n\nfunc Test_Netrc(t *testing.T) {\n\thost, err := common.ExtractHostFromCloneURL(\"https://git.example.com/foo/bar.git\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"git.example.com\", host)\n}\n"
  },
  {
    "path": "server/forge/forge.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package forge defines the Forge interface for integrating with Git hosting\n// platforms (GitHub, GitLab, Gitea, Forgejo, Bitbucket, etc.).\n//\n// The Forge interface provides a unified abstraction for OAuth authentication,\n// repository management, webhook processing, and status reporting.\npackage forge\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// Forge defines the interface for integrating with Git hosting platforms.\n//\n// Architecture:\n// A Forge instance represents a single forge provider. Woodpecker supports\n// multiple forge instances simultaneously through ForgeManager.\n// Each User and Repo has a ForgeID field associating them with a specific forge.\n//\n// Thread Safety:\n// Implementations must be safe for concurrent use. Methods receive context.Context\n// for cancellation/timeout. Do not maintain user-specific state; user context is\n// passed via *model.User parameter.\n//\n// Authentication:\n// OAuth2-based authentication is assumed. Tokens are refreshed 30 minutes before\n// expiry via the optional Refresher interface.\n//\n// Configuration Fetching:\n// Pipeline configurations retrieved via File() or Dir() from Repo.Config path\n// with fallback to defaults.\n//\n// Error Handling:\n// - types.ErrIgnoreEvent: Skippable webhook events\n// - types.ErrRecordNotExist: Resource not found\n// - types.ErrNotImplemented: Can be used to signal it's not supported\n// - nil Repo/Pipeline: \"No action needed\" (not an error).\ntype Forge interface {\n\t// Name returns the unique identifier of this forge driver.\n\t// Examples: \"github\", \"gitlab\", \"gitea\", \"forgejo\", \"bitbucket\"\n\t// Must be unique and constant across all implementations.\n\tName() string\n\n\t// URL returns the root URL of the forge instance.\n\t// Examples: \"https://github.com\", \"https://gitlab.example.com\"\n\tURL() string\n\n\t// Login authenticates a user via OAuth2.\n\t//\n\t// OAuth Flow:\n\t//  1. Initial call with empty OAuthRequest.Code returns (nil, redirectURL, nil)\n\t//  2. User authorizes at redirectURL\n\t//  3. Second call with OAuthRequest.Code returns (User, redirectURL, nil)\n\t//\n\t// Returned User must contain: Login, Email, Avatar, AccessToken, RefreshToken, Expiry, ForgeRemoteID\n\tLogin(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error)\n\n\t// Teams fetches all team/organization memberships for a user.\n\t// Used to determine if an user is member of an team/organization.\n\t// Should support pagination via ListOptions.\n\t//\n\t// Errors:\n\t//  - Expect types.ErrNotImplemented to be returned if forge doesn't support teams/organizations.\n\tTeams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error)\n\n\t// Repo fetches a single repository.\n\t//\n\t// Lookup Strategy:\n\t// - Prefer lookup by remoteID (forge's internal ID) if provided (more reliable as repos can be renamed)\n\t// - Fallback to owner/name if remoteID empty\n\t//\n\t// Must verify user has at least read access.\n\t// Caller must make sure ForgeID is set.\n\tRepo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error)\n\n\t// Repos fetches all repositories accessible to the user.\n\t// Should include user's permission level in Repo.Perm.\n\t// Should support pagination via ListOptions.\n\t// Caller must make sure ForgeID is set.\n\tRepos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error)\n\n\t// File fetches a single file at a specific commit.\n\t// Primary method for retrieving pipeline configuration files.\n\t// Must fetch at specific commit (b.Commit), not branch head.\n\tFile(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error)\n\n\t// Dir fetches all files in a directory at a specific commit.\n\t// Supports pipeline configurations split across multiple files.\n\t// Should return files only.\n\t//\n\t// Errors:\n\t//  - Expect types.ErrNotImplemented to be returned if not supported by the forge\n\tDir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error)\n\n\t// Status sends workflow status updates to the forge.\n\t// Provides visual feedback in forge UI (commit checks, PR status).\n\t// Failures should be logged but not block pipeline execution.\n\tStatus(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error\n\n\t// Netrc generates .netrc credentials for cloning private repositories.\n\t// May receive nil user for public repos.\n\tNetrc(u *model.User, r *model.Repo) (*model.Netrc, error)\n\n\t// Activate creates a webhook pointing to Woodpecker.\n\t// Called when user activates a repository.\n\t// Must verify user has admin access. Should set webhook secret from r.Hash.\n\t// Configure webhook for all events Hook() can parse.\n\tActivate(ctx context.Context, u *model.User, r *model.Repo, link string) error\n\n\t// Deactivate removes the webhook.\n\t// Should ignore if webhook doesn't exist anymore.\n\tDeactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error\n\n\t// Branches returns all branch names in the repository.\n\t// Should support pagination via ListOptions.\n\t//\n\t// Errors:\n\t//  - Expect types.ErrNotImplemented to be returned if not supported by the forge\n\tBranches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error)\n\n\t// BranchHead returns the latest commit SHA for a branch.\n\t// Is essential for cron feature to work.\n\tBranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error)\n\n\t// PullRequests returns all open pull requests.\n\t// Should support pagination via ListOptions.\n\t//\n\t// Errors:\n\t//  - Expect types.ErrNotImplemented to be returned if not supported by the forge\n\tPullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error)\n\n\t// Hook parses incoming webhook and returns pipeline data.\n\t//\n\t// Webhook Processing Flow:\n\t//  1. HTTP request arrives at /api/hook with forge-specific format\n\t//  2. Webhook token verified against repo.Hash\n\t//  3. Hook() parses webhook and returns (Repo, Pipeline, error)\n\t//\n\t// Return Semantics:\n\t// - (repo, pipeline, nil): Execute pipeline for this event\n\t// - (repo, nil, nil): Valid webhook, no pipeline should run\n\t// - (nil, nil, types.ErrIgnoreEvent): Event ignored (logged)\n\t// - (nil, nil, error): Invalid webhook or parsing error\n\t//\n\t// Must verify webhook signature to prevent spoofing.\n\t// Should return types.ErrIgnoreEvent for non-pipeline events\n\t// (e.g. repository settings changed).\n\tHook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error)\n\n\t// OrgMembership checks if user is member of organization and their permission.\n\t// Should return (Member: false, Admin: false) if not a member.\n\t//\n\t// Errors:\n\t//  - Expect types.ErrNotImplemented to be returned if not supported by the forge\n\tOrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)\n\n\t// Org fetches organization details.\n\t// If identifier is a user, return org with IsUser: true.\n\tOrg(ctx context.Context, u *model.User, org string) (*model.Org, error)\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequest.json",
    "content": "{\n  \"action\": \"opened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"html_url\": \"http://forgejo.golang.org/gordon/hello-world/pull/1\",\n    \"state\": \"open\",\n    \"title\": \"Update the README with new information\",\n    \"body\": \"please merge\",\n    \"user\": {\n      \"id\": 1,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"9353195a19e45482665306e466c832c46560532d\"\n    },\n    \"head\": {\n      \"label\": \"feature/changes\",\n      \"ref\": \"feature/changes\",\n      \"sha\": \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\"\n    }\n  },\n  \"repository\": {\n    \"id\": 35129377,\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"owner\": {\n      \"id\": 1,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"private\": true,\n    \"html_url\": \"http://forgejo.golang.org/gordon/hello-world\",\n    \"clone_url\": \"https://forgejo.golang.org/gordon/hello-world.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"gordon\",\n    \"username\": \"gordon\",\n    \"full_name\": \"Gordon the Gopher\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestAssigneeCleared.json",
    "content": "{\n  \"action\": \"unassigned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [\n      {\n        \"id\": 494011,\n        \"name\": \"Kind/Documentation\",\n        \"color\": \"37474f\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011\"\n      },\n      {\n        \"id\": 494002,\n        \"name\": \"Kind/Enhancement\",\n        \"color\": \"84b6eb\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 22669,\n      \"title\": \"mile v2\"\n    },\n    \"assignee\": null,\n    \"assignees\": null,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestAssigneesAdded.json",
    "content": "{\n  \"action\": \"assigned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@noreply.codeberg.org\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"assignees\": [\n      {\n        \"id\": 2628,\n        \"login\": \"6543\",\n        \"full_name\": \"\",\n        \"email\": \"6543@noreply.codeberg.org\",\n        \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n        \"html_url\": \"https://codeberg.org/6543\",\n        \"visibility\": \"public\"\n      }\n    ],\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestClosed.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 62112,\n    \"url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"title\": \"Adjust file\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"closed\",\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"diff_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1.diff\",\n    \"patch_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1.patch\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"head\": {\n      \"label\": \"anbraten-patch-1\",\n      \"ref\": \"anbraten-patch-1\",\n      \"sha\": \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"merge_base\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n    \"due_date\": null,\n    \"created_at\": \"2023-12-05T18:06:38Z\",\n    \"updated_at\": \"2023-12-05T18:06:43Z\",\n    \"closed_at\": \"2023-12-05T18:06:43Z\",\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 46534,\n    \"owner\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@repo.forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"test-repo\",\n    \"full_name\": \"anbraten/test-repo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 26,\n    \"language\": \"\",\n    \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n    \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n    \"link\": \"\",\n    \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n    \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 1,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-12-05T18:03:55Z\",\n    \"updated_at\": \"2023-12-05T18:06:29Z\",\n    \"archived_at\": \"1970-01-01T00:00:00Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": false,\n    \"has_actions\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"sender\": {\n    \"id\": 26907,\n    \"login\": \"anbraten\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"anbraten@sender.forgejo.com\",\n    \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2021-07-19T23:21:52Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"website\": \"\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 0,\n    \"following_count\": 0,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestEdited.json",
    "content": "{\n  \"action\": \"edited\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 62112,\n    \"url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Adjust file\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"anbraten-patch-1\",\n      \"ref\": \"anbraten-patch-1\",\n      \"sha\": \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\"\n  },\n  \"repository\": {\n    \"id\": 46534,\n    \"owner\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@repo.forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test-repo\",\n    \"full_name\": \"anbraten/test-repo\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n    \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n    \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n    \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 26907,\n    \"login\": \"anbraten\",\n    \"full_name\": \"\",\n    \"email\": \"anbraten@sender.forgejo.com\",\n    \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestLabelAdded.json",
    "content": "{\n  \"action\": \"label_updated\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [\n      {\n        \"id\": 494011,\n        \"name\": \"Kind/Documentation\",\n        \"color\": \"37474f\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011\"\n      },\n      {\n        \"id\": 494002,\n        \"name\": \"Kind/Enhancement\",\n        \"color\": \"84b6eb\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 22669,\n      \"title\": \"mile v2\"\n    },\n    \"assignee\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@noreply.codeberg.org\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"assignees\": [\n      {\n        \"id\": 2628,\n        \"login\": \"6543\",\n        \"full_name\": \"\",\n        \"email\": \"6543@noreply.codeberg.org\",\n        \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n        \"html_url\": \"https://codeberg.org/6543\",\n        \"visibility\": \"public\"\n      }\n    ],\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestLabelsCleared.json",
    "content": "{\n  \"action\": \"label_cleared\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [\n      {\n        \"id\": 494002,\n        \"name\": \"Kind/Enhancement\",\n        \"color\": \"84b6eb\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002\"\n      },\n      {\n        \"id\": 494008,\n        \"name\": \"Kind/Testing\",\n        \"color\": \"795548\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494008\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 22666,\n      \"title\": \"mile v1\"\n    },\n    \"assignee\": null,\n    \"assignees\": null,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestLabelsUpdated.json",
    "content": "{\n  \"action\": \"label_updated\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [\n      {\n        \"id\": 494002,\n        \"name\": \"Kind/Enhancement\",\n        \"color\": \"84b6eb\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002\"\n      },\n      {\n        \"id\": 494008,\n        \"name\": \"Kind/Testing\",\n        \"color\": \"795548\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494008\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 22666,\n      \"title\": \"mile v1\"\n    },\n    \"assignee\": null,\n    \"assignees\": null,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 62112,\n    \"url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"title\": \"Adjust file\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"closed\",\n    \"is_locked\": false,\n    \"comments\": 1,\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n    \"diff_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1.diff\",\n    \"patch_url\": \"https://forgejo.com/anbraten/test-repo/pulls/1.patch\",\n    \"mergeable\": true,\n    \"merged\": true,\n    \"merged_at\": \"2023-12-05T18:35:31Z\",\n    \"merge_commit_sha\": \"f2440f050054df0f8ecabcace648f1683509064c\",\n    \"merged_by\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"f2440f050054df0f8ecabcace648f1683509064c\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"head\": {\n      \"label\": \"anbraten-patch-1\",\n      \"ref\": \"anbraten-patch-1\",\n      \"sha\": \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.forgejo.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n        \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"merge_base\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n    \"due_date\": null,\n    \"created_at\": \"2023-12-05T18:06:38Z\",\n    \"updated_at\": \"2023-12-05T18:35:31Z\",\n    \"closed_at\": \"2023-12-05T18:35:31Z\",\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 46534,\n    \"owner\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.forgejo.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"test-repo\",\n    \"full_name\": \"anbraten/test-repo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 26,\n    \"language\": \"\",\n    \"languages_url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo/languages\",\n    \"html_url\": \"https://forgejo.com/anbraten/test-repo\",\n    \"url\": \"https://forgejo.com/api/v1/repos/anbraten/test-repo\",\n    \"link\": \"\",\n    \"ssh_url\": \"git@forgejo.com:anbraten/test-repo.git\",\n    \"clone_url\": \"https://forgejo.com/anbraten/test-repo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 1,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-12-05T18:03:55Z\",\n    \"updated_at\": \"2023-12-05T18:06:29Z\",\n    \"archived_at\": \"1970-01-01T00:00:00Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": false,\n    \"has_actions\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"sender\": {\n    \"id\": 26907,\n    \"login\": \"anbraten\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"anbraten@noreply.forgejo.com\",\n    \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2021-07-19T23:21:52Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"website\": \"\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 0,\n    \"following_count\": 0,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestMilestoneAdded.json",
    "content": "{\n  \"action\": \"milestoned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": {\n      \"id\": 22669,\n      \"title\": \"mile v2\"\n    },\n    \"assignee\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@noreply.codeberg.org\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"assignees\": [\n      {\n        \"id\": 2628,\n        \"login\": \"6543\",\n        \"full_name\": \"\",\n        \"email\": \"6543@noreply.codeberg.org\",\n        \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n        \"html_url\": \"https://codeberg.org/6543\",\n        \"visibility\": \"public\"\n      }\n    ],\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestMilestoneChanged.json",
    "content": "{\n  \"action\": \"milestoned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [\n      {\n        \"id\": 494011,\n        \"name\": \"Kind/Documentation\",\n        \"color\": \"37474f\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011\"\n      },\n      {\n        \"id\": 494002,\n        \"name\": \"Kind/Enhancement\",\n        \"color\": \"84b6eb\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 22669,\n      \"title\": \"mile v2\"\n    },\n    \"assignee\": null,\n    \"assignees\": null,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestMilestoneCleared.json",
    "content": "{\n  \"action\": \"demilestoned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"visibility\": \"public\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"visibility\": \"public\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"private\": false,\n        \"fork\": false,\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\"\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\"\n  },\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"visibility\": \"public\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"private\": false,\n    \"fork\": false,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"visibility\": \"public\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestReopened.json",
    "content": "{\n  \"action\": \"reopened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"login_name\": \"\",\n      \"source_id\": 0,\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"language\": \"en-US\",\n      \"is_admin\": false,\n      \"last_login\": \"2025-08-05T17:04:55+02:00\",\n      \"created\": \"2019-10-12T05:05:49+02:00\",\n      \"restricted\": false,\n      \"active\": true,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"pronouns\": \"\",\n      \"website\": \"https://mh.obermui.de\",\n      \"description\": \"\\u003ca href=\\\"https://matrix.to/#/@marddl:obermui.de\\\" rel=\\\"nofollow\\\"\\u003e\\u003cimg src=\\\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\\\"\\u003e\\u003c/a\\u003e\\r\\n\\u003ca rel=\\\"me\\\" href=\\\"https://chaos.social/@6543\\\"\\u003eMastodon\\u003c/a\\u003e\",\n      \"visibility\": \"public\",\n      \"followers_count\": 46,\n      \"following_count\": 33,\n      \"starred_repos_count\": 92,\n      \"username\": \"6543\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [],\n    \"requested_reviewers_teams\": [],\n    \"state\": \"open\",\n    \"draft\": false,\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"review_comments\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"diff_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1.diff\",\n    \"patch_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1.patch\",\n    \"mergeable\": false,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"login_name\": \"\",\n          \"source_id\": 0,\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2023-04-02T15:13:07+02:00\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"pronouns\": \"\",\n          \"website\": \"\",\n          \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 0,\n          \"username\": \"test_it\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 34,\n        \"language\": \"\",\n        \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 1,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 0,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-08-27T03:32:56+02:00\",\n        \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n        \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"wiki_branch\": \"master\",\n        \"globally_editable_wiki\": false,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": true,\n        \"has_actions\": false,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_fast_forward_only_merge\": false,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"default_update_style\": \"merge\",\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"object_format_name\": \"sha1\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null,\n        \"topics\": []\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"login_name\": \"\",\n          \"source_id\": 0,\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2023-04-02T15:13:07+02:00\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"pronouns\": \"\",\n          \"website\": \"\",\n          \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 0,\n          \"username\": \"test_it\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 34,\n        \"language\": \"\",\n        \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 1,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 0,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-08-27T03:32:56+02:00\",\n        \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n        \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"wiki_branch\": \"master\",\n        \"globally_editable_wiki\": false,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": true,\n        \"has_actions\": false,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_fast_forward_only_merge\": false,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"default_update_style\": \"merge\",\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"object_format_name\": \"sha1\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null,\n        \"topics\": []\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n    \"due_date\": null,\n    \"created_at\": \"2025-07-29T16:45:09+02:00\",\n    \"updated_at\": \"2025-08-05T17:06:49+02:00\",\n    \"closed_at\": null,\n    \"pin_order\": 0,\n    \"flow\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"login_name\": \"\",\n      \"source_id\": 0,\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-04-02T15:13:07+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"pronouns\": \"\",\n      \"website\": \"\",\n      \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"test_it\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 34,\n    \"language\": \"\",\n    \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 1,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 0,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-08-27T03:32:56+02:00\",\n    \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n    \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"wiki_branch\": \"master\",\n    \"globally_editable_wiki\": false,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": true,\n    \"has_actions\": false,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_fast_forward_only_merge\": false,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"default_update_style\": \"merge\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"object_format_name\": \"sha1\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null,\n    \"topics\": []\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"login_name\": \"\",\n    \"source_id\": 0,\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"pronouns\": \"\",\n    \"website\": \"https://mh.obermui.de\",\n    \"description\": \"\\u003ca href=\\\"https://matrix.to/#/@marddl:obermui.de\\\" rel=\\\"nofollow\\\"\\u003e\\u003cimg src=\\\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\\\"\\u003e\\u003c/a\\u003e\\r\\n\\u003ca rel=\\\"me\\\" href=\\\"https://chaos.social/@6543\\\"\\u003eMastodon\\u003c/a\\u003e\",\n    \"visibility\": \"public\",\n    \"followers_count\": 46,\n    \"following_count\": 33,\n    \"starred_repos_count\": 92,\n    \"username\": \"6543\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPullRequestUpdated.json",
    "content": "{\n  \"action\": \"synchronized\",\n  \"number\": 2,\n  \"pull_request\": {\n    \"id\": 2,\n    \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n    \"number\": 2,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"test\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"test@noreply.localhost\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:05+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"test\"\n    },\n    \"title\": \"New Pull\",\n    \"body\": \"create an awesome pull\",\n    \"labels\": [\n      {\n        \"id\": 8,\n        \"name\": \"Kind/Bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/8\"\n      },\n      {\n        \"id\": 11,\n        \"name\": \"Kind/Security\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"9c27b0\",\n        \"description\": \"This is security issue\",\n        \"url\": \"http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/11\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"open\",\n    \"is_locked\": false,\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n    \"diff_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.diff\",\n    \"patch_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.patch\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n      \"repo_id\": 6\n    },\n    \"head\": {\n      \"label\": \"test-patch-1\",\n      \"ref\": \"test-patch-1\",\n      \"sha\": \"788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25\",\n      \"repo_id\": 6\n    },\n    \"merge_base\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n    \"due_date\": null,\n    \"created_at\": \"2024-02-22T01:38:39+01:00\",\n    \"updated_at\": \"2024-02-22T01:42:03+01:00\",\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 6,\n    \"owner\": {\n      \"id\": 2,\n      \"login\": \"Test-CI\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:48+02:00\",\n      \"prohibit_login\": false,\n      \"visibility\": \"public\",\n      \"username\": \"Test-CI\"\n    },\n    \"name\": \"multi-line-secrets\",\n    \"full_name\": \"Test-CI/multi-line-secrets\",\n    \"description\": \"\",\n    \"private\": false,\n    \"languages_url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages\",\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n    \"url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n    \"clone_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n    \"original_url\": \"\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"object_format_name\": \"\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"test\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"test\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPush.json",
    "content": "{\n  \"ref\": \"refs/heads/main\",\n  \"before\": \"4b2626259b5a97b6b4eab5e6cca66adb986b672b\",\n  \"after\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n  \"compare_url\": \"http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5\",\n  \"commits\": [\n    {\n      \"id\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n      \"message\": \"bump\\n\",\n      \"url\": \"http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5\",\n      \"author\": {\n        \"name\": \"Gordon the Gopher\",\n        \"email\": \"gordon@golang.org\",\n        \"username\": \"gordon\"\n      },\n      \"added\": [\"CHANGELOG.md\"],\n      \"removed\": [],\n      \"modified\": [\"app/controller/application.rb\"]\n    }\n  ],\n  \"repository\": {\n    \"id\": 1,\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"html_url\": \"http://forgejo.golang.org/gordon/hello-world\",\n    \"ssh_url\": \"git@forgejo.golang.org:gordon/hello-world.git\",\n    \"clone_url\": \"http://forgejo.golang.org/gordon/hello-world.git\",\n    \"description\": \"\",\n    \"website\": \"\",\n    \"watchers\": 1,\n    \"owner\": {\n      \"name\": \"gordon\",\n      \"email\": \"gordon@golang.org\",\n      \"login\": \"gordon\",\n      \"username\": \"gordon\"\n    },\n    \"private\": true,\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"pusher\": {\n    \"name\": \"gordon\",\n    \"email\": \"gordon@golang.org\",\n    \"username\": \"gordon\",\n    \"login\": \"gordon\"\n  },\n  \"sender\": {\n    \"login\": \"gordon\",\n    \"id\": 1,\n    \"username\": \"gordon\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPushBranch.json",
    "content": "{\n  \"ref\": \"refs/heads/fdsafdsa\",\n  \"before\": \"0000000000000000000000000000000000000000\",\n  \"after\": \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n  \"compare_url\": \"https://codeberg.org/meisam/woodpecktester/compare/main...28c3613ae62640216bea5e7dc71aa65356e4298b\",\n  \"commits\": [],\n  \"head_commit\": {\n    \"id\": \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n    \"message\": \"Delete '.woodpecker/.check.yml'\\n\",\n    \"url\": \"https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b\",\n    \"author\": {\n      \"name\": \"meisam\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"username\": \"meisam\"\n    },\n    \"committer\": {\n      \"name\": \"meisam\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"username\": \"meisam\"\n    },\n    \"verification\": null,\n    \"timestamp\": \"2022-07-12T21:09:27+02:00\",\n    \"added\": [],\n    \"removed\": [\".woodpecker/.check.yml\"],\n    \"modified\": []\n  },\n  \"repository\": {\n    \"id\": 50820,\n    \"owner\": {\n      \"id\": 14844,\n      \"login\": \"meisam\",\n      \"full_name\": \"\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"avatar_url\": \"https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2020-10-08T11:19:12+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"Materials engineer, physics enthusiast, large collection of the bad programming habits, always happy to fix the old ones and make new mistakes!\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"meisam\",\n      \"permissions\": {\n        \"admin\": true,\n        \"push\": true,\n        \"pull\": true\n      }\n    },\n    \"name\": \"woodpecktester\",\n    \"full_name\": \"meisam/woodpecktester\",\n    \"description\": \"Just for testing the Woodpecker CI and reporting bugs\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 367,\n    \"language\": \"\",\n    \"languages_url\": \"https://codeberg.org/api/v1/repos/meisam/woodpecktester/languages\",\n    \"html_url\": \"https://codeberg.org/meisam/woodpecktester\",\n    \"ssh_url\": \"git@codeberg.org:meisam/woodpecktester.git\",\n    \"clone_url\": \"https://codeberg.org/meisam/woodpecktester.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 0,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2022-07-04T00:34:39+02:00\",\n    \"updated_at\": \"2022-07-24T20:31:29+02:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"default_merge_style\": \"merge\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"pusher\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@obermui.de\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 22,\n    \"following_count\": 16,\n    \"starred_repos_count\": 55,\n    \"username\": \"6543\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@obermui.de\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"visibility\": \"public\",\n    \"followers_count\": 22,\n    \"following_count\": 16,\n    \"starred_repos_count\": 55,\n    \"username\": \"6543\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookPushMulti.json",
    "content": "{\n  \"ref\": \"refs/heads/main\",\n  \"before\": \"6efcf5b7c98f3e7a491675164b7a2e7acac27941\",\n  \"after\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n  \"compare_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453\",\n  \"commits\": [\n    {\n      \"id\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n      \"message\": \"add some text\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:18:07+01:00\",\n      \"added\": [],\n      \"removed\": [],\n      \"modified\": [\"aaa\"]\n    },\n    {\n      \"id\": \"29cd95250404bd007c13b03eabe521196bab98a5\",\n      \"message\": \"rm a a file\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29cd95250404bd007c13b03eabe521196bab98a5\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:17:49+01:00\",\n      \"added\": [],\n      \"removed\": [\"aa\"],\n      \"modified\": []\n    },\n    {\n      \"id\": \"93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91\",\n      \"message\": \"add some a files\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:17:33+01:00\",\n      \"added\": [\"aa\", \"aaa\"],\n      \"removed\": [],\n      \"modified\": []\n    }\n  ],\n  \"total_commits\": 3,\n  \"head_commit\": {\n    \"id\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n    \"message\": \"add some text\\n\",\n    \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453\",\n    \"author\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"test-user\"\n    },\n    \"committer\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"test-user\"\n    },\n    \"verification\": null,\n    \"timestamp\": \"2024-02-22T00:18:07+01:00\",\n    \"added\": [],\n    \"removed\": [],\n    \"modified\": [\"aaa\"]\n  },\n  \"repository\": {\n    \"id\": 6,\n    \"owner\": {\n      \"id\": 2,\n      \"login\": \"Test-CI\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:48+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"Test-CI\"\n    },\n    \"name\": \"multi-line-secrets\",\n    \"full_name\": \"Test-CI/multi-line-secrets\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 35,\n    \"language\": \"\",\n    \"languages_url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages\",\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n    \"url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n    \"clone_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"watchers_count\": 2,\n    \"open_issues_count\": 1,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-10-31T19:53:15+01:00\",\n    \"updated_at\": \"2023-11-02T06:16:34+01:00\",\n    \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"avatar_url\": \"\",\n    \"object_format_name\": \"\"\n  },\n  \"pusher\": {\n    \"id\": 1,\n    \"login\": \"test-user\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"prohibit_login\": false,\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"username\": \"test-user\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"test-user\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"prohibit_login\": false,\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"username\": \"test-user\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookRelease.json",
    "content": "{\n  \"action\": \"published\",\n  \"release\": {\n    \"id\": 48,\n    \"tag_name\": \"0.0.5\",\n    \"target_commitish\": \"main\",\n    \"name\": \"Version 0.0.5\",\n    \"body\": \"\",\n    \"url\": \"https://git.xxx/api/v1/repos/anbraten/demo/releases/48\",\n    \"html_url\": \"https://git.xxx/anbraten/demo/releases/tag/0.0.5\",\n    \"tarball_url\": \"https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz\",\n    \"zipball_url\": \"https://git.xxx/anbraten/demo/archive/0.0.5.zip\",\n    \"draft\": false,\n    \"prerelease\": false,\n    \"created_at\": \"2022-02-09T20:23:05Z\",\n    \"published_at\": \"2022-02-09T20:23:05Z\",\n    \"author\": {\n      \"id\": 1,\n      \"login\": \"anbraten\",\n      \"full_name\": \"Anton Bracke\",\n      \"email\": \"anbraten@noreply.xxx\",\n      \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2018-03-21T10:04:48Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"world\",\n      \"website\": \"https://xxx\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 1,\n      \"following_count\": 1,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"assets\": []\n  },\n  \"repository\": {\n    \"id\": 77,\n    \"owner\": {\n      \"id\": 1,\n      \"login\": \"anbraten\",\n      \"full_name\": \"Anton Bracke\",\n      \"email\": \"anbraten@noreply.xxx\",\n      \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2018-03-21T10:04:48Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"world\",\n      \"website\": \"https://xxx\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 1,\n      \"following_count\": 1,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"demo\",\n    \"full_name\": \"anbraten/demo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": true,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 59,\n    \"html_url\": \"https://git.xxx/anbraten/demo\",\n    \"ssh_url\": \"ssh://git@git.xxx:22/anbraten/demo.git\",\n    \"clone_url\": \"https://git.xxx/anbraten/demo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 1,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 2,\n    \"open_pr_counter\": 2,\n    \"release_counter\": 4,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2021-08-30T20:54:13Z\",\n    \"updated_at\": \"2022-01-09T01:29:23Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": false,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"default_merge_style\": \"squash\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"anbraten\",\n    \"full_name\": \"Anbraten\",\n    \"email\": \"anbraten@noreply.xxx\",\n    \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2018-03-21T10:04:48Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"World\",\n    \"website\": \"https://xxx\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 1,\n    \"following_count\": 1,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/HookTag.json",
    "content": "{\n  \"sha\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n  \"secret\": \"l26Un7G7HXogLAvsyf2hOA4EMARSTsR3\",\n  \"ref\": \"v1.0.0\",\n  \"ref_type\": \"tag\",\n  \"repository\": {\n    \"id\": 12,\n    \"owner\": {\n      \"id\": 4,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"description\": \"a hello world example\",\n    \"private\": true,\n    \"fork\": false,\n    \"html_url\": \"http://forgejo.golang.org/gordon/hello-world\",\n    \"ssh_url\": \"git@forgejo.golang.org:gordon/hello-world.git\",\n    \"clone_url\": \"http://forgejo.golang.org/gordon/hello-world.git\",\n    \"default_branch\": \"main\",\n    \"created_at\": \"2015-10-22T19:32:44Z\",\n    \"updated_at\": \"2016-11-24T13:37:16Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"username\": \"gordon\",\n    \"login\": \"gordon\",\n    \"full_name\": \"Gordon the Gopher\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/handler.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler returns an http.Handler that is capable of handling a variety of mock\n// Forgejo requests and returning mock responses.\nfunc Handler() http.Handler {\n\tgin.SetMode(gin.TestMode)\n\n\te := gin.New()\n\te.GET(\"/api/v1/repos/:owner/:name\", getRepo)\n\te.GET(\"/api/v1/repositories/:id\", getRepoByID)\n\te.GET(\"/api/v1/repos/:owner/:name/raw/:file\", getRepoFile)\n\te.POST(\"/api/v1/repos/:owner/:name/hooks\", createRepoHook)\n\te.GET(\"/api/v1/repos/:owner/:name/hooks\", listRepoHooks)\n\te.DELETE(\"/api/v1/repos/:owner/:name/hooks/:id\", deleteRepoHook)\n\te.POST(\"/api/v1/repos/:owner/:name/statuses/:commit\", createRepoCommitStatus)\n\te.GET(\"/api/v1/repos/:owner/:name/pulls/:index/files\", getPRFiles)\n\te.GET(\"/api/v1/user/repos\", getUserRepos)\n\te.GET(\"/api/v1/version\", getVersion)\n\n\treturn e\n}\n\nfunc listRepoHooks(c *gin.Context) {\n\tpage := c.Query(\"page\")\n\tif page != \"\" && page != \"1\" {\n\t\tc.String(http.StatusOK, \"[]\")\n\t} else {\n\t\tc.String(http.StatusOK, listRepoHookPayloads)\n\t}\n}\n\nfunc getRepo(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc getRepoByID(c *gin.Context) {\n\tswitch c.Param(\"id\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc createRepoCommitStatus(c *gin.Context) {\n\tif c.Param(\"commit\") == \"v1.0.0\" || c.Param(\"commit\") == \"9ecad50\" {\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n\tc.String(http.StatusNotFound, \"\")\n}\n\nfunc getRepoFile(c *gin.Context) {\n\tfile := c.Param(\"file\")\n\tref := c.Query(\"ref\")\n\n\tif file == \"file_not_found\" {\n\t\tc.String(http.StatusNotFound, \"\")\n\t}\n\tif ref == \"v1.0.0\" || ref == \"9ecad50\" {\n\t\tc.String(http.StatusOK, repoFilePayload)\n\t}\n\tc.String(http.StatusNotFound, \"\")\n}\n\nfunc createRepoHook(c *gin.Context) {\n\tin := struct {\n\t\tType string `json:\"type\"`\n\t\tConf struct {\n\t\t\tType string `json:\"content_type\"`\n\t\t\tURL  string `json:\"url\"`\n\t\t} `json:\"config\"`\n\t}{}\n\t_ = c.BindJSON(&in)\n\tif in.Type != \"forgejo\" ||\n\t\tin.Conf.Type != \"json\" ||\n\t\tin.Conf.URL != \"http://localhost\" {\n\t\tc.String(http.StatusInternalServerError, \"\")\n\t\treturn\n\t}\n\n\tc.String(http.StatusOK, \"{}\")\n}\n\nfunc deleteRepoHook(c *gin.Context) {\n\tc.String(http.StatusOK, \"{}\")\n}\n\nfunc getUserRepos(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"token repos_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tpage := c.Query(\"page\")\n\t\tif page != \"\" && page != \"1\" {\n\t\t\tc.String(http.StatusOK, \"[]\")\n\t\t} else {\n\t\t\tc.String(http.StatusOK, userRepoPayload)\n\t\t}\n\t}\n}\n\nfunc getVersion(c *gin.Context) {\n\tc.JSON(http.StatusOK, map[string]any{\"version\": \"1.18.0\"})\n}\n\nfunc getPRFiles(c *gin.Context) {\n\tpage := c.Query(\"page\")\n\tif page == \"1\" {\n\t\tc.String(http.StatusOK, prFilesPayload)\n\t} else {\n\t\tc.String(http.StatusOK, \"[]\")\n\t}\n}\n\nconst listRepoHookPayloads = `\n[\n\t{\n\t\t\"id\": 1,\n\t\t\"type\": \"forgejo\",\n\t\t\"config\": {\n\t\t\t\"content_type\": \"json\",\n\t\t\t\"url\": \"http:\\/\\/localhost\\/hook?access_token=1234567890\"\n\t\t}\n\t}\n]\n`\n\nconst repoPayload = `\n{\n\t\"id\": 5,\n\t\"owner\": {\n\t\t\"login\": \"test_name\",\n\t\t\"email\": \"octocat@github.com\",\n\t\t\"avatar_url\": \"https:\\/\\/secure.gravatar.com\\/avatar\\/8c58a0be77ee441bb8f8595b7f1b4e87\"\n\t},\n\t\"full_name\": \"test_name\\/repo_name\",\n\t\"private\": true,\n\t\"html_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name\",\n\t\"clone_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name.git\",\n\t\"permissions\": {\n\t\t\"admin\": true,\n\t\t\"push\": true,\n\t\t\"pull\": true\n\t}\n}\n`\n\nconst repoFilePayload = `{ platform: linux/amd64 }`\n\nconst userRepoPayload = `\n[\n\t{\n\t\t\"id\": 5,\n\t\t\"owner\": {\n\t\t\t\"login\": \"test_name\",\n\t\t\t\"email\": \"octocat@github.com\",\n\t\t\t\"avatar_url\": \"https:\\/\\/secure.gravatar.com\\/avatar\\/8c58a0be77ee441bb8f8595b7f1b4e87\"\n\t\t},\n\t\t\"full_name\": \"test_name\\/repo_name\",\n\t\t\"private\": true,\n\t\t\"html_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name\",\n\t\t\"clone_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name.git\",\n\t\t\"permissions\": {\n\t\t\t\"admin\": true,\n\t\t\t\"push\": true,\n\t\t\t\"pull\": true\n\t\t}\n\t}\n]\n`\n\nconst prFilesPayload = `\n[\n\t{\n\t\t\"filename\": \"README.md\",\n\t\t\"status\": \"changed\",\n\t\t\"additions\": 2,\n\t\t\"deletions\": 0,\n\t\t\"changes\": 2,\n\t\t\"html_url\": \"http://localhost/username/repo/src/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md\",\n\t\t\"contents_url\": \"http://localhost:3000/api/v1/repos/username/repo/contents/README.md?ref=e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd\",\n\t\t\"raw_url\": \"http://localhost/username/repo/raw/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md\"\n\t}\n]\n`\n"
  },
  {
    "path": "server/forge/forgejo/fixtures/hooks.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport _ \"embed\"\n\n// HookPush is a sample Forgejo push hook.\n//\n//go:embed HookPush.json\nvar HookPush string\n\n// HookPushMulti push multible commits to a branch.\n//\n//go:embed HookPushMulti.json\nvar HookPushMulti string\n\n// HookPushBranch is a sample Forgejo push hook where a new branch was created from an existing commit.\n//\n//go:embed HookPushBranch.json\nvar HookPushBranch string\n\n// HookTag is a sample Forgejo tag hook.\n//\n//go:embed HookTag.json\nvar HookTag string\n\n// HookPullRequest is a sample pull_request webhook payload.\n//\n//go:embed HookPullRequest.json\nvar HookPullRequest string\n\n//go:embed HookPullRequestUpdated.json\nvar HookPullRequestUpdated string\n\n//go:embed HookPullRequestMerged.json\nvar HookPullRequestMerged string\n\n//go:embed HookPullRequestClosed.json\nvar HookPullRequestClosed string\n\n//go:embed HookPullRequestEdited.json\nvar HookPullRequestEdited string\n\n//go:embed HookRelease.json\nvar HookRelease string\n\n//go:embed HookPullRequestAssigneesAdded.json\nvar HookPullRequestAssigneesAdded string\n\n//go:embed HookPullRequestMilestoneAdded.json\nvar HookPullRequestMilestoneAdded string\n\n//go:embed HookPullRequestLabelAdded.json\nvar HookPullRequestLabelAdded string\n\n//go:embed HookPullRequestAssigneeCleared.json\nvar HookPullRequestAssigneeCleared string\n\n//go:embed HookPullRequestMilestoneChanged.json\nvar HookPullRequestMilestoneChanged string\n\n//go:embed HookPullRequestLabelsUpdated.json\nvar HookPullRequestLabelsUpdated string\n\n//go:embed HookPullRequestLabelsCleared.json\nvar HookPullRequestLabelsCleared string\n\n//go:embed HookPullRequestMilestoneCleared.json\nvar HookPullRequestMilestoneCleared string\n\n//go:embed HookPullRequestReopened.json\nvar HookPullRequestReopened string\n"
  },
  {
    "path": "server/forge/forgejo/forgejo.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3\"\n\t\"github.com/rs/zerolog/log\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tauthorizeTokenURL = \"%s/login/oauth/authorize\"\n\taccessTokenURL    = \"%s/login/oauth/access_token\"\n\tdefaultPageSize   = 50\n\tforgejoDevVersion = \"v7.0.2\"\n)\n\ntype Forgejo struct {\n\tid                int64\n\turl               string\n\toauth2URL         string\n\toAuthClientID     string\n\toAuthClientSecret string\n\tskipVerify        bool\n\tpageSize          int\n}\n\n// Opts defines configuration options.\ntype Opts struct {\n\tURL               string // Forgejo server url.\n\tOAuth2URL         string // User-facing Forgejo server url for OAuth2.\n\tOAuthClientID     string // OAuth2 Client ID\n\tOAuthClientSecret string // OAuth2 Client Secret\n\tSkipVerify        bool   // Skip ssl verification.\n}\n\n// New returns a Forge implementation that integrates with Forgejo,\n// an open source Git service written in Go. See https://forgejo.org/\nfunc New(id int64, opts Opts) (forge.Forge, error) {\n\tif opts.OAuth2URL == \"\" {\n\t\topts.OAuth2URL = opts.URL\n\t}\n\n\treturn &Forgejo{\n\t\tid:                id,\n\t\turl:               opts.URL,\n\t\toauth2URL:         opts.OAuth2URL,\n\t\toAuthClientID:     opts.OAuthClientID,\n\t\toAuthClientSecret: opts.OAuthClientSecret,\n\t\tskipVerify:        opts.SkipVerify,\n\t}, nil\n}\n\n// Name returns the string name of this driver.\nfunc (c *Forgejo) Name() string {\n\treturn \"forgejo\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (c *Forgejo) URL() string {\n\treturn c.url\n}\n\nfunc (c *Forgejo) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) {\n\treturn &oauth2.Config{\n\t\t\tClientID:     c.oAuthClientID,\n\t\t\tClientSecret: c.oAuthClientSecret,\n\t\t\tEndpoint: oauth2.Endpoint{\n\t\t\t\tAuthURL:  fmt.Sprintf(authorizeTokenURL, c.oauth2URL),\n\t\t\t\tTokenURL: fmt.Sprintf(accessTokenURL, c.oauth2URL),\n\t\t\t},\n\t\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t\t},\n\n\t\tcontext.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: c.skipVerify},\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t}})\n}\n\n// Login authenticates an account with Forgejo using basic authentication. The\n// Forgejo account details are returned when the user is successfully authenticated.\nfunc (c *Forgejo) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig, oauth2Ctx := c.oauth2Config(ctx)\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\t// check the OAuth code\n\tif len(req.Code) == 0 {\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(oauth2Ctx, req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tclient, err := c.newClientToken(ctx, token.AccessToken)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\taccount, _, err := client.GetMyUserInfo()\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\treturn &model.User{\n\t\tAccessToken:   token.AccessToken,\n\t\tRefreshToken:  token.RefreshToken,\n\t\tExpiry:        token.Expiry.UTC().Unix(),\n\t\tLogin:         account.UserName,\n\t\tEmail:         account.Email,\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.ID)),\n\t\tAvatar:        expandAvatar(c.url, account.AvatarURL),\n\t}, redirectURL, nil\n}\n\n// Refresh refreshes the Forgejo oauth2 access token. If the token is\n// refreshed, the user is updated and a true value is returned.\nfunc (c *Forgejo) Refresh(ctx context.Context, user *model.User) (bool, error) {\n\tconfig, oauth2Ctx := c.oauth2Config(ctx)\n\tconfig.RedirectURL = \"\"\n\n\tsource := config.TokenSource(oauth2Ctx, &oauth2.Token{\n\t\tAccessToken:  user.AccessToken,\n\t\tRefreshToken: user.RefreshToken,\n\t\tExpiry:       time.Unix(user.Expiry, 0),\n\t})\n\n\ttoken, err := source.Token()\n\tif err != nil || len(token.AccessToken) == 0 {\n\t\treturn false, err\n\t}\n\n\tuser.AccessToken = token.AccessToken\n\tuser.RefreshToken = token.RefreshToken\n\tuser.Expiry = token.Expiry.UTC().Unix()\n\treturn true, nil\n}\n\n// Teams is supported by the Forgejo driver.\nfunc (c *Forgejo) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn shared_utils.Paginate(func(page int) ([]*model.Team, error) {\n\t\torgs, _, err := client.ListMyOrgs(\n\t\t\tforgejo.ListOrgsOptions{\n\t\t\t\tListOptions: forgejo.ListOptions{\n\t\t\t\t\tPage:     page,\n\t\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\tteams := make([]*model.Team, 0, len(orgs))\n\t\tfor _, org := range orgs {\n\t\t\tteams = append(teams, toTeam(org, c.url))\n\t\t}\n\t\treturn teams, err\n\t}, -1)\n}\n\n// TeamPerm is not supported by the Forgejo driver.\nfunc (c *Forgejo) TeamPerm(_ *model.User, _ string) (*model.Perm, error) {\n\treturn nil, nil\n}\n\n// Repo returns the Forgejo repository.\nfunc (c *Forgejo) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif remoteID.IsValid() {\n\t\tintID, err := strconv.ParseInt(string(remoteID), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trepo, resp, err := client.GetRepoByID(intID)\n\t\tif err != nil {\n\t\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\treturn toRepo(repo), nil\n\t}\n\n\trepo, resp, err := client.GetRepo(owner, name)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn toRepo(repo), nil\n}\n\n// Repos returns a list of all repositories for the Forgejo account, including\n// organization repositories.\nfunc (c *Forgejo) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trepos, err := shared_utils.Paginate(func(page int) ([]*forgejo.Repository, error) {\n\t\trepos, _, err := client.ListMyRepos(\n\t\t\tforgejo.ListReposOptions{\n\t\t\t\tListOptions: forgejo.ListOptions{\n\t\t\t\t\tPage:     page,\n\t\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\treturn repos, err\n\t}, -1)\n\n\tresult := make([]*model.Repo, 0, len(repos))\n\tfor _, repo := range repos {\n\t\tif repo.Archived {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, toRepo(repo))\n\t}\n\treturn result, err\n}\n\n// File fetches the file from the Forgejo repository and returns its contents.\nfunc (c *Forgejo) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcfg, resp, err := client.GetFile(r.Owner, r.Name, b.Commit, f)\n\tif err != nil && resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t}\n\treturn cfg, err\n}\n\nfunc (c *Forgejo) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {\n\tvar configs []*forge_types.FileMeta\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// List files in repository\n\tcontents, resp, err := client.ListContents(r.Owner, r.Name, b.Commit, f)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tfor _, e := range contents {\n\t\tif e.Type == \"file\" {\n\t\t\tdata, err := c.File(ctx, u, r, b, e.Path)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"multi-pipeline cannot get %s: %w\", e.Path, err)\n\t\t\t}\n\n\t\t\tconfigs = append(configs, &forge_types.FileMeta{\n\t\t\t\tName: e.Path,\n\t\t\t\tData: data,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn configs, nil\n}\n\n// Status is supported by the Forgejo driver.\nfunc (c *Forgejo) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = client.CreateStatus(\n\t\trepo.Owner,\n\t\trepo.Name,\n\t\tpipeline.Commit,\n\t\tforgejo.CreateStatusOption{\n\t\t\tState:       getStatus(workflow.State),\n\t\t\tTargetURL:   common.GetPipelineStatusURL(repo, pipeline, workflow),\n\t\t\tDescription: common.GetPipelineStatusDescription(workflow.State),\n\t\t\tContext:     common.GetPipelineStatusContext(repo, pipeline, workflow),\n\t\t},\n\t)\n\treturn err\n}\n\n// Netrc returns a netrc file capable of authenticating Forgejo requests and\n// cloning Forgejo repositories. The netrc will use the global machine account\n// when configured.\nfunc (c *Forgejo) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\tlogin := \"\"\n\ttoken := \"\"\n\n\tif u != nil {\n\t\tlogin = u.Login\n\t\ttoken = u.AccessToken\n\t}\n\n\thost, err := common.ExtractHostFromCloneURL(r.Clone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Netrc{\n\t\tLogin:    login,\n\t\tPassword: token,\n\t\tMachine:  host,\n\t\tType:     model.ForgeTypeForgejo,\n\t}, nil\n}\n\n// Activate activates the repository by registering post-commit hooks with\n// the Forgejo repository.\nfunc (c *Forgejo) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tconfig := map[string]string{\n\t\t\"url\":          link,\n\t\t\"secret\":       r.Hash,\n\t\t\"content_type\": \"json\",\n\t}\n\thook := forgejo.CreateHookOption{\n\t\tType:   forgejo.HookTypeForgejo,\n\t\tConfig: config,\n\t\tEvents: []string{\"push\", \"create\", \"pull_request\", \"release\"},\n\t\tActive: true,\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, response, err := client.CreateRepoHook(r.Owner, r.Name, hook)\n\tif err != nil {\n\t\tif response != nil {\n\t\t\tif response.StatusCode == http.StatusNotFound {\n\t\t\t\treturn fmt.Errorf(\"could not find repository\")\n\t\t\t}\n\t\t\tif response.StatusCode == http.StatusOK {\n\t\t\t\treturn fmt.Errorf(\"could not find repository, repository was probably renamed\")\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Deactivate deactivates the repository be removing repository push hooks from\n// the Forgejo repository.\nfunc (c *Forgejo) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// make sure a repo rename does not trick us\n\tforgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thooks, err := shared_utils.Paginate(func(page int) ([]*forgejo.Hook, error) {\n\t\thooks, _, err := client.ListRepoHooks(forgeRepo.Owner, forgeRepo.Name, forgejo.ListHooksOptions{\n\t\t\tListOptions: forgejo.ListOptions{\n\t\t\t\tPage:     page,\n\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t},\n\t\t})\n\t\treturn hooks, err\n\t}, -1)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thook := matchingHooks(hooks, link)\n\tif hook != nil {\n\t\t_, err := client.DeleteRepoHook(forgeRepo.Owner, forgeRepo.Name, hook.ID)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (c *Forgejo) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbranches, _, err := client.ListRepoBranches(r.Owner, r.Name,\n\t\tforgejo.ListRepoBranchesOptions{ListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage}})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]string, len(branches))\n\tfor i := range branches {\n\t\tresult[i] = branches[i].Name\n\t}\n\treturn result, err\n}\n\n// BranchHead returns the sha of the head (latest commit) of the specified branch.\nfunc (c *Forgejo) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tb, _, err := client.GetRepoBranch(r.Owner, r.Name, branch)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &model.Commit{\n\t\tSHA:      b.Commit.ID,\n\t\tForgeURL: b.Commit.URL,\n\t}, nil\n}\n\nfunc (c *Forgejo) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpullRequests, _, err := client.ListRepoPullRequests(r.Owner, r.Name, forgejo.ListPullRequestsOptions{\n\t\tListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage},\n\t\tState:       forgejo.StateOpen,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]*model.PullRequest, len(pullRequests))\n\tfor i := range pullRequests {\n\t\tresult[i] = &model.PullRequest{\n\t\t\tIndex: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].Index))),\n\t\t\tTitle: pullRequests[i].Title,\n\t\t}\n\t}\n\treturn result, err\n}\n\n// Hook parses the incoming Forgejo hook and returns the Repository and Pipeline\n// details. If the hook is unsupported nil values are returned.\nfunc (c *Forgejo) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\trepo, pipeline, err := parseHook(r)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == \"\" {\n\t\ttagName := strings.Split(pipeline.Ref, \"/\")[2]\n\t\tsha, err := c.getTagCommitSHA(ctx, repo, tagName)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tpipeline.Commit = sha\n\t}\n\n\tif pipeline != nil && pipeline.IsPullRequest() && len(pipeline.ChangedFiles) == 0 {\n\t\tindex, err := strconv.ParseInt(strings.Split(pipeline.Ref, \"/\")[2], 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tpipeline.ChangedFiles, err = c.getChangedFilesForPR(ctx, repo, index)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"could not get changed files for PR %s#%d\", repo.FullName, index)\n\t\t}\n\t}\n\n\treturn repo, pipeline, nil\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (c *Forgejo) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmember, _, err := client.CheckOrgMembership(owner, u.Login)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !member {\n\t\treturn &model.OrgPerm{}, nil\n\t}\n\n\tperm, _, err := client.GetOrgPermissions(owner, u.Login)\n\tif err != nil {\n\t\treturn &model.OrgPerm{Member: member}, err\n\t}\n\n\treturn &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil\n}\n\nfunc (c *Forgejo) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\torg, _, orgErr := client.GetOrg(owner)\n\tif orgErr == nil && org != nil {\n\t\treturn &model.Org{\n\t\t\tName:    org.UserName,\n\t\t\tPrivate: forgejo.VisibleType(org.Visibility) != forgejo.VisibleTypePublic,\n\t\t}, nil\n\t}\n\n\tuser, _, err := client.GetUserInfo(owner)\n\tif err != nil {\n\t\tif orgErr != nil {\n\t\t\terr = errors.Join(orgErr, err)\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &model.Org{\n\t\tName:    user.UserName,\n\t\tIsUser:  true,\n\t\tPrivate: user.Visibility != forgejo.VisibleTypePublic,\n\t}, nil\n}\n\n// newClientToken returns a Forgejo client with token.\nfunc (c *Forgejo) newClientToken(ctx context.Context, token string) (*forgejo.Client, error) {\n\thttpClient := &http.Client{}\n\tif c.skipVerify {\n\t\thttpClient.Transport = &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t}\n\t}\n\twrappedClient := httputil.WrapClient(httpClient, \"forge-forgejo\")\n\tclient, err := forgejo.NewClient(c.url, forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx))\n\tif err != nil &&\n\t\t(errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), \"Malformed version\")) {\n\t\t// we guess it's a dev forgejo version\n\t\tlog.Error().Err(err).Msgf(\"could not detect forgejo version, assume dev version %s\", forgejoDevVersion)\n\t\tclient, err = forgejo.NewClient(c.url, forgejo.SetForgejoVersion(forgejoDevVersion), forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx))\n\t}\n\treturn client, err\n}\n\n// getStatus is a helper function that converts a Woodpecker\n// status to a Forgejo status.\nfunc getStatus(status model.StatusValue) forgejo.StatusState {\n\tswitch status {\n\tcase model.StatusPending, model.StatusBlocked:\n\t\treturn forgejo.StatusPending\n\tcase model.StatusRunning:\n\t\treturn forgejo.StatusPending\n\tcase model.StatusSuccess:\n\t\treturn forgejo.StatusSuccess\n\tcase model.StatusFailure:\n\t\treturn forgejo.StatusFailure\n\tcase model.StatusKilled:\n\t\treturn forgejo.StatusFailure\n\tcase model.StatusDeclined:\n\t\treturn forgejo.StatusWarning\n\tcase model.StatusError:\n\t\treturn forgejo.StatusError\n\tdefault:\n\t\treturn forgejo.StatusFailure\n\t}\n}\n\nfunc (c *Forgejo) getChangedFilesForPR(ctx context.Context, repo *model.Repo, index int64) ([]string, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn []string{}, nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tforge.Refresh(ctx, c, _store, user)\n\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn shared_utils.Paginate(func(page int) ([]string, error) {\n\t\tforgejoFiles, _, err := client.ListPullRequestFiles(repo.Owner, repo.Name, index,\n\t\t\tforgejo.ListPullRequestFilesOptions{ListOptions: forgejo.ListOptions{Page: page}})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar files []string\n\t\tfor _, file := range forgejoFiles {\n\t\t\tfiles = append(files, file.Filename)\n\t\t}\n\t\treturn files, nil\n\t}, -1)\n}\n\nfunc (c *Forgejo) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn \"\", nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tforge.Refresh(ctx, c, _store, user)\n\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttag, _, err := client.GetTag(repo.Owner, repo.Name, tagName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn tag.Commit.SHA, nil\n}\n\nfunc (c *Forgejo) perPage(ctx context.Context) int {\n\tif c.pageSize == 0 {\n\t\tclient, err := c.newClientToken(ctx, \"\")\n\t\tif err != nil {\n\t\t\treturn defaultPageSize\n\t\t}\n\n\t\tapi, _, err := client.GetGlobalAPISettings()\n\t\tif err != nil {\n\t\t\treturn defaultPageSize\n\t\t}\n\t\tc.pageSize = api.MaxResponseItems\n\t}\n\treturn c.pageSize\n}\n"
  },
  {
    "path": "server/forge/forgejo/forgejo_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestNew(t *testing.T) {\n\tforge, _ := New(1, Opts{\n\t\tURL:        \"http://localhost:8080\",\n\t\tSkipVerify: true,\n\t})\n\n\tf, _ := forge.(*Forgejo)\n\tassert.Equal(t, \"http://localhost:8080\", f.url)\n\tassert.True(t, f.skipVerify)\n}\n\nfunc Test_forgejo(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ts := httptest.NewServer(fixtures.Handler())\n\tdefer s.Close()\n\tc, _ := New(1, Opts{\n\t\tURL:        s.URL,\n\t\tSkipVerify: true,\n\t})\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tctx := store.InjectToContext(t.Context(), mockStore)\n\n\tt.Run(\"netrc with user token\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(fakeUser, fakeRepo)\n\t\tassert.Equal(t, \"forgejo.org\", netrc.Machine)\n\t\tassert.Equal(t, fakeUser.Login, netrc.Login)\n\t\tassert.Equal(t, fakeUser.AccessToken, netrc.Password)\n\t\tassert.Equal(t, model.ForgeTypeForgejo, netrc.Type)\n\t})\n\tt.Run(\"netrc with machine account\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(nil, fakeRepo)\n\t\tassert.Equal(t, \"forgejo.org\", netrc.Machine)\n\t\tassert.Empty(t, netrc.Login)\n\t\tassert.Empty(t, netrc.Password)\n\t})\n\n\tt.Run(\"repository details\", func(t *testing.T) {\n\t\trepo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeRepo.Owner, repo.Owner)\n\t\tassert.Equal(t, fakeRepo.Name, repo.Name)\n\t\tassert.Equal(t, fakeRepo.Owner+\"/\"+fakeRepo.Name, repo.FullName)\n\t\tassert.True(t, repo.IsSCMPrivate)\n\t\tassert.Equal(t, \"http://localhost/test_name/repo_name.git\", repo.Clone)\n\t\tassert.Equal(t, \"http://localhost/test_name/repo_name\", repo.ForgeURL)\n\t})\n\tt.Run(\"repo not found\", func(t *testing.T) {\n\t\t_, err := c.Repo(ctx, fakeUser, \"0\", fakeRepoNotFound.Owner, fakeRepoNotFound.Name)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"repository list\", func(t *testing.T) {\n\t\trepos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10})\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeRepo.ForgeRemoteID, repos[0].ForgeRemoteID)\n\t\tassert.Equal(t, fakeRepo.Owner, repos[0].Owner)\n\t\tassert.Equal(t, fakeRepo.Name, repos[0].Name)\n\t\tassert.Equal(t, fakeRepo.Owner+\"/\"+fakeRepo.Name, repos[0].FullName)\n\t})\n\tt.Run(\"not found error\", func(t *testing.T) {\n\t\t_, err := c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10})\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"register repository\", func(t *testing.T) {\n\t\terr := c.Activate(ctx, fakeUser, fakeRepo, \"http://localhost\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"remove hooks\", func(t *testing.T) {\n\t\terr := c.Deactivate(ctx, fakeUser, fakeRepo, \"http://localhost\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"repository file\", func(t *testing.T) {\n\t\traw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, \".woodpecker.yml\")\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"{ platform: linux/amd64 }\", string(raw))\n\t})\n\n\tt.Run(\"pipeline status\", func(t *testing.T) {\n\t\terr := c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"PR hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullRequest)\n\t\tmockStore.On(\"GetRepoNameFallback\", mock.Anything, mock.Anything, mock.Anything).Return(fakeRepo, nil)\n\t\tmockStore.On(\"GetUser\", mock.Anything).Return(fakeUser, nil)\n\t\tr, b, err := c.Hook(ctx, req)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.EventPull, b.Event)\n\t\tassert.Equal(t, []string{\"README.md\"}, b.ChangedFiles)\n\t})\n}\n\nvar (\n\tfakeUser = &model.User{\n\t\tLogin:       \"someuser\",\n\t\tAccessToken: \"cfcd2084\",\n\t}\n\n\tfakeUserNoRepos = &model.User{\n\t\tLogin:       \"someuser\",\n\t\tAccessToken: \"repos_not_found\",\n\t}\n\n\tfakeRepo = &model.Repo{\n\t\tClone:         \"http://forgejo.org/test_name/repo_name.git\",\n\t\tForgeRemoteID: \"5\",\n\t\tOwner:         \"test_name\",\n\t\tName:          \"repo_name\",\n\t\tFullName:      \"test_name/repo_name\",\n\t}\n\n\tfakeRepoNotFound = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"repo_not_found\",\n\t\tFullName: \"test_name/repo_not_found\",\n\t}\n\n\tfakePipeline = &model.Pipeline{\n\t\tCommit: \"9ecad50\",\n\t}\n\n\tfakeWorkflow = &model.Workflow{\n\t\tName:  \"test\",\n\t\tState: model.StatusSuccess,\n\t}\n)\n"
  },
  {
    "path": "server/forge/forgejo/helper.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// toRepo converts a Forgejo repository to a Woodpecker repository.\nfunc toRepo(from *forgejo.Repository) *model.Repo {\n\tname := strings.Split(from.FullName, \"/\")[1]\n\tavatar := expandAvatar(\n\t\tfrom.HTMLURL,\n\t\tfrom.Owner.AvatarURL,\n\t)\n\treturn &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.ID)),\n\t\tName:          name,\n\t\tOwner:         from.Owner.UserName,\n\t\tFullName:      from.FullName,\n\t\tAvatar:        avatar,\n\t\tForgeURL:      from.HTMLURL,\n\t\tIsSCMPrivate:  from.Private || from.Owner.Visibility != forgejo.VisibleTypePublic,\n\t\tClone:         from.CloneURL,\n\t\tCloneSSH:      from.SSHURL,\n\t\tBranch:        from.DefaultBranch,\n\t\tPerm:          toPerm(from.Permissions),\n\t\tPREnabled:     from.HasPullRequests,\n\t}\n}\n\n// toPerm converts a Forgejo permission to a Woodpecker permission.\nfunc toPerm(from *forgejo.Permission) *model.Perm {\n\treturn &model.Perm{\n\t\tPull:  from.Pull,\n\t\tPush:  from.Push,\n\t\tAdmin: from.Admin,\n\t}\n}\n\n// toTeam converts a Forgejo team to a Woodpecker team.\nfunc toTeam(from *forgejo.Organization, link string) *model.Team {\n\treturn &model.Team{\n\t\tLogin:  from.UserName,\n\t\tAvatar: expandAvatar(link, from.AvatarURL),\n\t}\n}\n\n// pipelineFromPush extracts the Pipeline data from a Forgejo push hook.\nfunc pipelineFromPush(hook *pushHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\n\tvar message string\n\tlink := hook.Compare\n\tif len(hook.Commits) > 0 {\n\t\tmessage = hook.Commits[0].Message\n\t\tif len(hook.Commits) == 1 {\n\t\t\tlink = hook.Commits[0].URL\n\t\t}\n\t} else {\n\t\tmessage = hook.HeadCommit.Message\n\t\tlink = hook.HeadCommit.URL\n\t}\n\n\treturn &model.Pipeline{\n\t\tEvent:        model.EventPush,\n\t\tCommit:       hook.After,\n\t\tRef:          hook.Ref,\n\t\tForgeURL:     link,\n\t\tBranch:       strings.TrimPrefix(hook.Ref, \"refs/heads/\"),\n\t\tMessage:      message,\n\t\tAvatar:       avatar,\n\t\tAuthor:       hook.Sender.UserName,\n\t\tEmail:        hook.Sender.Email,\n\t\tTimestamp:    time.Now().UTC().Unix(),\n\t\tSender:       hook.Sender.UserName,\n\t\tChangedFiles: getChangedFilesFromPushHook(hook),\n\t}\n}\n\nfunc getChangedFilesFromPushHook(hook *pushHook) []string {\n\t// assume a capacity of 4 changed files per commit\n\tfiles := make([]string, 0, len(hook.Commits)*4)\n\tfor _, c := range hook.Commits {\n\t\tfiles = append(files, c.Added...)\n\t\tfiles = append(files, c.Removed...)\n\t\tfiles = append(files, c.Modified...)\n\t}\n\n\tfiles = append(files, hook.HeadCommit.Added...)\n\tfiles = append(files, hook.HeadCommit.Removed...)\n\tfiles = append(files, hook.HeadCommit.Modified...)\n\n\treturn utils.DeduplicateStrings(files)\n}\n\n// pipelineFromTag extracts the Pipeline data from a Forgejo tag hook.\nfunc pipelineFromTag(hook *pushHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\tref := strings.TrimPrefix(hook.Ref, \"refs/tags/\")\n\n\treturn &model.Pipeline{\n\t\tEvent:     model.EventTag,\n\t\tCommit:    hook.Sha,\n\t\tRef:       fmt.Sprintf(\"refs/tags/%s\", ref),\n\t\tForgeURL:  fmt.Sprintf(\"%s/src/tag/%s\", hook.Repo.HTMLURL, ref),\n\t\tMessage:   fmt.Sprintf(\"created tag %s\", ref),\n\t\tAvatar:    avatar,\n\t\tAuthor:    hook.Sender.UserName,\n\t\tSender:    hook.Sender.UserName,\n\t\tEmail:     hook.Sender.Email,\n\t\tTimestamp: time.Now().UTC().Unix(),\n\t}\n}\n\n// pipelineFromPullRequest extracts the Pipeline data from a Forgejo pull_request hook.\nfunc pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.PullRequest.Poster.AvatarURL),\n\t)\n\n\tevent := model.EventPull\n\tswitch hook.Action {\n\tcase actionClose:\n\t\tevent = model.EventPullClosed\n\tcase actionEdited,\n\t\tactionLabelUpdate,\n\t\tactionLabelCleared,\n\t\tactionMilestoned,\n\t\tactionDeMilestoned,\n\t\tactionAssigned,\n\t\tactionUnAssigned:\n\t\tevent = model.EventPullMetadata\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:    event,\n\t\tCommit:   hook.PullRequest.Head.Sha,\n\t\tForgeURL: hook.PullRequest.HTMLURL,\n\t\tRef:      fmt.Sprintf(\"refs/pull/%d/head\", hook.Number),\n\t\tBranch:   hook.PullRequest.Base.Ref,\n\t\tMessage:  hook.PullRequest.Title,\n\t\tAuthor:   hook.PullRequest.Poster.UserName,\n\t\tAvatar:   avatar,\n\t\tSender:   hook.Sender.UserName,\n\t\tEmail:    hook.Sender.Email,\n\t\tTitle:    hook.PullRequest.Title,\n\t\tRefspec: fmt.Sprintf(\"%s:%s\",\n\t\t\thook.PullRequest.Head.Ref,\n\t\t\thook.PullRequest.Base.Ref,\n\t\t),\n\t\tPullRequestLabels:    convertLabels(hook.PullRequest.Labels),\n\t\tPullRequestMilestone: convertMilestone(hook.PullRequest.Milestone),\n\t\tFromFork:             hook.PullRequest.Head.RepoID != hook.PullRequest.Base.RepoID,\n\t}\n\n\tif pipeline.Event == model.EventPullMetadata {\n\t\tpipeline.EventReason = []string{hook.Action}\n\t}\n\n\treturn pipeline\n}\n\nfunc convertMilestone(milestone *forgejo.Milestone) string {\n\tif milestone == nil || milestone.ID == 0 {\n\t\treturn \"\"\n\t}\n\treturn milestone.Title\n}\n\nfunc pipelineFromRelease(hook *releaseHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\n\treturn &model.Pipeline{\n\t\tEvent:        model.EventRelease,\n\t\tRef:          fmt.Sprintf(\"refs/tags/%s\", hook.Release.TagName),\n\t\tForgeURL:     hook.Release.HTMLURL,\n\t\tBranch:       hook.Release.Target,\n\t\tMessage:      fmt.Sprintf(\"created release %s\", hook.Release.Title),\n\t\tAvatar:       avatar,\n\t\tAuthor:       hook.Sender.UserName,\n\t\tSender:       hook.Sender.UserName,\n\t\tEmail:        hook.Sender.Email,\n\t\tIsPrerelease: hook.Release.IsPrerelease,\n\t}\n}\n\n// helper function that parses a push hook from a read closer.\nfunc parsePush(r io.Reader) (*pushHook, error) {\n\tpush := new(pushHook)\n\terr := json.NewDecoder(r).Decode(push)\n\treturn push, err\n}\n\nfunc parsePullRequest(r io.Reader) (*pullRequestHook, error) {\n\tpr := new(pullRequestHook)\n\terr := json.NewDecoder(r).Decode(pr)\n\treturn pr, err\n}\n\nfunc parseRelease(r io.Reader) (*releaseHook, error) {\n\tpr := new(releaseHook)\n\terr := json.NewDecoder(r).Decode(pr)\n\treturn pr, err\n}\n\n// fixMalformedAvatar is a helper function that fixes an avatar url if malformed\n// (currently a known bug with forgejo).\nfunc fixMalformedAvatar(url string) string {\n\tindex := strings.Index(url, \"///\")\n\tif index != -1 {\n\t\treturn url[index+1:]\n\t}\n\tindex = strings.Index(url, \"//avatars/\")\n\tif index != -1 {\n\t\treturn strings.ReplaceAll(url, \"//avatars/\", \"/avatars/\")\n\t}\n\treturn url\n}\n\n// expandAvatar is a helper function that converts a relative avatar URL to the\n// absolute url.\nfunc expandAvatar(repo, rawURL string) string {\n\taURL, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn rawURL\n\t}\n\tif aURL.IsAbs() {\n\t\t// Url is already absolute\n\t\treturn aURL.String()\n\t}\n\n\t// Resolve to base\n\tburl, err := url.Parse(repo)\n\tif err != nil {\n\t\treturn rawURL\n\t}\n\taURL = burl.ResolveReference(aURL)\n\n\treturn aURL.String()\n}\n\n// helper function to return matching hooks.\nfunc matchingHooks(hooks []*forgejo.Hook, rawURL string) *forgejo.Hook {\n\tlink, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tfor _, hook := range hooks {\n\t\tif val, ok := hook.Config[\"url\"]; ok {\n\t\t\thookURL, err := url.Parse(val)\n\t\t\tif err == nil && hookURL.Host == link.Host {\n\t\t\t\treturn hook\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc convertLabels(from []*forgejo.Label) []string {\n\tlabels := make([]string, len(from))\n\tfor i, label := range from {\n\t\tlabels[i] = label.Name\n\t}\n\treturn labels\n}\n"
  },
  {
    "path": "server/forge/forgejo/helper_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\n\t\"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_parsePush(t *testing.T) {\n\tt.Run(\"Should parse push hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, err := parsePush(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"refs/heads/main\", hook.Ref)\n\t\tassert.Equal(t, \"ef98532add3b2feb7a137426bba1248724367df5\", hook.After)\n\t\tassert.Equal(t, \"4b2626259b5a97b6b4eab5e6cca66adb986b672b\", hook.Before)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5\", hook.Compare)\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Pusher.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Pusher.UserName)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\t})\n\tt.Run(\"Should parse tag hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookTag)\n\t\thook, err := parsePush(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"v1.0.0\", hook.Ref)\n\t\tassert.Equal(t, \"ef98532add3b2feb7a137426bba1248724367df5\", hook.Sha)\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, _ := parsePush(buf)\n\t\tpipeline := pipelineFromPush(hook)\n\t\tassert.Equal(t, model.EventPush, pipeline.Event)\n\t\tassert.Equal(t, hook.After, pipeline.Commit)\n\t\tassert.Equal(t, hook.Ref, pipeline.Ref)\n\t\tassert.Equal(t, hook.Commits[0].URL, pipeline.ForgeURL)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, hook.Commits[0].Message, pipeline.Message)\n\t\tassert.Equal(t, \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", pipeline.Avatar)\n\t\tassert.Equal(t, hook.Sender.UserName, pipeline.Author)\n\t\tassert.Equal(t, []string{\"CHANGELOG.md\", \"app/controller/application.rb\"}, pipeline.ChangedFiles)\n\t})\n\n\tt.Run(\"Should return a Repo struct from a push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, _ := parsePush(buf)\n\t\trepo := toRepo(hook.Repo)\n\t\tassert.Equal(t, hook.Repo.Name, repo.Name)\n\t\tassert.Equal(t, hook.Repo.Owner.UserName, repo.Owner)\n\t\tassert.Equal(t, \"gordon/hello-world\", repo.FullName)\n\t\tassert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a tag hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookTag)\n\t\thook, _ := parsePush(buf)\n\t\tpipeline := pipelineFromTag(hook)\n\t\tassert.Equal(t, model.EventTag, pipeline.Event)\n\t\tassert.Equal(t, hook.Sha, pipeline.Commit)\n\t\tassert.Equal(t, \"refs/tags/v1.0.0\", pipeline.Ref)\n\t\tassert.Empty(t, pipeline.Branch)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"created tag v1.0.0\", pipeline.Message)\n\t})\n}\n\nfunc Test_parsePullRequest(t *testing.T) {\n\tt.Run(\"Should parse pull_request hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, err := parsePullRequest(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"opened\", hook.Action)\n\t\tassert.Equal(t, int64(1), hook.Number)\n\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\n\t\tassert.Equal(t, \"Update the README with new information\", hook.PullRequest.Title)\n\t\tassert.Equal(t, \"please merge\", hook.PullRequest.Body)\n\t\tassert.Equal(t, forgejo.StateOpen, hook.PullRequest.State)\n\t\tassert.Equal(t, \"gordon\", hook.PullRequest.Poster.UserName)\n\t\tassert.Equal(t, \"main\", hook.PullRequest.Base.Name)\n\t\tassert.Equal(t, \"main\", hook.PullRequest.Base.Ref)\n\t\tassert.Equal(t, \"feature/changes\", hook.PullRequest.Head.Name)\n\t\tassert.Equal(t, \"feature/changes\", hook.PullRequest.Head.Ref)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a pull_request hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, _ := parsePullRequest(buf)\n\t\tpipeline := pipelineFromPullRequest(hook)\n\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\tassert.Equal(t, hook.PullRequest.Head.Sha, pipeline.Commit)\n\t\tassert.Equal(t, \"refs/pull/1/head\", pipeline.Ref)\n\t\tassert.Equal(t, \"http://forgejo.golang.org/gordon/hello-world/pull/1\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"feature/changes:main\", pipeline.Refspec)\n\t\tassert.Equal(t, hook.PullRequest.Title, pipeline.Message)\n\t\tassert.Equal(t, \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", pipeline.Avatar)\n\t\tassert.Equal(t, hook.PullRequest.Poster.UserName, pipeline.Author)\n\t})\n\n\tt.Run(\"Should return a Repo struct from a pull_request hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, _ := parsePullRequest(buf)\n\t\trepo := toRepo(hook.Repo)\n\t\tassert.Equal(t, hook.Repo.Name, repo.Name)\n\t\tassert.Equal(t, hook.Repo.Owner.UserName, repo.Owner)\n\t\tassert.Equal(t, \"gordon/hello-world\", repo.FullName)\n\t\tassert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL)\n\t})\n}\n\nfunc Test_toPerm(t *testing.T) {\n\tperms := []forgejo.Permission{\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  true,\n\t\t\tPull:  true,\n\t\t},\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  true,\n\t\t\tPull:  false,\n\t\t},\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  false,\n\t\t\tPull:  false,\n\t\t},\n\t}\n\tfor _, from := range perms {\n\t\tperm := toPerm(&from)\n\t\tassert.Equal(t, from.Pull, perm.Pull)\n\t\tassert.Equal(t, from.Push, perm.Push)\n\t\tassert.Equal(t, from.Admin, perm.Admin)\n\t}\n}\n\nfunc Test_toTeam(t *testing.T) {\n\tfrom := &forgejo.Organization{\n\t\tUserName:  \"woodpecker\",\n\t\tAvatarURL: \"/avatars/1\",\n\t}\n\n\tto := toTeam(from, \"http://localhost:80\")\n\tassert.Equal(t, from.UserName, to.Login)\n\tassert.Equal(t, \"http://localhost:80/avatars/1\", to.Avatar)\n}\n\nfunc Test_toRepo(t *testing.T) {\n\tfrom := forgejo.Repository{\n\t\tFullName: \"gophers/hello-world\",\n\t\tOwner: &forgejo.User{\n\t\t\tUserName:  \"gordon\",\n\t\t\tAvatarURL: \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\tCloneURL:      \"http://forgejo.golang.org/gophers/hello-world.git\",\n\t\tHTMLURL:       \"http://forgejo.golang.org/gophers/hello-world\",\n\t\tPrivate:       true,\n\t\tDefaultBranch: \"main\",\n\t\tPermissions:   &forgejo.Permission{Admin: true},\n\t}\n\trepo := toRepo(&from)\n\tassert.Equal(t, from.FullName, repo.FullName)\n\tassert.Equal(t, from.Owner.UserName, repo.Owner)\n\tassert.Equal(t, \"hello-world\", repo.Name)\n\tassert.Equal(t, \"main\", repo.Branch)\n\tassert.Equal(t, from.HTMLURL, repo.ForgeURL)\n\tassert.Equal(t, from.CloneURL, repo.Clone)\n\tassert.Equal(t, from.Owner.AvatarURL, repo.Avatar)\n\tassert.Equal(t, from.Private, repo.IsSCMPrivate)\n\tassert.True(t, repo.Perm.Admin)\n}\n\nfunc Test_fixMalformedAvatar(t *testing.T) {\n\turls := []struct {\n\t\tBefore string\n\t\tAfter  string\n\t}{\n\t\t{\n\t\t\t\"http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"http://forgejo.golang.org/avatars/1\",\n\t\t\t\"http://forgejo.golang.org/avatars/1\",\n\t\t},\n\t\t{\n\t\t\t\"http://forgejo.golang.org//avatars/1\",\n\t\t\t\"http://forgejo.golang.org/avatars/1\",\n\t\t},\n\t}\n\n\tfor _, url := range urls {\n\t\tgot := fixMalformedAvatar(url.Before)\n\t\tassert.Equal(t, url.After, got)\n\t}\n}\n\nfunc Test_expandAvatar(t *testing.T) {\n\turls := []struct {\n\t\tBefore string\n\t\tAfter  string\n\t}{\n\t\t{\n\t\t\t\"/avatars/1\",\n\t\t\t\"http://forgejo.io/avatars/1\",\n\t\t},\n\t\t{\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"/forgejo/avatars/2\",\n\t\t\t\"http://forgejo.io/forgejo/avatars/2\",\n\t\t},\n\t}\n\n\trepo := \"http://forgejo.io/foo/bar\"\n\tfor _, url := range urls {\n\t\tgot := expandAvatar(repo, url.Before)\n\t\tassert.Equal(t, url.After, got)\n\t}\n}\n"
  },
  {
    "path": "server/forge/forgejo/parse.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\thookEvent       = \"X-Forgejo-Event\"\n\thookPush        = \"push\"\n\thookCreated     = \"create\"\n\thookPullRequest = \"pull_request\"\n\thookRelease     = \"release\"\n\n\tactionOpen         = \"opened\"\n\tactionSync         = \"synchronized\"\n\tactionClose        = \"closed\"\n\tactionEdited       = \"edited\"\n\tactionLabelUpdate  = \"label_updated\"\n\tactionLabelCleared = \"label_cleared\"\n\tactionMilestoned   = \"milestoned\"\n\tactionDeMilestoned = \"demilestoned\"\n\tactionAssigned     = \"assigned\"\n\tactionUnAssigned   = \"unassigned\"\n\tactionReopen       = \"reopened\"\n\n\trefBranch = \"branch\"\n\trefTag    = \"tag\"\n)\n\nvar actionList = []string{\n\tactionOpen,\n\tactionSync,\n\tactionClose,\n\tactionEdited,\n\tactionLabelUpdate,\n\tactionMilestoned,\n\tactionDeMilestoned,\n\tactionLabelCleared,\n\tactionAssigned,\n\tactionUnAssigned,\n\tactionReopen,\n}\n\nfunc supportedAction(action string) bool {\n\treturn slices.Contains(actionList, action)\n}\n\n// parseHook parses a Forgejo hook from an http.Request and returns\n// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.\nfunc parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\thookType := r.Header.Get(hookEvent)\n\tswitch hookType {\n\tcase hookPush:\n\t\treturn parsePushHook(r.Body)\n\tcase hookCreated:\n\t\treturn parseCreatedHook(r.Body)\n\tcase hookPullRequest:\n\t\treturn parsePullRequestHook(r.Body)\n\tcase hookRelease:\n\t\treturn parseReleaseHook(r.Body)\n\t}\n\tlog.Debug().Msgf(\"unsupported hook type: '%s'\", hookType)\n\treturn nil, nil, &types.ErrIgnoreEvent{Event: hookType}\n}\n\n// parsePushHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {\n\tpush, err := parsePush(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// ignore push events for tags\n\tif strings.HasPrefix(push.Ref, \"refs/tags/\") {\n\t\treturn nil, nil, nil\n\t}\n\n\t// TODO is this even needed?\n\tif push.RefType == refBranch {\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(push.Repo)\n\tpipeline = pipelineFromPush(push)\n\treturn repo, pipeline, err\n}\n\n// parseCreatedHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {\n\tpush, err := parsePush(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif push.RefType != refTag {\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(push.Repo)\n\tpipeline = pipelineFromTag(push)\n\treturn repo, pipeline, nil\n}\n\n// parsePullRequestHook parses a pull_request hook and returns the Repo and Pipeline details.\nfunc parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {\n\tvar (\n\t\trepo     *model.Repo\n\t\tpipeline *model.Pipeline\n\t)\n\n\tpr, err := parsePullRequest(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Only trigger pipelines for supported event types\n\tif !supportedAction(pr.Action) {\n\t\tlog.Debug().Msgf(\"pull_request action is '%s'. Only '%s' are supported\", pr.Action, strings.Join(actionList, \"', '\"))\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(pr.Repo)\n\tpipeline = pipelineFromPullRequest(pr)\n\n\t// all other actions return the state of labels after the actions where done ... so we should too\n\tif pr.Action == actionLabelCleared {\n\t\tpipeline.PullRequestLabels = []string{}\n\t}\n\tif pr.Action == actionDeMilestoned {\n\t\tpipeline.PullRequestMilestone = \"\"\n\t}\n\n\tfor i := range pipeline.EventReason {\n\t\tpipeline.EventReason[i] = common.NormalizeEventReason(pipeline.EventReason[i])\n\t}\n\n\treturn repo, pipeline, err\n}\n\n// parseReleaseHook parses a release hook and returns the Repo and Pipeline details.\nfunc parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {\n\tvar (\n\t\trepo     *model.Repo\n\t\tpipeline *model.Pipeline\n\t)\n\n\trelease, err := parseRelease(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\trepo = toRepo(release.Repo)\n\tpipeline = pipelineFromRelease(release)\n\treturn repo, pipeline, err\n}\n"
  },
  {
    "path": "server/forge/forgejo/parse_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestForgejoParser(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tdata  string\n\t\tevent string\n\t\terr   error\n\t\trepo  *model.Repo\n\t\tpipe  *model.Pipeline\n\t}{\n\t\t{\n\t\t\tname:  \"should ignore unsupported hook events\",\n\t\t\tdata:  fixtures.HookPullRequest,\n\t\t\tevent: \"issues\",\n\t\t\terr:   &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should handle a push hook\",\n\t\t\tdata:  fixtures.HookPushBranch,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"50820\",\n\t\t\t\tOwner:         \"meisam\",\n\t\t\t\tName:          \"woodpecktester\",\n\t\t\t\tFullName:      \"meisam/woodpecktester\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/meisam/woodpecktester\",\n\t\t\t\tClone:         \"https://codeberg.org/meisam/woodpecktester.git\",\n\t\t\t\tCloneSSH:      \"git@codeberg.org:meisam/woodpecktester.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"6543\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n\t\t\t\tBranch:       \"fdsafdsa\",\n\t\t\t\tRef:          \"refs/heads/fdsafdsa\",\n\t\t\t\tMessage:      \"Delete '.woodpecker/.check.yml'\\n\",\n\t\t\t\tSender:       \"6543\",\n\t\t\t\tAvatar:       \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:        \"6543@obermui.de\",\n\t\t\t\tForgeURL:     \"https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b\",\n\t\t\t\tChangedFiles: []string{\".woodpecker/.check.yml\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should extract repository and pipeline details\",\n\t\t\tdata:  fixtures.HookPush,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"1\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"http://forgejo.golang.org/gordon/hello-world\",\n\t\t\t\tForgeURL:      \"http://forgejo.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"http://forgejo.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"git@forgejo.golang.org:gordon/hello-world.git\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"gordon\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tBranch:       \"main\",\n\t\t\t\tRef:          \"refs/heads/main\",\n\t\t\t\tMessage:      \"bump\\n\",\n\t\t\t\tSender:       \"gordon\",\n\t\t\t\tAvatar:       \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:        \"gordon@golang.org\",\n\t\t\t\tForgeURL:     \"http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tChangedFiles: []string{\"CHANGELOG.md\", \"app/controller/application.rb\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should handle multi commit push\",\n\t\t\tdata:  fixtures.HookPushMulti,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"6\",\n\t\t\t\tOwner:         \"Test-CI\",\n\t\t\t\tName:          \"multi-line-secrets\",\n\t\t\t\tFullName:      \"Test-CI/multi-line-secrets\",\n\t\t\t\tAvatar:        \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n\t\t\t\tForgeURL:      \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n\t\t\t\tClone:         \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"test-user\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"29be01c073851cf0db0c6a466e396b725a670453\",\n\t\t\t\tBranch:       \"main\",\n\t\t\t\tRef:          \"refs/heads/main\",\n\t\t\t\tMessage:      \"add some text\\n\",\n\t\t\t\tSender:       \"test-user\",\n\t\t\t\tAvatar:       \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n\t\t\t\tEmail:        \"test@noreply.localhost\",\n\t\t\t\tForgeURL:     \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453\",\n\t\t\t\tChangedFiles: []string{\"aaa\", \"aa\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"tag event should handle a tag hook\",\n\t\t\tdata:  fixtures.HookTag,\n\t\t\tevent: \"create\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"12\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tForgeURL:      \"http://forgejo.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"http://forgejo.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"git@forgejo.golang.org:gordon/hello-world.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"gordon\",\n\t\t\t\tEvent:    \"tag\",\n\t\t\t\tCommit:   \"ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tRef:      \"refs/tags/v1.0.0\",\n\t\t\t\tMessage:  \"created tag v1.0.0\",\n\t\t\t\tSender:   \"gordon\",\n\t\t\t\tAvatar:   \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:    \"gordon@golang.org\",\n\t\t\t\tForgeURL: \"http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR hook when PR got created\",\n\t\t\tdata:  fixtures.HookPullRequest,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"35129377\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tForgeURL:      \"http://forgejo.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"https://forgejo.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"gordon\",\n\t\t\t\tEvent:             \"pull_request\",\n\t\t\t\tCommit:            \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"feature/changes:main\",\n\t\t\t\tTitle:             \"Update the README with new information\",\n\t\t\t\tMessage:           \"Update the README with new information\",\n\t\t\t\tSender:            \"gordon\",\n\t\t\t\tAvatar:            \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:             \"gordon@golang.org\",\n\t\t\t\tForgeURL:          \"http://forgejo.golang.org/gordon/hello-world/pull/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request reopen events should handle a PR as it was first created\",\n\t\t\tdata:  fixtures.HookPullRequestReopened,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  false,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"6543\",\n\t\t\t\tEvent:             \"pull_request\",\n\t\t\t\tCommit:            \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"6543-patch-1:main\",\n\t\t\t\tTitle:             \"Some ned more AAAA\",\n\t\t\t\tMessage:           \"Some ned more AAAA\",\n\t\t\t\tSender:            \"6543\",\n\t\t\t\tAvatar:            \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:             \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:          \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR hook when PR got updated\",\n\t\t\tdata:  fixtures.HookPullRequestUpdated,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"6\",\n\t\t\t\tOwner:         \"Test-CI\",\n\t\t\t\tName:          \"multi-line-secrets\",\n\t\t\t\tFullName:      \"Test-CI/multi-line-secrets\",\n\t\t\t\tAvatar:        \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n\t\t\t\tForgeURL:      \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n\t\t\t\tClone:         \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  false,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"test\",\n\t\t\t\tEvent:    \"pull_request\",\n\t\t\t\tCommit:   \"788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25\",\n\t\t\t\tBranch:   \"main\",\n\t\t\t\tRef:      \"refs/pull/2/head\",\n\t\t\t\tRefspec:  \"test-patch-1:main\",\n\t\t\t\tTitle:    \"New Pull\",\n\t\t\t\tMessage:  \"New Pull\",\n\t\t\t\tSender:   \"test\",\n\t\t\t\tAvatar:   \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n\t\t\t\tEmail:    \"test@noreply.localhost\",\n\t\t\t\tForgeURL: \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n\t\t\t\tPullRequestLabels: []string{\n\t\t\t\t\t\"Kind/Bug\",\n\t\t\t\t\t\"Kind/Security\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR edited hook when PR got edited\",\n\t\t\tdata:  fixtures.HookPullRequestEdited,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"46534\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"test-repo\",\n\t\t\t\tFullName:      \"anbraten/test-repo\",\n\t\t\t\tAvatar:        \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tForgeURL:      \"https://forgejo.com/anbraten/test-repo\",\n\t\t\t\tClone:         \"https://forgejo.com/anbraten/test-repo.git\",\n\t\t\t\tCloneSSH:      \"git@forgejo.com:anbraten/test-repo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"anbraten\",\n\t\t\t\tEvent:             \"pull_request_metadata\",\n\t\t\t\tEventReason:       []string{\"edited\"},\n\t\t\t\tCommit:            \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"anbraten-patch-1:main\",\n\t\t\t\tTitle:             \"Adjust file\",\n\t\t\t\tMessage:           \"Adjust file\",\n\t\t\t\tSender:            \"anbraten\",\n\t\t\t\tAvatar:            \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tEmail:             \"anbraten@sender.forgejo.com\",\n\t\t\t\tForgeURL:          \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR closed hook when PR got closed\",\n\t\t\tdata:  fixtures.HookPullRequestClosed,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"46534\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"test-repo\",\n\t\t\t\tFullName:      \"anbraten/test-repo\",\n\t\t\t\tAvatar:        \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tForgeURL:      \"https://forgejo.com/anbraten/test-repo\",\n\t\t\t\tClone:         \"https://forgejo.com/anbraten/test-repo.git\",\n\t\t\t\tCloneSSH:      \"git@forgejo.com:anbraten/test-repo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"anbraten\",\n\t\t\t\tEvent:             \"pull_request_closed\",\n\t\t\t\tCommit:            \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"anbraten-patch-1:main\",\n\t\t\t\tTitle:             \"Adjust file\",\n\t\t\t\tMessage:           \"Adjust file\",\n\t\t\t\tSender:            \"anbraten\",\n\t\t\t\tAvatar:            \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tEmail:             \"anbraten@sender.forgejo.com\",\n\t\t\t\tForgeURL:          \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR closed hook when PR was merged\",\n\t\t\tdata:  fixtures.HookPullRequestMerged,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"46534\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"test-repo\",\n\t\t\t\tFullName:      \"anbraten/test-repo\",\n\t\t\t\tAvatar:        \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tForgeURL:      \"https://forgejo.com/anbraten/test-repo\",\n\t\t\t\tClone:         \"https://forgejo.com/anbraten/test-repo.git\",\n\t\t\t\tCloneSSH:      \"git@forgejo.com:anbraten/test-repo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"anbraten\",\n\t\t\t\tEvent:             \"pull_request_closed\",\n\t\t\t\tCommit:            \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"anbraten-patch-1:main\",\n\t\t\t\tTitle:             \"Adjust file\",\n\t\t\t\tMessage:           \"Adjust file\",\n\t\t\t\tSender:            \"anbraten\",\n\t\t\t\tAvatar:            \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tEmail:             \"anbraten@noreply.forgejo.com\",\n\t\t\t\tForgeURL:          \"https://forgejo.com/anbraten/test-repo/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"release events should handle release hook\",\n\t\t\tdata:  fixtures.HookRelease,\n\t\t\tevent: \"release\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"77\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"demo\",\n\t\t\t\tFullName:      \"anbraten/demo\",\n\t\t\t\tAvatar:        \"https://git.xxx/user/avatar/anbraten/-1\",\n\t\t\t\tForgeURL:      \"https://git.xxx/anbraten/demo\",\n\t\t\t\tClone:         \"https://git.xxx/anbraten/demo.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@git.xxx:22/anbraten/demo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"anbraten\",\n\t\t\t\tEvent:    \"release\",\n\t\t\t\tBranch:   \"main\",\n\t\t\t\tRef:      \"refs/tags/0.0.5\",\n\t\t\t\tMessage:  \"created release Version 0.0.5\",\n\t\t\t\tSender:   \"anbraten\",\n\t\t\t\tAvatar:   \"https://git.xxx/user/avatar/anbraten/-1\",\n\t\t\t\tEmail:    \"anbraten@noreply.xxx\",\n\t\t\t\tForgeURL: \"https://git.xxx/anbraten/demo/releases/tag/0.0.5\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR assignees added hook when assignees are added\",\n\t\t\tdata:  fixtures.HookPullRequestAssigneesAdded,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"6543\",\n\t\t\t\tEvent:             \"pull_request_metadata\",\n\t\t\t\tEventReason:       []string{\"assigned\"},\n\t\t\t\tCommit:            \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"6543-patch-1:main\",\n\t\t\t\tTitle:             \"Some ned more AAAA\",\n\t\t\t\tMessage:           \"Some ned more AAAA\",\n\t\t\t\tSender:            \"6543\",\n\t\t\t\tAvatar:            \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:             \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:          \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t\tChangedFiles:      nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR milestone added hook when milestone is added\",\n\t\t\tdata:  fixtures.HookPullRequestMilestoneAdded,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"milestoned\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{},\n\t\t\t\tPullRequestMilestone: \"mile v2\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR label updated hook when labels are updated\",\n\t\t\tdata:  fixtures.HookPullRequestLabelAdded,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"label_updated\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{\"Kind/Documentation\", \"Kind/Enhancement\"},\n\t\t\t\tPullRequestMilestone: \"mile v2\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR assignee cleared hook when assignee is removed\",\n\t\t\tdata:  fixtures.HookPullRequestAssigneeCleared,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"unassigned\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{\"Kind/Documentation\", \"Kind/Enhancement\"},\n\t\t\t\tPullRequestMilestone: \"mile v2\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR milestone changed hook when milestone is changed\",\n\t\t\tdata:  fixtures.HookPullRequestMilestoneChanged,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"milestoned\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{\"Kind/Documentation\", \"Kind/Enhancement\"},\n\t\t\t\tPullRequestMilestone: \"mile v2\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR labels updated hook when labels are updated\",\n\t\t\tdata:  fixtures.HookPullRequestLabelsUpdated,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"label_updated\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{\"Kind/Enhancement\", \"Kind/Testing\"},\n\t\t\t\tPullRequestMilestone: \"mile v1\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR labels cleared hook when labels are cleared\",\n\t\t\tdata:  fixtures.HookPullRequestLabelsCleared,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"6543\",\n\t\t\t\tEvent:                \"pull_request_metadata\",\n\t\t\t\tEventReason:          []string{\"label_cleared\"},\n\t\t\t\tCommit:               \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/1/head\",\n\t\t\t\tRefspec:              \"6543-patch-1:main\",\n\t\t\t\tTitle:                \"Some ned more AAAA\",\n\t\t\t\tMessage:              \"Some ned more AAAA\",\n\t\t\t\tSender:               \"6543\",\n\t\t\t\tAvatar:               \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:                \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:             \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels:    []string{},\n\t\t\t\tPullRequestMilestone: \"mile v1\",\n\t\t\t\tChangedFiles:         nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR milestone cleared hook when milestone is removed\",\n\t\t\tdata:  fixtures.HookPullRequestMilestoneCleared,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"6543\",\n\t\t\t\tEvent:             \"pull_request_metadata\",\n\t\t\t\tEventReason:       []string{\"demilestoned\"},\n\t\t\t\tCommit:            \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"6543-patch-1:main\",\n\t\t\t\tTitle:             \"Some ned more AAAA\",\n\t\t\t\tMessage:           \"Some ned more AAAA\",\n\t\t\t\tSender:            \"6543\",\n\t\t\t\tAvatar:            \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:             \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:          \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t\tChangedFiles:      nil,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(http.MethodPost, \"/api/hook\", bytes.NewBufferString(tc.data))\n\t\t\treq.Header = http.Header{}\n\t\t\treq.Header.Set(hookEvent, tc.event)\n\t\t\tr, p, err := parseHook(req)\n\t\t\tif tc.err != nil {\n\t\t\t\tassert.ErrorIs(t, err, tc.err)\n\t\t\t} else if assert.NoError(t, err) {\n\t\t\t\tassert.EqualValues(t, tc.repo, r)\n\t\t\t\tp.Timestamp = 0\n\t\t\t\tassert.EqualValues(t, tc.pipe, p)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/forge/forgejo/types.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forgejo\n\nimport \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3\"\n\ntype pushHook struct {\n\tSha     string `json:\"sha\"`\n\tRef     string `json:\"ref\"`\n\tBefore  string `json:\"before\"`\n\tAfter   string `json:\"after\"`\n\tCompare string `json:\"compare_url\"`\n\tRefType string `json:\"ref_type\"`\n\n\tPusher *forgejo.User `json:\"pusher\"`\n\n\tRepo *forgejo.Repository `json:\"repository\"`\n\n\tCommits []forgejo.PayloadCommit `json:\"commits\"`\n\n\tHeadCommit forgejo.PayloadCommit `json:\"head_commit\"`\n\n\tSender *forgejo.User `json:\"sender\"`\n}\n\ntype pullRequestHook struct {\n\tAction      string               `json:\"action\"`\n\tNumber      int64                `json:\"number\"`\n\tPullRequest *forgejo.PullRequest `json:\"pull_request\"`\n\tRepo        *forgejo.Repository  `json:\"repository\"`\n\tSender      *forgejo.User        `json:\"sender\"`\n}\n\ntype releaseHook struct {\n\tAction  string              `json:\"action\"`\n\tRepo    *forgejo.Repository `json:\"repository\"`\n\tSender  *forgejo.User       `json:\"sender\"`\n\tRelease *forgejo.Release\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequest.json",
    "content": "{\n  \"action\": \"opened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"html_url\": \"http://gitea.golang.org/gordon/hello-world/pull/1\",\n    \"state\": \"open\",\n    \"title\": \"Update the README with new information\",\n    \"body\": \"please merge\",\n    \"user\": {\n      \"id\": 1,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"9353195a19e45482665306e466c832c46560532d\"\n    },\n    \"head\": {\n      \"label\": \"feature/changes\",\n      \"ref\": \"feature/changes\",\n      \"sha\": \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\"\n    }\n  },\n  \"repository\": {\n    \"id\": 35129377,\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"owner\": {\n      \"id\": 1,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"private\": true,\n    \"html_url\": \"http://gitea.golang.org/gordon/hello-world\",\n    \"clone_url\": \"https://gitea.golang.org/gordon/hello-world.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"gordon\",\n    \"username\": \"gordon\",\n    \"full_name\": \"Gordon the Gopher\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestAddLabel.json",
    "content": "{\n  \"action\": \"label_updated\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      },\n      {\n        \"id\": 297,\n        \"name\": \"help wanted\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"128a0c\",\n        \"description\": \"Need some help\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestAddMile.json",
    "content": "{\n  \"action\": \"milestoned\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      },\n      {\n        \"id\": 297,\n        \"name\": \"help wanted\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"128a0c\",\n        \"description\": \"Need some help\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 277,\n      \"title\": \"new mile\",\n      \"state\": \"open\",\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"closed_at\": null,\n      \"due_on\": null\n    },\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestAddReviewRequest.json",
    "content": "{\n  \"action\": \"review_requested\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestAssigneesAdded.json",
    "content": "{\n  \"action\": \"assigned\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignee\": {\n      \"id\": 6,\n      \"login\": \"lilly\",\n      \"full_name\": \"Lilly Apple\",\n      \"email\": \"lilly@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/a02de81c8ee997fc0aff3c6ebe18841903a75fd6\",\n      \"html_url\": \"https://gitea.com/lilly\",\n      \"visibility\": \"public\",\n      \"username\": \"lilly\"\n    },\n    \"assignees\": [\n      {\n        \"id\": 6,\n        \"login\": \"lilly\",\n        \"full_name\": \"Lilly Apple\",\n        \"email\": \"lilly@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/a02de81c8ee997fc0aff3c6ebe18841903a75fd6\",\n        \"html_url\": \"https://gitea.com/lilly\",\n        \"visibility\": \"public\",\n        \"username\": \"lilly\"\n      }\n    ],\n    \"requested_reviewers\": [\n      {\n        \"id\": 349,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"draft\": false,\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@noreply.example.org\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@noreply.example.org\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\"\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 349,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 349,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestAssigneesRemoved.json",
    "content": "{\n  \"action\": \"unassigned\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 349,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"mergeable\": true,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@noreply.example.org\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\",\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@noreply.example.org\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"avatar_url\": \"\",\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\"\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 349,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\",\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 349,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestChangeBody.json",
    "content": "{\n  \"action\": \"edited\",\n  \"number\": 7,\n  \"changes\": {\n    \"body\": {\n      \"from\": \"\"\n    }\n  },\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestChangeLabel.json",
    "content": "{\n  \"action\": \"label_updated\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestChangeMile.json",
    "content": "{\n  \"action\": \"milestoned\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      },\n      {\n        \"id\": 297,\n        \"name\": \"help wanted\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"128a0c\",\n        \"description\": \"Need some help\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 273,\n      \"title\": \"closed mile\",\n      \"state\": \"closed\",\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"closed_at\": \"2025-05-28T03:13:46+02:00\",\n      \"due_on\": null\n    },\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestChangeTitle.json",
    "content": "{\n  \"action\": \"edited\",\n  \"number\": 7,\n  \"changes\": {\n    \"title\": {\n      \"from\": \"Update .woodpecker.yml\"\n    }\n  },\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"Edit pull title :D\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestClosed.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 62112,\n    \"url\": \"https://gitea.com/anbraten/test-repo/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@gitea.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"title\": \"Adjust file\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"closed\",\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"html_url\": \"https://gitea.com/anbraten/test-repo/pulls/1\",\n    \"diff_url\": \"https://gitea.com/anbraten/test-repo/pulls/1.diff\",\n    \"patch_url\": \"https://gitea.com/anbraten/test-repo/pulls/1.patch\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.gitea.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n        \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"head\": {\n      \"label\": \"anbraten-patch-1\",\n      \"ref\": \"anbraten-patch-1\",\n      \"sha\": \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.gitea.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n        \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"merge_base\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n    \"due_date\": null,\n    \"created_at\": \"2023-12-05T18:06:38Z\",\n    \"updated_at\": \"2023-12-05T18:06:43Z\",\n    \"closed_at\": \"2023-12-05T18:06:43Z\",\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 46534,\n    \"owner\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@repo.gitea.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"test-repo\",\n    \"full_name\": \"anbraten/test-repo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 26,\n    \"language\": \"\",\n    \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n    \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n    \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n    \"link\": \"\",\n    \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n    \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 1,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-12-05T18:03:55Z\",\n    \"updated_at\": \"2023-12-05T18:06:29Z\",\n    \"archived_at\": \"1970-01-01T00:00:00Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": false,\n    \"has_actions\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"sender\": {\n    \"id\": 26907,\n    \"login\": \"anbraten\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"anbraten@sender.gitea.com\",\n    \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2021-07-19T23:21:52Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"website\": \"\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 0,\n    \"following_count\": 0,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 62112,\n    \"url\": \"https://gitea.com/anbraten/test-repo/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.gitea.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"title\": \"Adjust file\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"closed\",\n    \"is_locked\": false,\n    \"comments\": 1,\n    \"html_url\": \"https://gitea.com/anbraten/test-repo/pulls/1\",\n    \"diff_url\": \"https://gitea.com/anbraten/test-repo/pulls/1.diff\",\n    \"patch_url\": \"https://gitea.com/anbraten/test-repo/pulls/1.patch\",\n    \"mergeable\": true,\n    \"merged\": true,\n    \"merged_at\": \"2023-12-05T18:35:31Z\",\n    \"merge_commit_sha\": \"f2440f050054df0f8ecabcace648f1683509064c\",\n    \"merged_by\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.gitea.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"f2440f050054df0f8ecabcace648f1683509064c\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.gitea.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n        \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"head\": {\n      \"label\": \"anbraten-patch-1\",\n      \"ref\": \"anbraten-patch-1\",\n      \"sha\": \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n      \"repo_id\": 46534,\n      \"repo\": {\n        \"id\": 46534,\n        \"owner\": {\n          \"id\": 26907,\n          \"login\": \"anbraten\",\n          \"login_name\": \"\",\n          \"full_name\": \"\",\n          \"email\": \"anbraten@noreply.gitea.com\",\n          \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2021-07-19T23:21:52Z\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"website\": \"\",\n          \"description\": \"\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 1,\n          \"username\": \"anbraten\"\n        },\n        \"name\": \"test-repo\",\n        \"full_name\": \"anbraten/test-repo\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 26,\n        \"language\": \"\",\n        \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n        \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n        \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n        \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 0,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 1,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-12-05T18:03:55Z\",\n        \"updated_at\": \"2023-12-05T18:06:29Z\",\n        \"archived_at\": \"1970-01-01T00:00:00Z\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": false,\n        \"has_actions\": true,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null\n      }\n    },\n    \"merge_base\": \"068aee163ffd44eef28a7f9ebd43e2c01774f0fa\",\n    \"due_date\": null,\n    \"created_at\": \"2023-12-05T18:06:38Z\",\n    \"updated_at\": \"2023-12-05T18:35:31Z\",\n    \"closed_at\": \"2023-12-05T18:35:31Z\",\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 46534,\n    \"owner\": {\n      \"id\": 26907,\n      \"login\": \"anbraten\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"anbraten@noreply.gitea.com\",\n      \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2021-07-19T23:21:52Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"test-repo\",\n    \"full_name\": \"anbraten/test-repo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 26,\n    \"language\": \"\",\n    \"languages_url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo/languages\",\n    \"html_url\": \"https://gitea.com/anbraten/test-repo\",\n    \"url\": \"https://gitea.com/api/v1/repos/anbraten/test-repo\",\n    \"link\": \"\",\n    \"ssh_url\": \"git@gitea.com:anbraten/test-repo.git\",\n    \"clone_url\": \"https://gitea.com/anbraten/test-repo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 1,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-12-05T18:03:55Z\",\n    \"updated_at\": \"2023-12-05T18:06:29Z\",\n    \"archived_at\": \"1970-01-01T00:00:00Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": false,\n    \"has_actions\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"sender\": {\n    \"id\": 26907,\n    \"login\": \"anbraten\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"anbraten@noreply.gitea.com\",\n    \"avatar_url\": \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2021-07-19T23:21:52Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"website\": \"\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 0,\n    \"following_count\": 0,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestRemoveLabel.json",
    "content": "{\n  \"action\": \"label_cleared\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      },\n      {\n        \"id\": 297,\n        \"name\": \"help wanted\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"128a0c\",\n        \"description\": \"Need some help\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestRemoveMile.json",
    "content": "{\n  \"action\": \"demilestoned\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 21,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"created\": \"2018-01-25T14:38:19+01:00\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [\n      {\n        \"id\": 285,\n        \"name\": \"bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285\"\n      },\n      {\n        \"id\": 297,\n        \"name\": \"help wanted\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"128a0c\",\n        \"description\": \"Need some help\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297\"\n      }\n    ],\n    \"milestone\": {\n      \"id\": 273,\n      \"title\": \"closed mile\",\n      \"state\": \"closed\",\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"closed_at\": \"2025-05-28T03:13:46+02:00\",\n      \"due_on\": null\n    },\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 8765,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"created\": \"2023-05-23T15:17:35+02:00\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 8765,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"created\": \"2023-05-23T15:17:35+02:00\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"private\": false,\n        \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"link\": \"\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"merge_base\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 8765,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"created\": \"2023-05-23T15:17:35+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"private\": false,\n    \"has_pull_requests\": true,\n    \"languages_url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 8765,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"created\": \"2023-05-23T15:17:35+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestReopened.json",
    "content": "{\n  \"action\": \"reopened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"id\": 701944,\n    \"url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"number\": 1,\n    \"user\": {\n      \"id\": 2628,\n      \"login\": \"6543\",\n      \"login_name\": \"\",\n      \"source_id\": 0,\n      \"full_name\": \"\",\n      \"email\": \"6543@obermui.de\",\n      \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n      \"html_url\": \"https://codeberg.org/6543\",\n      \"language\": \"en-US\",\n      \"is_admin\": false,\n      \"last_login\": \"2025-08-05T17:04:55+02:00\",\n      \"created\": \"2019-10-12T05:05:49+02:00\",\n      \"restricted\": false,\n      \"active\": true,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"pronouns\": \"\",\n      \"website\": \"https://mh.obermui.de\",\n      \"description\": \"\\u003ca href=\\\"https://matrix.to/#/@marddl:obermui.de\\\" rel=\\\"nofollow\\\"\\u003e\\u003cimg src=\\\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\\\"\\u003e\\u003c/a\\u003e\\r\\n\\u003ca rel=\\\"me\\\" href=\\\"https://chaos.social/@6543\\\"\\u003eMastodon\\u003c/a\\u003e\",\n      \"visibility\": \"public\",\n      \"followers_count\": 46,\n      \"following_count\": 33,\n      \"starred_repos_count\": 92,\n      \"username\": \"6543\"\n    },\n    \"title\": \"Some ned more AAAA\",\n    \"body\": \"\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [],\n    \"requested_reviewers_teams\": [],\n    \"state\": \"open\",\n    \"draft\": false,\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"review_comments\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n    \"diff_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1.diff\",\n    \"patch_url\": \"https://codeberg.org/test_it/test_ci_thing/pulls/1.patch\",\n    \"mergeable\": false,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"allow_maintainer_edit\": false,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"login_name\": \"\",\n          \"source_id\": 0,\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2023-04-02T15:13:07+02:00\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"pronouns\": \"\",\n          \"website\": \"\",\n          \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 0,\n          \"username\": \"test_it\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 34,\n        \"language\": \"\",\n        \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 1,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 0,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-08-27T03:32:56+02:00\",\n        \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n        \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"wiki_branch\": \"master\",\n        \"globally_editable_wiki\": false,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": true,\n        \"has_actions\": false,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_fast_forward_only_merge\": false,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"default_update_style\": \"merge\",\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"object_format_name\": \"sha1\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null,\n        \"topics\": []\n      }\n    },\n    \"head\": {\n      \"label\": \"6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"repo_id\": 138564,\n      \"repo\": {\n        \"id\": 138564,\n        \"owner\": {\n          \"id\": 90470,\n          \"login\": \"test_it\",\n          \"login_name\": \"\",\n          \"source_id\": 0,\n          \"full_name\": \"\",\n          \"email\": \"\",\n          \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n          \"html_url\": \"https://codeberg.org/test_it\",\n          \"language\": \"\",\n          \"is_admin\": false,\n          \"last_login\": \"0001-01-01T00:00:00Z\",\n          \"created\": \"2023-04-02T15:13:07+02:00\",\n          \"restricted\": false,\n          \"active\": false,\n          \"prohibit_login\": false,\n          \"location\": \"\",\n          \"pronouns\": \"\",\n          \"website\": \"\",\n          \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n          \"visibility\": \"public\",\n          \"followers_count\": 0,\n          \"following_count\": 0,\n          \"starred_repos_count\": 0,\n          \"username\": \"test_it\"\n        },\n        \"name\": \"test_ci_thing\",\n        \"full_name\": \"test_it/test_ci_thing\",\n        \"description\": \"\",\n        \"empty\": false,\n        \"private\": false,\n        \"fork\": false,\n        \"template\": false,\n        \"parent\": null,\n        \"mirror\": false,\n        \"size\": 34,\n        \"language\": \"\",\n        \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n        \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n        \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n        \"link\": \"\",\n        \"ssh_url\": \"git@codeberg.org/test_it/test_ci_thing.git\",\n        \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n        \"original_url\": \"\",\n        \"website\": \"\",\n        \"stars_count\": 1,\n        \"forks_count\": 0,\n        \"watchers_count\": 1,\n        \"open_issues_count\": 0,\n        \"open_pr_counter\": 0,\n        \"release_counter\": 0,\n        \"default_branch\": \"main\",\n        \"archived\": false,\n        \"created_at\": \"2023-08-27T03:32:56+02:00\",\n        \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n        \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n        \"permissions\": {\n          \"admin\": true,\n          \"push\": true,\n          \"pull\": true\n        },\n        \"has_issues\": true,\n        \"internal_tracker\": {\n          \"enable_time_tracker\": true,\n          \"allow_only_contributors_to_track_time\": true,\n          \"enable_issue_dependencies\": true\n        },\n        \"has_wiki\": true,\n        \"wiki_branch\": \"master\",\n        \"globally_editable_wiki\": false,\n        \"has_pull_requests\": true,\n        \"has_projects\": true,\n        \"has_releases\": true,\n        \"has_packages\": true,\n        \"has_actions\": false,\n        \"ignore_whitespace_conflicts\": false,\n        \"allow_merge_commits\": true,\n        \"allow_rebase\": true,\n        \"allow_rebase_explicit\": true,\n        \"allow_squash_merge\": true,\n        \"allow_fast_forward_only_merge\": false,\n        \"allow_rebase_update\": true,\n        \"default_delete_branch_after_merge\": false,\n        \"default_merge_style\": \"merge\",\n        \"default_allow_maintainer_edit\": false,\n        \"default_update_style\": \"merge\",\n        \"avatar_url\": \"\",\n        \"internal\": false,\n        \"mirror_interval\": \"\",\n        \"object_format_name\": \"sha1\",\n        \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n        \"repo_transfer\": null,\n        \"topics\": []\n      }\n    },\n    \"merge_base\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n    \"due_date\": null,\n    \"created_at\": \"2025-07-29T16:45:09+02:00\",\n    \"updated_at\": \"2025-08-05T17:06:49+02:00\",\n    \"closed_at\": null,\n    \"pin_order\": 0,\n    \"flow\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 138564,\n    \"owner\": {\n      \"id\": 90470,\n      \"login\": \"test_it\",\n      \"login_name\": \"\",\n      \"source_id\": 0,\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n      \"html_url\": \"https://codeberg.org/test_it\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-04-02T15:13:07+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"pronouns\": \"\",\n      \"website\": \"\",\n      \"description\": \"the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"test_it\"\n    },\n    \"name\": \"test_ci_thing\",\n    \"full_name\": \"test_it/test_ci_thing\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 34,\n    \"language\": \"\",\n    \"languages_url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages\",\n    \"html_url\": \"https://codeberg.org/test_it/test_ci_thing\",\n    \"url\": \"https://codeberg.org/api/v1/repos/test_it/test_ci_thing\",\n    \"link\": \"\",\n    \"ssh_url\": \"git@codeberg.org/test_it/test_ci_thing.git\",\n    \"clone_url\": \"https://codeberg.org/test_it/test_ci_thing.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 1,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 0,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-08-27T03:32:56+02:00\",\n    \"updated_at\": \"2025-07-29T16:45:07+02:00\",\n    \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"wiki_branch\": \"master\",\n    \"globally_editable_wiki\": false,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"has_releases\": true,\n    \"has_packages\": true,\n    \"has_actions\": false,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"allow_fast_forward_only_merge\": false,\n    \"allow_rebase_update\": true,\n    \"default_delete_branch_after_merge\": false,\n    \"default_merge_style\": \"merge\",\n    \"default_allow_maintainer_edit\": false,\n    \"default_update_style\": \"merge\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"object_format_name\": \"sha1\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null,\n    \"topics\": []\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"login_name\": \"\",\n    \"source_id\": 0,\n    \"full_name\": \"\",\n    \"email\": \"6543@noreply.codeberg.org\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"html_url\": \"https://codeberg.org/6543\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"pronouns\": \"\",\n    \"website\": \"https://mh.obermui.de\",\n    \"description\": \"\\u003ca href=\\\"https://matrix.to/#/@marddl:obermui.de\\\" rel=\\\"nofollow\\\"\\u003e\\u003cimg src=\\\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\\\"\\u003e\\u003c/a\\u003e\\r\\n\\u003ca rel=\\\"me\\\" href=\\\"https://chaos.social/@6543\\\"\\u003eMastodon\\u003c/a\\u003e\",\n    \"visibility\": \"public\",\n    \"followers_count\": 46,\n    \"following_count\": 33,\n    \"starred_repos_count\": 92,\n    \"username\": \"6543\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestReviewAck.json",
    "content": "{\n  \"action\": \"reviewed\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 349,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"comments\": 0,\n    \"review_comments\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    }\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 349,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"description\": \"\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 349,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"commit_id\": \"\",\n  \"review\": {\n    \"type\": \"pull_request_review_approved\",\n    \"content\": \"juhu thats a great idea\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestReviewComment.json",
    "content": "{\n  \"action\": \"reviewed\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 349,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"comments\": 0,\n    \"review_comments\": 3,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    }\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 349,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"description\": \"\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 349,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"commit_id\": \"\",\n  \"review\": {\n    \"type\": \"pull_request_review_comment\",\n    \"content\": \"and somethimes you have to comment\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestReviewDeny.json",
    "content": "{\n  \"action\": \"reviewed\",\n  \"number\": 7,\n  \"pull_request\": {\n    \"id\": 3779,\n    \"url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"number\": 7,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"jony\",\n      \"full_name\": \"Jony\",\n      \"email\": \"jony@noreply.example.org\",\n      \"avatar_url\": \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n      \"html_url\": \"https://gitea.com/jony\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"username\": \"jony\"\n    },\n    \"title\": \"somepull\",\n    \"body\": \"wow aaa new pulll body\",\n    \"labels\": [],\n    \"milestone\": null,\n    \"assignee\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": [\n      {\n        \"id\": 349,\n        \"login\": \"a_nice_user\",\n        \"full_name\": \"Nice User\",\n        \"email\": \"a_nice_user@noreply.example.org\",\n        \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n        \"html_url\": \"https://gitea.com/a_nice_user\",\n        \"visibility\": \"public\",\n        \"username\": \"a_nice_user\"\n      }\n    ],\n    \"state\": \"open\",\n    \"draft\": false,\n    \"is_locked\": false,\n    \"comments\": 0,\n    \"review_comments\": 2,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1,\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n    \"diff_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff\",\n    \"patch_url\": \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch\",\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"a40211c506550ebd79633d84e913dafa184c6d56\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"head\": {\n      \"label\": \"jony-patch-1\",\n      \"ref\": \"jony-patch-1\",\n      \"sha\": \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n      \"repo_id\": 1234,\n      \"repo\": {\n        \"id\": 1234,\n        \"owner\": {\n          \"id\": 349,\n          \"login\": \"a_nice_user\",\n          \"full_name\": \"Nice User\",\n          \"email\": \"a_nice_user@me.mail\",\n          \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n          \"html_url\": \"https://gitea.com/a_nice_user\",\n          \"visibility\": \"public\",\n          \"username\": \"a_nice_user\"\n        },\n        \"name\": \"hello_world_ci\",\n        \"full_name\": \"a_nice_user/hello_world_ci\",\n        \"description\": \"\",\n        \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n        \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n        \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n        \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n        \"default_branch\": \"main\",\n        \"permissions\": {\n          \"admin\": false,\n          \"push\": false,\n          \"pull\": true\n        },\n        \"has_pull_requests\": true,\n        \"object_format_name\": \"sha1\"\n      }\n    },\n    \"due_date\": null,\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 1234,\n    \"owner\": {\n      \"id\": 349,\n      \"login\": \"a_nice_user\",\n      \"full_name\": \"Nice User\",\n      \"email\": \"a_nice_user@me.mail\",\n      \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n      \"html_url\": \"https://gitea.com/a_nice_user\",\n      \"visibility\": \"public\",\n      \"username\": \"a_nice_user\"\n    },\n    \"name\": \"hello_world_ci\",\n    \"full_name\": \"a_nice_user/hello_world_ci\",\n    \"description\": \"\",\n    \"html_url\": \"https://gitea.com/a_nice_user/hello_world_ci\",\n    \"url\": \"https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci\",\n    \"ssh_url\": \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n    \"clone_url\": \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_pull_requests\": true,\n    \"object_format_name\": \"sha1\"\n  },\n  \"sender\": {\n    \"id\": 349,\n    \"login\": \"a_nice_user\",\n    \"full_name\": \"Nice User\",\n    \"email\": \"a_nice_user@noreply.example.org\",\n    \"avatar_url\": \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n    \"html_url\": \"https://gitea.com/a_nice_user\",\n    \"visibility\": \"public\",\n    \"username\": \"a_nice_user\"\n  },\n  \"commit_id\": \"\",\n  \"review\": {\n    \"type\": \"pull_request_review_rejected\",\n    \"content\": \"I decided otherwhies :O\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPullRequestUpdated.json",
    "content": "{\n  \"action\": \"synchronized\",\n  \"number\": 2,\n  \"pull_request\": {\n    \"id\": 2,\n    \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n    \"number\": 2,\n    \"user\": {\n      \"id\": 1,\n      \"login\": \"test\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"test@noreply.localhost\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:05+02:00\",\n      \"visibility\": \"public\",\n      \"username\": \"test\"\n    },\n    \"title\": \"New Pull\",\n    \"body\": \"create an awesome pull\",\n    \"labels\": [\n      {\n        \"id\": 8,\n        \"name\": \"Kind/Bug\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"ee0701\",\n        \"description\": \"Something is not working\",\n        \"url\": \"http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/8\"\n      },\n      {\n        \"id\": 11,\n        \"name\": \"Kind/Security\",\n        \"exclusive\": false,\n        \"is_archived\": false,\n        \"color\": \"9c27b0\",\n        \"description\": \"This is security issue\",\n        \"url\": \"http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/11\"\n      }\n    ],\n    \"milestone\": null,\n    \"assignees\": null,\n    \"requested_reviewers\": null,\n    \"state\": \"open\",\n    \"is_locked\": false,\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n    \"diff_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.diff\",\n    \"patch_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.patch\",\n    \"mergeable\": true,\n    \"merged\": false,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"merged_by\": null,\n    \"base\": {\n      \"label\": \"main\",\n      \"ref\": \"main\",\n      \"sha\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n      \"repo_id\": 6\n    },\n    \"head\": {\n      \"label\": \"test-patch-1\",\n      \"ref\": \"test-patch-1\",\n      \"sha\": \"788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25\",\n      \"repo_id\": 6\n    },\n    \"merge_base\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n    \"due_date\": null,\n    \"created_at\": \"2024-02-22T01:38:39+01:00\",\n    \"updated_at\": \"2024-02-22T01:42:03+01:00\",\n    \"closed_at\": null,\n    \"pin_order\": 0\n  },\n  \"requested_reviewer\": null,\n  \"repository\": {\n    \"id\": 6,\n    \"owner\": {\n      \"id\": 2,\n      \"login\": \"Test-CI\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:48+02:00\",\n      \"prohibit_login\": false,\n      \"visibility\": \"public\",\n      \"username\": \"Test-CI\"\n    },\n    \"name\": \"multi-line-secrets\",\n    \"full_name\": \"Test-CI/multi-line-secrets\",\n    \"description\": \"\",\n    \"private\": false,\n    \"languages_url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages\",\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n    \"url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n    \"clone_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n    \"original_url\": \"\",\n    \"default_branch\": \"main\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_pull_requests\": true,\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"object_format_name\": \"\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"test\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"visibility\": \"public\",\n    \"username\": \"test\"\n  },\n  \"commit_id\": \"\",\n  \"review\": null\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPush.json",
    "content": "{\n  \"ref\": \"refs/heads/main\",\n  \"before\": \"4b2626259b5a97b6b4eab5e6cca66adb986b672b\",\n  \"after\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n  \"compare_url\": \"http://gitea.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5\",\n  \"commits\": [\n    {\n      \"id\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n      \"message\": \"bump\\n\",\n      \"url\": \"http://gitea.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5\",\n      \"author\": {\n        \"name\": \"Gordon the Gopher\",\n        \"email\": \"gordon@golang.org\",\n        \"username\": \"gordon\"\n      },\n      \"added\": [\"CHANGELOG.md\"],\n      \"removed\": [],\n      \"modified\": [\"app/controller/application.rb\"]\n    }\n  ],\n  \"repository\": {\n    \"id\": 1,\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"html_url\": \"http://gitea.golang.org/gordon/hello-world\",\n    \"ssh_url\": \"git@gitea.golang.org:gordon/hello-world.git\",\n    \"clone_url\": \"http://gitea.golang.org/gordon/hello-world.git\",\n    \"description\": \"\",\n    \"website\": \"\",\n    \"watchers\": 1,\n    \"owner\": {\n      \"name\": \"gordon\",\n      \"email\": \"gordon@golang.org\",\n      \"login\": \"gordon\",\n      \"username\": \"gordon\"\n    },\n    \"private\": true,\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"pusher\": {\n    \"name\": \"gordon\",\n    \"email\": \"gordon@golang.org\",\n    \"username\": \"gordon\",\n    \"login\": \"gordon\"\n  },\n  \"sender\": {\n    \"login\": \"gordon\",\n    \"id\": 1,\n    \"username\": \"gordon\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPushBranch.json",
    "content": "{\n  \"ref\": \"refs/heads/fdsafdsa\",\n  \"before\": \"0000000000000000000000000000000000000000\",\n  \"after\": \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n  \"compare_url\": \"https://codeberg.org/meisam/woodpecktester/compare/main...28c3613ae62640216bea5e7dc71aa65356e4298b\",\n  \"commits\": [],\n  \"head_commit\": {\n    \"id\": \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n    \"message\": \"Delete '.woodpecker/.check.yml'\\n\",\n    \"url\": \"https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b\",\n    \"author\": {\n      \"name\": \"meisam\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"username\": \"meisam\"\n    },\n    \"committer\": {\n      \"name\": \"meisam\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"username\": \"meisam\"\n    },\n    \"verification\": null,\n    \"timestamp\": \"2022-07-12T21:09:27+02:00\",\n    \"added\": [],\n    \"removed\": [\".woodpecker/.check.yml\"],\n    \"modified\": []\n  },\n  \"repository\": {\n    \"id\": 50820,\n    \"owner\": {\n      \"id\": 14844,\n      \"login\": \"meisam\",\n      \"full_name\": \"\",\n      \"email\": \"meisam@noreply.codeberg.org\",\n      \"avatar_url\": \"https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2020-10-08T11:19:12+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"Materials engineer, physics enthusiast, large collection of the bad programming habits, always happy to fix the old ones and make new mistakes!\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"meisam\",\n      \"permissions\": {\n        \"admin\": true,\n        \"push\": true,\n        \"pull\": true\n      }\n    },\n    \"name\": \"woodpecktester\",\n    \"full_name\": \"meisam/woodpecktester\",\n    \"description\": \"Just for testing the Woodpecker CI and reporting bugs\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 367,\n    \"language\": \"\",\n    \"languages_url\": \"https://codeberg.org/api/v1/repos/meisam/woodpecktester/languages\",\n    \"html_url\": \"https://codeberg.org/meisam/woodpecktester\",\n    \"ssh_url\": \"git@codeberg.org:meisam/woodpecktester.git\",\n    \"clone_url\": \"https://codeberg.org/meisam/woodpecktester.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 0,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 0,\n    \"open_pr_counter\": 0,\n    \"release_counter\": 0,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2022-07-04T00:34:39+02:00\",\n    \"updated_at\": \"2022-07-24T20:31:29+02:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": true,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"default_merge_style\": \"merge\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\",\n    \"mirror_updated\": \"0001-01-01T00:00:00Z\",\n    \"repo_transfer\": null\n  },\n  \"pusher\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@obermui.de\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 22,\n    \"following_count\": 16,\n    \"starred_repos_count\": 55,\n    \"username\": \"6543\"\n  },\n  \"sender\": {\n    \"id\": 2628,\n    \"login\": \"6543\",\n    \"full_name\": \"\",\n    \"email\": \"6543@obermui.de\",\n    \"avatar_url\": \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2019-10-12T05:05:49+02:00\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"visibility\": \"public\",\n    \"followers_count\": 22,\n    \"following_count\": 16,\n    \"starred_repos_count\": 55,\n    \"username\": \"6543\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookPushMulti.json",
    "content": "{\n  \"ref\": \"refs/heads/main\",\n  \"before\": \"6efcf5b7c98f3e7a491675164b7a2e7acac27941\",\n  \"after\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n  \"compare_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453\",\n  \"commits\": [\n    {\n      \"id\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n      \"message\": \"add some text\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:18:07+01:00\",\n      \"added\": [],\n      \"removed\": [],\n      \"modified\": [\"aaa\"]\n    },\n    {\n      \"id\": \"29cd95250404bd007c13b03eabe521196bab98a5\",\n      \"message\": \"rm a a file\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29cd95250404bd007c13b03eabe521196bab98a5\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:17:49+01:00\",\n      \"added\": [],\n      \"removed\": [\"aa\"],\n      \"modified\": []\n    },\n    {\n      \"id\": \"93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91\",\n      \"message\": \"add some a files\\n\",\n      \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"committer\": {\n        \"name\": \"6543\",\n        \"email\": \"6543@obermui.de\",\n        \"username\": \"test-user\"\n      },\n      \"verification\": null,\n      \"timestamp\": \"2024-02-22T00:17:33+01:00\",\n      \"added\": [\"aa\", \"aaa\"],\n      \"removed\": [],\n      \"modified\": []\n    }\n  ],\n  \"total_commits\": 3,\n  \"head_commit\": {\n    \"id\": \"29be01c073851cf0db0c6a466e396b725a670453\",\n    \"message\": \"add some text\\n\",\n    \"url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453\",\n    \"author\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"test-user\"\n    },\n    \"committer\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"test-user\"\n    },\n    \"verification\": null,\n    \"timestamp\": \"2024-02-22T00:18:07+01:00\",\n    \"added\": [],\n    \"removed\": [],\n    \"modified\": [\"aaa\"]\n  },\n  \"repository\": {\n    \"id\": 6,\n    \"owner\": {\n      \"id\": 2,\n      \"login\": \"Test-CI\",\n      \"login_name\": \"\",\n      \"full_name\": \"\",\n      \"email\": \"\",\n      \"avatar_url\": \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2023-07-31T19:13:48+02:00\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"\",\n      \"website\": \"\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 0,\n      \"following_count\": 0,\n      \"starred_repos_count\": 0,\n      \"username\": \"Test-CI\"\n    },\n    \"name\": \"multi-line-secrets\",\n    \"full_name\": \"Test-CI/multi-line-secrets\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": false,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 35,\n    \"language\": \"\",\n    \"languages_url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages\",\n    \"html_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n    \"url\": \"http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets\",\n    \"link\": \"\",\n    \"ssh_url\": \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n    \"clone_url\": \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"watchers_count\": 2,\n    \"open_issues_count\": 1,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2023-10-31T19:53:15+01:00\",\n    \"updated_at\": \"2023-11-02T06:16:34+01:00\",\n    \"archived_at\": \"1970-01-01T01:00:00+01:00\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"avatar_url\": \"\",\n    \"object_format_name\": \"\"\n  },\n  \"pusher\": {\n    \"id\": 1,\n    \"login\": \"test-user\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"prohibit_login\": false,\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"username\": \"test-user\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"test-user\",\n    \"login_name\": \"\",\n    \"full_name\": \"\",\n    \"email\": \"test@noreply.localhost\",\n    \"avatar_url\": \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2023-07-31T19:13:05+02:00\",\n    \"prohibit_login\": false,\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"username\": \"test-user\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookRelease.json",
    "content": "{\n  \"action\": \"published\",\n  \"release\": {\n    \"id\": 48,\n    \"tag_name\": \"0.0.5\",\n    \"target_commitish\": \"main\",\n    \"name\": \"Version 0.0.5\",\n    \"body\": \"\",\n    \"url\": \"https://git.xxx/api/v1/repos/anbraten/demo/releases/48\",\n    \"html_url\": \"https://git.xxx/anbraten/demo/releases/tag/0.0.5\",\n    \"tarball_url\": \"https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz\",\n    \"zipball_url\": \"https://git.xxx/anbraten/demo/archive/0.0.5.zip\",\n    \"draft\": false,\n    \"prerelease\": false,\n    \"created_at\": \"2022-02-09T20:23:05Z\",\n    \"published_at\": \"2022-02-09T20:23:05Z\",\n    \"author\": {\n      \"id\": 1,\n      \"login\": \"anbraten\",\n      \"full_name\": \"Anton Bracke\",\n      \"email\": \"anbraten@noreply.xxx\",\n      \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2018-03-21T10:04:48Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"world\",\n      \"website\": \"https://xxx\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 1,\n      \"following_count\": 1,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"assets\": []\n  },\n  \"repository\": {\n    \"id\": 77,\n    \"owner\": {\n      \"id\": 1,\n      \"login\": \"anbraten\",\n      \"full_name\": \"Anton Bracke\",\n      \"email\": \"anbraten@noreply.xxx\",\n      \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n      \"language\": \"\",\n      \"is_admin\": false,\n      \"last_login\": \"0001-01-01T00:00:00Z\",\n      \"created\": \"2018-03-21T10:04:48Z\",\n      \"restricted\": false,\n      \"active\": false,\n      \"prohibit_login\": false,\n      \"location\": \"world\",\n      \"website\": \"https://xxx\",\n      \"description\": \"\",\n      \"visibility\": \"public\",\n      \"followers_count\": 1,\n      \"following_count\": 1,\n      \"starred_repos_count\": 1,\n      \"username\": \"anbraten\"\n    },\n    \"name\": \"demo\",\n    \"full_name\": \"anbraten/demo\",\n    \"description\": \"\",\n    \"empty\": false,\n    \"private\": true,\n    \"fork\": false,\n    \"template\": false,\n    \"parent\": null,\n    \"mirror\": false,\n    \"size\": 59,\n    \"html_url\": \"https://git.xxx/anbraten/demo\",\n    \"ssh_url\": \"ssh://git@git.xxx:22/anbraten/demo.git\",\n    \"clone_url\": \"https://git.xxx/anbraten/demo.git\",\n    \"original_url\": \"\",\n    \"website\": \"\",\n    \"stars_count\": 0,\n    \"forks_count\": 1,\n    \"watchers_count\": 1,\n    \"open_issues_count\": 2,\n    \"open_pr_counter\": 2,\n    \"release_counter\": 4,\n    \"default_branch\": \"main\",\n    \"archived\": false,\n    \"created_at\": \"2021-08-30T20:54:13Z\",\n    \"updated_at\": \"2022-01-09T01:29:23Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    },\n    \"has_issues\": true,\n    \"internal_tracker\": {\n      \"enable_time_tracker\": true,\n      \"allow_only_contributors_to_track_time\": true,\n      \"enable_issue_dependencies\": true\n    },\n    \"has_wiki\": false,\n    \"has_pull_requests\": true,\n    \"has_projects\": true,\n    \"ignore_whitespace_conflicts\": false,\n    \"allow_merge_commits\": true,\n    \"allow_rebase\": true,\n    \"allow_rebase_explicit\": true,\n    \"allow_squash_merge\": true,\n    \"default_merge_style\": \"squash\",\n    \"avatar_url\": \"\",\n    \"internal\": false,\n    \"mirror_interval\": \"\"\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"login\": \"anbraten\",\n    \"full_name\": \"Anbraten\",\n    \"email\": \"anbraten@noreply.xxx\",\n    \"avatar_url\": \"https://git.xxx/user/avatar/anbraten/-1\",\n    \"language\": \"\",\n    \"is_admin\": false,\n    \"last_login\": \"0001-01-01T00:00:00Z\",\n    \"created\": \"2018-03-21T10:04:48Z\",\n    \"restricted\": false,\n    \"active\": false,\n    \"prohibit_login\": false,\n    \"location\": \"World\",\n    \"website\": \"https://xxx\",\n    \"description\": \"\",\n    \"visibility\": \"public\",\n    \"followers_count\": 1,\n    \"following_count\": 1,\n    \"starred_repos_count\": 1,\n    \"username\": \"anbraten\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/HookTag.json",
    "content": "{\n  \"sha\": \"ef98532add3b2feb7a137426bba1248724367df5\",\n  \"secret\": \"l26Un7G7HXogLAvsyf2hOA4EMARSTsR3\",\n  \"ref\": \"v1.0.0\",\n  \"ref_type\": \"tag\",\n  \"repository\": {\n    \"id\": 12,\n    \"owner\": {\n      \"id\": 4,\n      \"username\": \"gordon\",\n      \"login\": \"gordon\",\n      \"full_name\": \"Gordon the Gopher\",\n      \"email\": \"gordon@golang.org\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n    },\n    \"name\": \"hello-world\",\n    \"full_name\": \"gordon/hello-world\",\n    \"description\": \"a hello world example\",\n    \"private\": true,\n    \"fork\": false,\n    \"html_url\": \"http://gitea.golang.org/gordon/hello-world\",\n    \"ssh_url\": \"git@gitea.golang.org:gordon/hello-world.git\",\n    \"clone_url\": \"http://gitea.golang.org/gordon/hello-world.git\",\n    \"default_branch\": \"main\",\n    \"created_at\": \"2015-10-22T19:32:44Z\",\n    \"updated_at\": \"2016-11-24T13:37:16Z\",\n    \"permissions\": {\n      \"admin\": true,\n      \"push\": true,\n      \"pull\": true\n    }\n  },\n  \"sender\": {\n    \"id\": 1,\n    \"username\": \"gordon\",\n    \"login\": \"gordon\",\n    \"full_name\": \"Gordon the Gopher\",\n    \"email\": \"gordon@golang.org\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitea/fixtures/handler.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler returns an http.Handler that is capable of handling a variety of mock\n// Gitea requests and returning mock responses.\nfunc Handler() http.Handler {\n\tgin.SetMode(gin.TestMode)\n\n\te := gin.New()\n\te.GET(\"/api/v1/repos/:owner/:name\", getRepo)\n\te.GET(\"/api/v1/repositories/:id\", getRepoByID)\n\te.GET(\"/api/v1/repos/:owner/:name/raw/:file\", getRepoFile)\n\te.POST(\"/api/v1/repos/:owner/:name/hooks\", createRepoHook)\n\te.GET(\"/api/v1/repos/:owner/:name/hooks\", listRepoHooks)\n\te.DELETE(\"/api/v1/repos/:owner/:name/hooks/:id\", deleteRepoHook)\n\te.POST(\"/api/v1/repos/:owner/:name/statuses/:commit\", createRepoCommitStatus)\n\te.GET(\"/api/v1/repos/:owner/:name/pulls/:index/files\", getPRFiles)\n\te.GET(\"/api/v1/user/repos\", getUserRepos)\n\te.GET(\"/api/v1/version\", getVersion)\n\n\treturn e\n}\n\nfunc listRepoHooks(c *gin.Context) {\n\tpage := c.Query(\"page\")\n\tif page != \"\" && page != \"1\" {\n\t\tc.String(http.StatusOK, \"[]\")\n\t} else {\n\t\tc.String(http.StatusOK, listRepoHookPayloads)\n\t}\n}\n\nfunc getRepo(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc getRepoByID(c *gin.Context) {\n\tswitch c.Param(\"id\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc createRepoCommitStatus(c *gin.Context) {\n\tif c.Param(\"commit\") == \"v1.0.0\" || c.Param(\"commit\") == \"9ecad50\" {\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n\tc.String(http.StatusNotFound, \"\")\n}\n\nfunc getRepoFile(c *gin.Context) {\n\tfile := c.Param(\"file\")\n\tref := c.Query(\"ref\")\n\n\tif file == \"file_not_found\" {\n\t\tc.String(http.StatusNotFound, \"\")\n\t}\n\tif ref == \"v1.0.0\" || ref == \"9ecad50\" {\n\t\tc.String(http.StatusOK, repoFilePayload)\n\t}\n\tc.String(http.StatusNotFound, \"\")\n}\n\nfunc createRepoHook(c *gin.Context) {\n\tin := struct {\n\t\tType string `json:\"type\"`\n\t\tConf struct {\n\t\t\tType string `json:\"content_type\"`\n\t\t\tURL  string `json:\"url\"`\n\t\t} `json:\"config\"`\n\t}{}\n\t_ = c.BindJSON(&in)\n\tif in.Type != \"gitea\" ||\n\t\tin.Conf.Type != \"json\" ||\n\t\tin.Conf.URL != \"http://localhost\" {\n\t\tc.String(http.StatusInternalServerError, \"\")\n\t\treturn\n\t}\n\n\tc.String(http.StatusOK, \"{}\")\n}\n\nfunc deleteRepoHook(c *gin.Context) {\n\tc.String(http.StatusOK, \"{}\")\n}\n\nfunc getUserRepos(c *gin.Context) {\n\tswitch c.Request.Header.Get(\"Authorization\") {\n\tcase \"token repos_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tpage := c.Query(\"page\")\n\t\tif page != \"\" && page != \"1\" {\n\t\t\tc.String(http.StatusOK, \"[]\")\n\t\t} else {\n\t\t\tc.String(http.StatusOK, userRepoPayload)\n\t\t}\n\t}\n}\n\nfunc getVersion(c *gin.Context) {\n\tc.JSON(http.StatusOK, map[string]any{\"version\": \"1.18.0\"})\n}\n\nfunc getPRFiles(c *gin.Context) {\n\tpage := c.Query(\"page\")\n\tif page == \"1\" {\n\t\tc.String(http.StatusOK, prFilesPayload)\n\t} else {\n\t\tc.String(http.StatusOK, \"[]\")\n\t}\n}\n\nconst listRepoHookPayloads = `\n[\n\t{\n\t\t\"id\": 1,\n\t\t\"type\": \"gitea\",\n\t\t\"config\": {\n\t\t\t\"content_type\": \"json\",\n\t\t\t\"url\": \"http:\\/\\/localhost\\/hook?access_token=1234567890\"\n\t\t}\n\t}\n]\n`\n\nconst repoPayload = `\n{\n\t\"id\": 5,\n\t\"owner\": {\n\t\t\"login\": \"test_name\",\n\t\t\"email\": \"octocat@github.com\",\n\t\t\"avatar_url\": \"https:\\/\\/secure.gravatar.com\\/avatar\\/8c58a0be77ee441bb8f8595b7f1b4e87\"\n\t},\n\t\"full_name\": \"test_name\\/repo_name\",\n\t\"private\": true,\n\t\"html_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name\",\n\t\"clone_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name.git\",\n\t\"permissions\": {\n\t\t\"admin\": true,\n\t\t\"push\": true,\n\t\t\"pull\": true\n\t}\n}\n`\n\nconst repoFilePayload = `{ platform: linux/amd64 }`\n\nconst userRepoPayload = `\n[\n\t{\n\t\t\"id\": 5,\n\t\t\"owner\": {\n\t\t\t\"login\": \"test_name\",\n\t\t\t\"email\": \"octocat@github.com\",\n\t\t\t\"avatar_url\": \"https:\\/\\/secure.gravatar.com\\/avatar\\/8c58a0be77ee441bb8f8595b7f1b4e87\"\n\t\t},\n\t\t\"full_name\": \"test_name\\/repo_name\",\n\t\t\"private\": true,\n\t\t\"html_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name\",\n\t\t\"clone_url\": \"http:\\/\\/localhost\\/test_name\\/repo_name.git\",\n\t\t\"permissions\": {\n\t\t\t\"admin\": true,\n\t\t\t\"push\": true,\n\t\t\t\"pull\": true\n\t\t}\n\t}\n]\n`\n\nconst prFilesPayload = `\n[\n\t{\n\t\t\"filename\": \"README.md\",\n\t\t\"status\": \"changed\",\n\t\t\"additions\": 2,\n\t\t\"deletions\": 0,\n\t\t\"changes\": 2,\n\t\t\"html_url\": \"http://localhost/username/repo/src/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md\",\n\t\t\"contents_url\": \"http://localhost:3000/api/v1/repos/username/repo/contents/README.md?ref=e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd\",\n\t\t\"raw_url\": \"http://localhost/username/repo/raw/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md\"\n\t}\n]\n`\n"
  },
  {
    "path": "server/forge/gitea/fixtures/hooks.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport _ \"embed\"\n\n// HookPush is a sample Gitea push hook.\n//\n//go:embed HookPush.json\nvar HookPush string\n\n// HookPushMulti push multible commits to a branch.\n//\n//go:embed HookPushMulti.json\nvar HookPushMulti string\n\n// HookPushBranch is a sample Gitea push hook where a new branch was created from an existing commit.\n//\n//go:embed HookPushBranch.json\nvar HookPushBranch string\n\n// HookTag is a sample Gitea tag hook.\n//\n//go:embed HookTag.json\nvar HookTag string\n\n// HookPullRequest is a sample pull_request webhook payload.\n//\n//go:embed HookPullRequest.json\nvar HookPullRequest string\n\n//go:embed HookPullRequestUpdated.json\nvar HookPullRequestUpdated string\n\n//go:embed HookPullRequestMerged.json\nvar HookPullRequestMerged string\n\n//go:embed HookPullRequestClosed.json\nvar HookPullRequestClosed string\n\n//go:embed HookPullRequestChangeTitle.json\nvar HookPullRequestChangeTitle string\n\n//go:embed HookPullRequestChangeBody.json\nvar HookPullRequestChangeBody string\n\n//go:embed HookPullRequestAddReviewRequest.json\nvar HookPullRequestAddReviewRequest string\n\n//go:embed HookPullRequestReviewAck.json\nvar HookPullRequestReviewAck string\n\n//go:embed HookPullRequestReviewDeny.json\nvar HookPullRequestReviewDeny string\n\n//go:embed HookPullRequestReviewComment.json\nvar HookPullRequestReviewComment string\n\n//go:embed HookPullRequestAddLabel.json\nvar HookPullRequestAddLabel string\n\n//go:embed HookPullRequestChangeLabel.json\nvar HookPullRequestChangeLabel string\n\n//go:embed HookPullRequestRemoveLabel.json\nvar HookPullRequestRemoveLabel string\n\n//go:embed HookPullRequestAddMile.json\nvar HookPullRequestAddMile string\n\n//go:embed HookPullRequestChangeMile.json\nvar HookPullRequestChangeMile string\n\n//go:embed HookPullRequestRemoveMile.json\nvar HookPullRequestRemoveMile string\n\n//go:embed HookPullRequestAssigneesAdded.json\nvar HookPullRequestAssigneesAdded string\n\n//go:embed HookPullRequestAssigneesRemoved.json\nvar HookPullRequestAssigneesRemoved string\n\n//go:embed HookRelease.json\nvar HookRelease string\n\n//go:embed HookPullRequestReopened.json\nvar HookPullRequestReopened string\n"
  },
  {
    "path": "server/forge/gitea/gitea.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"code.gitea.io/sdk/gitea\"\n\t\"github.com/rs/zerolog/log\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\tshared_utils \"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tauthorizeTokenURL = \"%s/login/oauth/authorize\"\n\taccessTokenURL    = \"%s/login/oauth/access_token\"\n\tdefaultPageSize   = 50\n\tgiteaDevVersion   = \"v1.21.0\"\n)\n\ntype Gitea struct {\n\tid                int64\n\turl               string\n\toAuthClientID     string\n\toAuthClientSecret string\n\toAuthHost         string\n\tskipVerify        bool\n\tpageSize          int\n}\n\n// Opts defines configuration options.\ntype Opts struct {\n\tURL               string // Gitea server url.\n\tOAuthClientID     string // OAuth2 Client ID\n\tOAuthClientSecret string // OAuth2 Client Secret\n\tOAuthHost         string // OAuth2 Host\n\tSkipVerify        bool   // Skip ssl verification.\n}\n\n// New returns a Forge implementation that integrates with Gitea,\n// an open source Git service written in Go. See https://gitea.io/\nfunc New(id int64, opts Opts) (forge.Forge, error) {\n\treturn &Gitea{\n\t\tid:                id,\n\t\turl:               opts.URL,\n\t\toAuthClientID:     opts.OAuthClientID,\n\t\toAuthClientSecret: opts.OAuthClientSecret,\n\t\toAuthHost:         opts.OAuthHost,\n\t\tskipVerify:        opts.SkipVerify,\n\t}, nil\n}\n\n// Name returns the string name of this driver.\nfunc (c *Gitea) Name() string {\n\treturn \"gitea\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (c *Gitea) URL() string {\n\treturn c.url\n}\n\nfunc (c *Gitea) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) {\n\tpublicOAuthURL := c.oAuthHost\n\tif publicOAuthURL == \"\" {\n\t\tpublicOAuthURL = c.url\n\t}\n\treturn &oauth2.Config{\n\t\t\tClientID:     c.oAuthClientID,\n\t\t\tClientSecret: c.oAuthClientSecret,\n\t\t\tEndpoint: oauth2.Endpoint{\n\t\t\t\tAuthURL:  fmt.Sprintf(authorizeTokenURL, publicOAuthURL),\n\t\t\t\tTokenURL: fmt.Sprintf(accessTokenURL, c.url),\n\t\t\t},\n\t\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t\t},\n\n\t\tcontext.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: c.skipVerify},\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t}})\n}\n\n// Login authenticates an account with Gitea using basic authentication. The\n// Gitea account details are returned when the user is successfully authenticated.\nfunc (c *Gitea) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig, oauth2Ctx := c.oauth2Config(ctx)\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\t// check the OAuth code\n\tif len(req.Code) == 0 {\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(oauth2Ctx, req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, fmt.Errorf(\"oauth2 config exchange failed: %w\", err)\n\t}\n\n\tclient, err := c.newClientToken(ctx, token.AccessToken)\n\tif err != nil {\n\t\treturn nil, redirectURL, fmt.Errorf(\"client creation with new access token failed: %w\", err)\n\t}\n\taccount, _, err := client.GetMyUserInfo()\n\tif err != nil {\n\t\treturn nil, redirectURL, fmt.Errorf(\"fetching user info failed: %w\", err)\n\t}\n\n\treturn &model.User{\n\t\tAccessToken:   token.AccessToken,\n\t\tRefreshToken:  token.RefreshToken,\n\t\tExpiry:        token.Expiry.UTC().Unix(),\n\t\tLogin:         account.UserName,\n\t\tEmail:         account.Email,\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.ID)),\n\t\tAvatar:        expandAvatar(c.url, account.AvatarURL),\n\t}, redirectURL, nil\n}\n\n// Refresh refreshes the Gitea oauth2 access token. If the token is\n// refreshed, the user is updated and a true value is returned.\nfunc (c *Gitea) Refresh(ctx context.Context, user *model.User) (bool, error) {\n\tconfig, oauth2Ctx := c.oauth2Config(ctx)\n\tconfig.RedirectURL = \"\"\n\n\tsource := config.TokenSource(oauth2Ctx, &oauth2.Token{\n\t\tAccessToken:  user.AccessToken,\n\t\tRefreshToken: user.RefreshToken,\n\t\tExpiry:       time.Unix(user.Expiry, 0),\n\t})\n\n\ttoken, err := source.Token()\n\tif err != nil || len(token.AccessToken) == 0 {\n\t\treturn false, err\n\t}\n\n\tuser.AccessToken = token.AccessToken\n\tuser.RefreshToken = token.RefreshToken\n\tuser.Expiry = token.Expiry.UTC().Unix()\n\treturn true, nil\n}\n\n// Teams is supported by the Gitea driver.\nfunc (c *Gitea) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn shared_utils.Paginate(func(page int) ([]*model.Team, error) {\n\t\torgs, _, err := client.ListMyOrgs(\n\t\t\tgitea.ListOrgsOptions{\n\t\t\t\tListOptions: gitea.ListOptions{\n\t\t\t\t\tPage:     page,\n\t\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\tteams := make([]*model.Team, 0, len(orgs))\n\t\tfor _, org := range orgs {\n\t\t\tteams = append(teams, toTeam(org, c.url))\n\t\t}\n\t\treturn teams, err\n\t}, -1)\n}\n\n// TeamPerm is not supported by the Gitea driver.\nfunc (c *Gitea) TeamPerm(_ *model.User, _ string) (*model.Perm, error) {\n\treturn nil, nil\n}\n\n// Repo returns the Gitea repository.\nfunc (c *Gitea) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif remoteID.IsValid() {\n\t\tintID, err := strconv.ParseInt(string(remoteID), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trepo, resp, err := client.GetRepoByID(intID)\n\t\tif err != nil {\n\t\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\treturn toRepo(repo), nil\n\t}\n\n\trepo, resp, err := client.GetRepo(owner, name)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn toRepo(repo), nil\n}\n\n// Repos returns a list of all repositories for the Gitea account, including\n// organization repositories.\nfunc (c *Gitea) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trepos, err := shared_utils.Paginate(func(page int) ([]*gitea.Repository, error) {\n\t\trepos, _, err := client.ListMyRepos(\n\t\t\tgitea.ListReposOptions{\n\t\t\t\tListOptions: gitea.ListOptions{\n\t\t\t\t\tPage:     page,\n\t\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t\treturn repos, err\n\t}, -1)\n\n\tresult := make([]*model.Repo, 0, len(repos))\n\tfor _, repo := range repos {\n\t\tif repo.Archived {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, toRepo(repo))\n\t}\n\treturn result, err\n}\n\n// File fetches the file from the Gitea repository and returns its contents.\nfunc (c *Gitea) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcfg, resp, err := client.GetFile(r.Owner, r.Name, b.Commit, f)\n\tif err != nil && resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t}\n\treturn cfg, err\n}\n\nfunc (c *Gitea) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {\n\tvar configs []*forge_types.FileMeta\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// List files in repository\n\tcontents, resp, err := client.ListContents(r.Owner, r.Name, b.Commit, f)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tfor _, e := range contents {\n\t\tif e.Type == \"file\" {\n\t\t\tdata, err := c.File(ctx, u, r, b, e.Path)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"multi-pipeline cannot get %s: %w\", e.Path, err)\n\t\t\t}\n\n\t\t\tconfigs = append(configs, &forge_types.FileMeta{\n\t\t\t\tName: e.Path,\n\t\t\t\tData: data,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn configs, nil\n}\n\n// Status is supported by the Gitea driver.\nfunc (c *Gitea) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = client.CreateStatus(\n\t\trepo.Owner,\n\t\trepo.Name,\n\t\tpipeline.Commit,\n\t\tgitea.CreateStatusOption{\n\t\t\tState:       getStatus(workflow.State),\n\t\t\tTargetURL:   common.GetPipelineStatusURL(repo, pipeline, workflow),\n\t\t\tDescription: common.GetPipelineStatusDescription(workflow.State),\n\t\t\tContext:     common.GetPipelineStatusContext(repo, pipeline, workflow),\n\t\t},\n\t)\n\treturn err\n}\n\n// Netrc returns a netrc file capable of authenticating Gitea requests and\n// cloning Gitea repositories. The netrc will use the global machine account\n// when configured.\nfunc (c *Gitea) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\tlogin := \"\"\n\ttoken := \"\"\n\n\tif u != nil {\n\t\tlogin = u.Login\n\t\ttoken = u.AccessToken\n\t}\n\n\thost, err := common.ExtractHostFromCloneURL(r.Clone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Netrc{\n\t\tLogin:    login,\n\t\tPassword: token,\n\t\tMachine:  host,\n\t\tType:     model.ForgeTypeGitea,\n\t}, nil\n}\n\n// Activate activates the repository by registering post-commit hooks with\n// the Gitea repository.\nfunc (c *Gitea) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tconfig := map[string]string{\n\t\t\"url\":          link,\n\t\t\"secret\":       r.Hash,\n\t\t\"content_type\": \"json\",\n\t}\n\thook := gitea.CreateHookOption{\n\t\tType:   gitea.HookTypeGitea,\n\t\tConfig: config,\n\t\tEvents: []string{\"push\", \"create\", \"pull_request\", \"release\"},\n\t\tActive: true,\n\t}\n\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, response, err := client.CreateRepoHook(r.Owner, r.Name, hook)\n\tif err != nil {\n\t\tif response != nil {\n\t\t\tif response.StatusCode == http.StatusNotFound {\n\t\t\t\treturn fmt.Errorf(\"could not find repository\")\n\t\t\t}\n\t\t\tif response.StatusCode == http.StatusOK {\n\t\t\t\treturn fmt.Errorf(\"could not find repository, repository was probably renamed\")\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Deactivate deactivates the repository be removing repository push hooks from\n// the Gitea repository.\nfunc (c *Gitea) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// make sure a repo rename does not trick us\n\tforgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thooks, err := shared_utils.Paginate(func(page int) ([]*gitea.Hook, error) {\n\t\thooks, _, err := client.ListRepoHooks(forgeRepo.Owner, forgeRepo.Name, gitea.ListHooksOptions{\n\t\t\tListOptions: gitea.ListOptions{\n\t\t\t\tPage:     page,\n\t\t\t\tPageSize: c.perPage(ctx),\n\t\t\t},\n\t\t})\n\t\treturn hooks, err\n\t}, -1)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thook := matchingHooks(hooks, link)\n\tif hook != nil {\n\t\t_, err := client.DeleteRepoHook(forgeRepo.Owner, forgeRepo.Name, hook.ID)\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (c *Gitea) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbranches, _, err := client.ListRepoBranches(r.Owner, r.Name,\n\t\tgitea.ListRepoBranchesOptions{ListOptions: gitea.ListOptions{Page: p.Page, PageSize: p.PerPage}})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]string, len(branches))\n\tfor i := range branches {\n\t\tresult[i] = branches[i].Name\n\t}\n\treturn result, err\n}\n\n// BranchHead returns the sha of the head (latest commit) of the specified branch.\nfunc (c *Gitea) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tb, _, err := client.GetRepoBranch(r.Owner, r.Name, branch)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &model.Commit{\n\t\tSHA:      b.Commit.ID,\n\t\tForgeURL: b.Commit.URL,\n\t}, nil\n}\n\nfunc (c *Gitea) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := c.newClientToken(ctx, token)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpullRequests, resp, err := client.ListRepoPullRequests(r.Owner, r.Name, gitea.ListPullRequestsOptions{\n\t\tListOptions: gitea.ListOptions{Page: p.Page, PageSize: p.PerPage},\n\t\tState:       gitea.StateOpen,\n\t})\n\tif err != nil {\n\t\t// Repositories without commits return empty list with status code 404\n\t\tif pullRequests != nil && resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\terr = nil\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tresult := make([]*model.PullRequest, len(pullRequests))\n\tfor i := range pullRequests {\n\t\tresult[i] = &model.PullRequest{\n\t\t\tIndex: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].Index))),\n\t\t\tTitle: pullRequests[i].Title,\n\t\t}\n\t}\n\treturn result, err\n}\n\n// Hook parses the incoming Gitea hook and returns the Repository and Pipeline\n// details. If the hook is unsupported nil values are returned.\nfunc (c *Gitea) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\trepo, pipeline, err := parseHook(r)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == \"\" {\n\t\ttagName := strings.Split(pipeline.Ref, \"/\")[2]\n\t\tsha, err := c.getTagCommitSHA(ctx, repo, tagName)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tpipeline.Commit = sha\n\t}\n\n\tif pipeline != nil && pipeline.IsPullRequest() && len(pipeline.ChangedFiles) == 0 {\n\t\tindex, err := strconv.ParseInt(strings.Split(pipeline.Ref, \"/\")[2], 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tpipeline.ChangedFiles, err = c.getChangedFilesForPR(ctx, repo, index)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"could not get changed files for PR %s#%d\", repo.FullName, index)\n\t\t}\n\t}\n\n\treturn repo, pipeline, nil\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (c *Gitea) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmember, _, err := client.CheckOrgMembership(owner, u.Login)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !member {\n\t\treturn &model.OrgPerm{}, nil\n\t}\n\n\tperm, _, err := client.GetOrgPermissions(owner, u.Login)\n\tif err != nil {\n\t\treturn &model.OrgPerm{Member: member}, err\n\t}\n\n\treturn &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil\n}\n\nfunc (c *Gitea) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {\n\tclient, err := c.newClientToken(ctx, u.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\torg, _, orgErr := client.GetOrg(owner)\n\tif orgErr == nil && org != nil {\n\t\treturn &model.Org{\n\t\t\tName:    org.Name,\n\t\t\tPrivate: gitea.VisibleType(org.Visibility) != gitea.VisibleTypePublic,\n\t\t}, nil\n\t}\n\n\tuser, _, err := client.GetUserInfo(owner)\n\tif err != nil {\n\t\tif orgErr != nil {\n\t\t\terr = errors.Join(orgErr, err)\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn &model.Org{\n\t\tName:    user.UserName,\n\t\tIsUser:  true,\n\t\tPrivate: user.Visibility != gitea.VisibleTypePublic,\n\t}, nil\n}\n\n// newClientToken returns the Gitea client with Token.\nfunc (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client, error) {\n\thttpClient := &http.Client{}\n\tif c.skipVerify {\n\t\thttpClient.Transport = &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t}\n\t}\n\twrappedClient := httputil.WrapClient(httpClient, \"forge-gitea\")\n\tclient, err := gitea.NewClient(c.url, gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx))\n\tif err != nil &&\n\t\t(errors.Is(err, &gitea.ErrUnknownVersion{}) || strings.Contains(err.Error(), \"Malformed version\")) {\n\t\t// we guess it's a dev gitea version\n\t\tlog.Error().Err(err).Msgf(\"could not detect gitea version, assume dev version %s\", giteaDevVersion)\n\t\tclient, err = gitea.NewClient(c.url, gitea.SetGiteaVersion(giteaDevVersion), gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx))\n\t}\n\treturn client, err\n}\n\n// getStatus is a helper function that converts a Woodpecker\n// status to a Gitea status.\nfunc getStatus(status model.StatusValue) gitea.StatusState {\n\tswitch status {\n\tcase model.StatusPending, model.StatusBlocked:\n\t\treturn gitea.StatusPending\n\tcase model.StatusRunning:\n\t\treturn gitea.StatusPending\n\tcase model.StatusSuccess:\n\t\treturn gitea.StatusSuccess\n\tcase model.StatusFailure:\n\t\treturn gitea.StatusFailure\n\tcase model.StatusKilled:\n\t\treturn gitea.StatusFailure\n\tcase model.StatusDeclined:\n\t\treturn gitea.StatusWarning\n\tcase model.StatusError:\n\t\treturn gitea.StatusError\n\tdefault:\n\t\treturn gitea.StatusFailure\n\t}\n}\n\nfunc (c *Gitea) getChangedFilesForPR(ctx context.Context, repo *model.Repo, index int64) ([]string, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn []string{}, nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tforge.Refresh(ctx, c, _store, user)\n\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn shared_utils.Paginate(func(page int) ([]string, error) {\n\t\tgiteaFiles, _, err := client.ListPullRequestFiles(repo.Owner, repo.Name, index,\n\t\t\tgitea.ListPullRequestFilesOptions{ListOptions: gitea.ListOptions{Page: page}})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar files []string\n\t\tfor _, file := range giteaFiles {\n\t\t\tfiles = append(files, file.Filename)\n\t\t}\n\t\treturn files, nil\n\t}, -1)\n}\n\nfunc (c *Gitea) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn \"\", nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tforge.Refresh(ctx, c, _store, user)\n\n\tclient, err := c.newClientToken(ctx, user.AccessToken)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttag, _, err := client.GetTag(repo.Owner, repo.Name, tagName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn tag.Commit.SHA, nil\n}\n\nfunc (c *Gitea) perPage(ctx context.Context) int {\n\tif c.pageSize == 0 {\n\t\tclient, err := c.newClientToken(ctx, \"\")\n\t\tif err != nil {\n\t\t\treturn defaultPageSize\n\t\t}\n\n\t\tapi, _, err := client.GetGlobalAPISettings()\n\t\tif err != nil {\n\t\t\treturn defaultPageSize\n\t\t}\n\t\tc.pageSize = api.MaxResponseItems\n\t}\n\treturn c.pageSize\n}\n"
  },
  {
    "path": "server/forge/gitea/gitea_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestNew(t *testing.T) {\n\tforge, _ := New(1, Opts{\n\t\tURL:        \"http://localhost:8080\",\n\t\tSkipVerify: true,\n\t})\n\n\tf, _ := forge.(*Gitea)\n\tassert.Equal(t, \"http://localhost:8080\", f.url)\n\tassert.True(t, f.skipVerify)\n}\n\nfunc Test_gitea(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ts := httptest.NewServer(fixtures.Handler())\n\tdefer s.Close()\n\tc, _ := New(1, Opts{\n\t\tURL:        s.URL,\n\t\tSkipVerify: true,\n\t})\n\n\tmockStore := store_mocks.NewMockStore(t)\n\tctx := store.InjectToContext(t.Context(), mockStore)\n\n\tt.Run(\"netrc with user token\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(fakeUser, fakeRepo)\n\t\tassert.Equal(t, \"gitea.com\", netrc.Machine)\n\t\tassert.Equal(t, fakeUser.Login, netrc.Login)\n\t\tassert.Equal(t, fakeUser.AccessToken, netrc.Password)\n\t\tassert.Equal(t, model.ForgeTypeGitea, netrc.Type)\n\t})\n\tt.Run(\"netrc with machine account\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(nil, fakeRepo)\n\t\tassert.Equal(t, \"gitea.com\", netrc.Machine)\n\t\tassert.Empty(t, netrc.Login)\n\t\tassert.Empty(t, netrc.Password)\n\t})\n\n\tt.Run(\"repository details\", func(t *testing.T) {\n\t\trepo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeRepo.Owner, repo.Owner)\n\t\tassert.Equal(t, fakeRepo.Name, repo.Name)\n\t\tassert.Equal(t, fakeRepo.Owner+\"/\"+fakeRepo.Name, repo.FullName)\n\t\tassert.True(t, repo.IsSCMPrivate)\n\t\tassert.Equal(t, \"http://localhost/test_name/repo_name.git\", repo.Clone)\n\t\tassert.Equal(t, \"http://localhost/test_name/repo_name\", repo.ForgeURL)\n\t})\n\tt.Run(\"repo not found\", func(t *testing.T) {\n\t\t_, err := c.Repo(ctx, fakeUser, \"0\", fakeRepoNotFound.Owner, fakeRepoNotFound.Name)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"repository list\", func(t *testing.T) {\n\t\trepos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10})\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeRepo.ForgeRemoteID, repos[0].ForgeRemoteID)\n\t\tassert.Equal(t, fakeRepo.Owner, repos[0].Owner)\n\t\tassert.Equal(t, fakeRepo.Name, repos[0].Name)\n\t\tassert.Equal(t, fakeRepo.Owner+\"/\"+fakeRepo.Name, repos[0].FullName)\n\t})\n\tt.Run(\"not found error\", func(t *testing.T) {\n\t\t_, err := c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10})\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"register repository\", func(t *testing.T) {\n\t\terr := c.Activate(ctx, fakeUser, fakeRepo, \"http://localhost\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"remove hooks\", func(t *testing.T) {\n\t\terr := c.Deactivate(ctx, fakeUser, fakeRepo, \"http://localhost\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"repository file\", func(t *testing.T) {\n\t\traw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, \".woodpecker.yml\")\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"{ platform: linux/amd64 }\", string(raw))\n\t})\n\n\tt.Run(\"pipeline status\", func(t *testing.T) {\n\t\terr := c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"PR hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\t\treq.Header = http.Header{}\n\t\treq.Header.Set(hookEvent, hookPullRequest)\n\t\tmockStore.On(\"GetRepoNameFallback\", mock.Anything, mock.Anything, mock.Anything).Return(fakeRepo, nil)\n\t\tmockStore.On(\"GetUser\", mock.Anything).Return(fakeUser, nil)\n\t\tr, b, err := c.Hook(ctx, req)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.EventPull, b.Event)\n\t\tassert.Equal(t, []string{\"README.md\"}, b.ChangedFiles)\n\t})\n}\n\nvar (\n\tfakeUser = &model.User{\n\t\tLogin:       \"someuser\",\n\t\tAccessToken: \"cfcd2084\",\n\t}\n\n\tfakeUserNoRepos = &model.User{\n\t\tLogin:       \"someuser\",\n\t\tAccessToken: \"repos_not_found\",\n\t}\n\n\tfakeRepo = &model.Repo{\n\t\tClone:         \"http://gitea.com/test_name/repo_name.git\",\n\t\tForgeRemoteID: \"5\",\n\t\tOwner:         \"test_name\",\n\t\tName:          \"repo_name\",\n\t\tFullName:      \"test_name/repo_name\",\n\t}\n\n\tfakeRepoNotFound = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"repo_not_found\",\n\t\tFullName: \"test_name/repo_not_found\",\n\t}\n\n\tfakePipeline = &model.Pipeline{\n\t\tCommit: \"9ecad50\",\n\t}\n\n\tfakeWorkflow = &model.Workflow{\n\t\tName:  \"test\",\n\t\tState: model.StatusSuccess,\n\t}\n)\n"
  },
  {
    "path": "server/forge/gitea/helper.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"code.gitea.io/sdk/gitea\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\n// toRepo converts a Gitea repository to a Woodpecker repository.\nfunc toRepo(from *gitea.Repository) *model.Repo {\n\tname := strings.Split(from.FullName, \"/\")[1]\n\tavatar := expandAvatar(\n\t\tfrom.HTMLURL,\n\t\tfrom.Owner.AvatarURL,\n\t)\n\treturn &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.ID)),\n\t\tName:          name,\n\t\tOwner:         from.Owner.UserName,\n\t\tFullName:      from.FullName,\n\t\tAvatar:        avatar,\n\t\tForgeURL:      from.HTMLURL,\n\t\tIsSCMPrivate:  from.Private || from.Owner.Visibility != gitea.VisibleTypePublic,\n\t\tClone:         from.CloneURL,\n\t\tCloneSSH:      from.SSHURL,\n\t\tBranch:        from.DefaultBranch,\n\t\tPerm:          toPerm(from.Permissions),\n\t\tPREnabled:     from.HasPullRequests,\n\t}\n}\n\n// toPerm converts a Gitea permission to a Woodpecker permission.\nfunc toPerm(from *gitea.Permission) *model.Perm {\n\treturn &model.Perm{\n\t\tPull:  from.Pull,\n\t\tPush:  from.Push,\n\t\tAdmin: from.Admin,\n\t}\n}\n\n// toTeam converts a Gitea team to a Woodpecker team.\nfunc toTeam(from *gitea.Organization, link string) *model.Team {\n\treturn &model.Team{\n\t\tLogin:  from.Name,\n\t\tAvatar: expandAvatar(link, from.AvatarURL),\n\t}\n}\n\n// pipelineFromPush extracts the Pipeline data from a Gitea push hook.\nfunc pipelineFromPush(hook *pushHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\n\tvar message string\n\tlink := hook.Compare\n\tif len(hook.Commits) > 0 {\n\t\tmessage = hook.Commits[0].Message\n\t\tif len(hook.Commits) == 1 {\n\t\t\tlink = hook.Commits[0].URL\n\t\t}\n\t} else {\n\t\tmessage = hook.HeadCommit.Message\n\t\tlink = hook.HeadCommit.URL\n\t}\n\n\treturn &model.Pipeline{\n\t\tEvent:        model.EventPush,\n\t\tCommit:       hook.After,\n\t\tRef:          hook.Ref,\n\t\tForgeURL:     link,\n\t\tBranch:       strings.TrimPrefix(hook.Ref, \"refs/heads/\"),\n\t\tMessage:      message,\n\t\tAvatar:       avatar,\n\t\tAuthor:       hook.Sender.UserName,\n\t\tEmail:        hook.Sender.Email,\n\t\tTimestamp:    time.Now().UTC().Unix(),\n\t\tSender:       hook.Sender.UserName,\n\t\tChangedFiles: getChangedFilesFromPushHook(hook),\n\t}\n}\n\nfunc getChangedFilesFromPushHook(hook *pushHook) []string {\n\t// assume a capacity of 4 changed files per commit\n\tfiles := make([]string, 0, len(hook.Commits)*4)\n\tfor _, c := range hook.Commits {\n\t\tfiles = append(files, c.Added...)\n\t\tfiles = append(files, c.Removed...)\n\t\tfiles = append(files, c.Modified...)\n\t}\n\n\tfiles = append(files, hook.HeadCommit.Added...)\n\tfiles = append(files, hook.HeadCommit.Removed...)\n\tfiles = append(files, hook.HeadCommit.Modified...)\n\n\treturn utils.DeduplicateStrings(files)\n}\n\n// pipelineFromTag extracts the Pipeline data from a Gitea tag hook.\nfunc pipelineFromTag(hook *pushHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\tref := strings.TrimPrefix(hook.Ref, \"refs/tags/\")\n\n\treturn &model.Pipeline{\n\t\tEvent:     model.EventTag,\n\t\tCommit:    hook.Sha,\n\t\tRef:       fmt.Sprintf(\"refs/tags/%s\", ref),\n\t\tForgeURL:  fmt.Sprintf(\"%s/src/tag/%s\", hook.Repo.HTMLURL, ref),\n\t\tMessage:   fmt.Sprintf(\"created tag %s\", ref),\n\t\tAvatar:    avatar,\n\t\tAuthor:    hook.Sender.UserName,\n\t\tSender:    hook.Sender.UserName,\n\t\tEmail:     hook.Sender.Email,\n\t\tTimestamp: time.Now().UTC().Unix(),\n\t}\n}\n\n// pipelineFromPullRequest extracts the Pipeline data from a Gitea pull_request hook.\nfunc pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.PullRequest.Poster.AvatarURL),\n\t)\n\n\tevent := model.EventPull\n\tswitch hook.Action {\n\tcase actionClose:\n\t\tevent = model.EventPullClosed\n\tcase actionEdited,\n\t\tactionLabelUpdate,\n\t\tactionLabelCleared,\n\t\tactionMilestoned,\n\t\tactionDeMilestoned,\n\t\tactionAssigned,\n\t\tactionUnAssigned:\n\t\tevent = model.EventPullMetadata\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:    event,\n\t\tCommit:   hook.PullRequest.Head.Sha,\n\t\tForgeURL: hook.PullRequest.HTMLURL,\n\t\tRef:      fmt.Sprintf(\"refs/pull/%d/head\", hook.Number),\n\t\tBranch:   hook.PullRequest.Base.Ref,\n\t\tMessage:  hook.PullRequest.Title,\n\t\tAuthor:   hook.PullRequest.Poster.UserName,\n\t\tAvatar:   avatar,\n\t\tSender:   hook.Sender.UserName,\n\t\tEmail:    hook.Sender.Email,\n\t\tTitle:    hook.PullRequest.Title,\n\t\tRefspec: fmt.Sprintf(\"%s:%s\",\n\t\t\thook.PullRequest.Head.Ref,\n\t\t\thook.PullRequest.Base.Ref,\n\t\t),\n\t\tPullRequestLabels:    convertLabels(hook.PullRequest.Labels),\n\t\tPullRequestMilestone: convertMilestone(hook.PullRequest.Milestone),\n\t\tFromFork:             hook.PullRequest.Head.RepoID != hook.PullRequest.Base.RepoID,\n\t}\n\n\tif pipeline.Event == model.EventPullMetadata {\n\t\tpipeline.EventReason = []string{hook.Action}\n\t}\n\n\treturn pipeline\n}\n\nfunc convertMilestone(milestone *gitea.Milestone) string {\n\tif milestone == nil || milestone.ID == 0 {\n\t\treturn \"\"\n\t}\n\treturn milestone.Title\n}\n\nfunc pipelineFromRelease(hook *releaseHook) *model.Pipeline {\n\tavatar := expandAvatar(\n\t\thook.Repo.HTMLURL,\n\t\tfixMalformedAvatar(hook.Sender.AvatarURL),\n\t)\n\n\treturn &model.Pipeline{\n\t\tEvent:        model.EventRelease,\n\t\tRef:          fmt.Sprintf(\"refs/tags/%s\", hook.Release.TagName),\n\t\tForgeURL:     hook.Release.HTMLURL,\n\t\tBranch:       hook.Release.Target,\n\t\tMessage:      fmt.Sprintf(\"created release %s\", hook.Release.Title),\n\t\tAvatar:       avatar,\n\t\tAuthor:       hook.Sender.UserName,\n\t\tSender:       hook.Sender.UserName,\n\t\tEmail:        hook.Sender.Email,\n\t\tIsPrerelease: hook.Release.IsPrerelease,\n\t}\n}\n\n// parsePush parses a push hook from a read closer.\nfunc parsePush(r io.Reader) (*pushHook, error) {\n\tpush := new(pushHook)\n\terr := json.NewDecoder(r).Decode(push)\n\treturn push, err\n}\n\nfunc parsePullRequest(r io.Reader) (*pullRequestHook, error) {\n\tpr := new(pullRequestHook)\n\terr := json.NewDecoder(r).Decode(pr)\n\treturn pr, err\n}\n\nfunc parseRelease(r io.Reader) (*releaseHook, error) {\n\tpr := new(releaseHook)\n\terr := json.NewDecoder(r).Decode(pr)\n\treturn pr, err\n}\n\n// fixMalformedAvatar fixes an avatar url if malformed (currently a known bug with gitea).\nfunc fixMalformedAvatar(url string) string {\n\tindex := strings.Index(url, \"///\")\n\tif index != -1 {\n\t\treturn url[index+1:]\n\t}\n\tindex = strings.Index(url, \"//avatars/\")\n\tif index != -1 {\n\t\treturn strings.ReplaceAll(url, \"//avatars/\", \"/avatars/\")\n\t}\n\treturn url\n}\n\n// expandAvatar converts a relative avatar URL to the absolute url.\nfunc expandAvatar(repo, rawURL string) string {\n\taURL, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn rawURL\n\t}\n\tif aURL.IsAbs() {\n\t\t// Url is already absolute\n\t\treturn aURL.String()\n\t}\n\n\t// Resolve to base\n\tburl, err := url.Parse(repo)\n\tif err != nil {\n\t\treturn rawURL\n\t}\n\taURL = burl.ResolveReference(aURL)\n\n\treturn aURL.String()\n}\n\n// matchingHooks return matching hooks.\nfunc matchingHooks(hooks []*gitea.Hook, rawURL string) *gitea.Hook {\n\tlink, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tfor _, hook := range hooks {\n\t\tif val, ok := hook.Config[\"url\"]; ok {\n\t\t\thookURL, err := url.Parse(val)\n\t\t\tif err == nil && hookURL.Host == link.Host {\n\t\t\t\treturn hook\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc convertLabels(from []*gitea.Label) []string {\n\tlabels := make([]string, len(from))\n\tfor i, label := range from {\n\t\tlabels[i] = label.Name\n\t}\n\treturn labels\n}\n"
  },
  {
    "path": "server/forge/gitea/helper_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\n\t\"code.gitea.io/sdk/gitea\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_parsePush(t *testing.T) {\n\tt.Run(\"Should parse push hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, err := parsePush(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"refs/heads/main\", hook.Ref)\n\t\tassert.Equal(t, \"ef98532add3b2feb7a137426bba1248724367df5\", hook.After)\n\t\tassert.Equal(t, \"4b2626259b5a97b6b4eab5e6cca66adb986b672b\", hook.Before)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5\", hook.Compare)\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Pusher.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Pusher.UserName)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\t})\n\tt.Run(\"Should parse tag hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookTag)\n\t\thook, err := parsePush(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"v1.0.0\", hook.Ref)\n\t\tassert.Equal(t, \"ef98532add3b2feb7a137426bba1248724367df5\", hook.Sha)\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, _ := parsePush(buf)\n\t\tpipeline := pipelineFromPush(hook)\n\t\tassert.Equal(t, model.EventPush, pipeline.Event)\n\t\tassert.Equal(t, hook.After, pipeline.Commit)\n\t\tassert.Equal(t, hook.Ref, pipeline.Ref)\n\t\tassert.Equal(t, hook.Commits[0].URL, pipeline.ForgeURL)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, hook.Commits[0].Message, pipeline.Message)\n\t\tassert.Equal(t, \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", pipeline.Avatar)\n\t\tassert.Equal(t, hook.Sender.UserName, pipeline.Author)\n\t\tassert.Equal(t, []string{\"CHANGELOG.md\", \"app/controller/application.rb\"}, pipeline.ChangedFiles)\n\t})\n\n\tt.Run(\"Should return a Repo struct from a push hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPush)\n\t\thook, _ := parsePush(buf)\n\t\trepo := toRepo(hook.Repo)\n\t\tassert.Equal(t, hook.Repo.Name, repo.Name)\n\t\tassert.Equal(t, hook.Repo.Owner.UserName, repo.Owner)\n\t\tassert.Equal(t, \"gordon/hello-world\", repo.FullName)\n\t\tassert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a tag hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookTag)\n\t\thook, _ := parsePush(buf)\n\t\tpipeline := pipelineFromTag(hook)\n\t\tassert.Equal(t, model.EventTag, pipeline.Event)\n\t\tassert.Equal(t, hook.Sha, pipeline.Commit)\n\t\tassert.Equal(t, \"refs/tags/v1.0.0\", pipeline.Ref)\n\t\tassert.Empty(t, pipeline.Branch)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world/src/tag/v1.0.0\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"created tag v1.0.0\", pipeline.Message)\n\t})\n}\n\nfunc Test_parsePullRequest(t *testing.T) {\n\tt.Run(\"Should parse pull_request hook payload\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, err := parsePullRequest(buf)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"opened\", hook.Action)\n\t\tassert.Equal(t, int64(1), hook.Number)\n\n\t\tassert.Equal(t, \"hello-world\", hook.Repo.Name)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world\", hook.Repo.HTMLURL)\n\t\tassert.Equal(t, \"gordon/hello-world\", hook.Repo.FullName)\n\t\tassert.Equal(t, \"gordon@golang.org\", hook.Repo.Owner.Email)\n\t\tassert.Equal(t, \"gordon\", hook.Repo.Owner.UserName)\n\t\tassert.True(t, hook.Repo.Private)\n\t\tassert.Equal(t, \"gordon\", hook.Sender.UserName)\n\t\tassert.Equal(t, \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", hook.Sender.AvatarURL)\n\n\t\tassert.Equal(t, \"Update the README with new information\", hook.PullRequest.Title)\n\t\tassert.Equal(t, \"please merge\", hook.PullRequest.Body)\n\t\tassert.Equal(t, gitea.StateOpen, hook.PullRequest.State)\n\t\tassert.Equal(t, \"gordon\", hook.PullRequest.Poster.UserName)\n\t\tassert.Equal(t, \"main\", hook.PullRequest.Base.Name)\n\t\tassert.Equal(t, \"main\", hook.PullRequest.Base.Ref)\n\t\tassert.Equal(t, \"feature/changes\", hook.PullRequest.Head.Name)\n\t\tassert.Equal(t, \"feature/changes\", hook.PullRequest.Head.Ref)\n\t})\n\n\tt.Run(\"Should return a Pipeline struct from a pull_request hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, _ := parsePullRequest(buf)\n\t\tpipeline := pipelineFromPullRequest(hook)\n\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\tassert.Equal(t, hook.PullRequest.Head.Sha, pipeline.Commit)\n\t\tassert.Equal(t, \"refs/pull/1/head\", pipeline.Ref)\n\t\tassert.Equal(t, \"http://gitea.golang.org/gordon/hello-world/pull/1\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"feature/changes:main\", pipeline.Refspec)\n\t\tassert.Equal(t, hook.PullRequest.Title, pipeline.Message)\n\t\tassert.Equal(t, \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\", pipeline.Avatar)\n\t\tassert.Equal(t, hook.PullRequest.Poster.UserName, pipeline.Author)\n\t})\n\n\tt.Run(\"Should return a Repo struct from a pull_request hook\", func(t *testing.T) {\n\t\tbuf := bytes.NewBufferString(fixtures.HookPullRequest)\n\t\thook, _ := parsePullRequest(buf)\n\t\trepo := toRepo(hook.Repo)\n\t\tassert.Equal(t, hook.Repo.Name, repo.Name)\n\t\tassert.Equal(t, hook.Repo.Owner.UserName, repo.Owner)\n\t\tassert.Equal(t, \"gordon/hello-world\", repo.FullName)\n\t\tassert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL)\n\t})\n}\n\nfunc Test_toPerm(t *testing.T) {\n\tperms := []gitea.Permission{\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  true,\n\t\t\tPull:  true,\n\t\t},\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  true,\n\t\t\tPull:  false,\n\t\t},\n\t\t{\n\t\t\tAdmin: true,\n\t\t\tPush:  false,\n\t\t\tPull:  false,\n\t\t},\n\t}\n\tfor _, from := range perms {\n\t\tperm := toPerm(&from)\n\t\tassert.Equal(t, from.Pull, perm.Pull)\n\t\tassert.Equal(t, from.Push, perm.Push)\n\t\tassert.Equal(t, from.Admin, perm.Admin)\n\t}\n}\n\nfunc Test_toTeam(t *testing.T) {\n\tfrom := &gitea.Organization{\n\t\tName:      \"woodpecker\",\n\t\tAvatarURL: \"/avatars/1\",\n\t}\n\n\tto := toTeam(from, \"http://localhost:80\")\n\tassert.Equal(t, from.Name, to.Login)\n\tassert.Equal(t, \"http://localhost:80/avatars/1\", to.Avatar)\n}\n\nfunc Test_toRepo(t *testing.T) {\n\tfrom := gitea.Repository{\n\t\tFullName: \"gophers/hello-world\",\n\t\tOwner: &gitea.User{\n\t\t\tUserName:  \"gordon\",\n\t\t\tAvatarURL: \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\tCloneURL:      \"http://gitea.golang.org/gophers/hello-world.git\",\n\t\tHTMLURL:       \"http://gitea.golang.org/gophers/hello-world\",\n\t\tPrivate:       true,\n\t\tDefaultBranch: \"main\",\n\t\tPermissions:   &gitea.Permission{Admin: true},\n\t}\n\trepo := toRepo(&from)\n\tassert.Equal(t, from.FullName, repo.FullName)\n\tassert.Equal(t, from.Owner.UserName, repo.Owner)\n\tassert.Equal(t, \"hello-world\", repo.Name)\n\tassert.Equal(t, \"main\", repo.Branch)\n\tassert.Equal(t, from.HTMLURL, repo.ForgeURL)\n\tassert.Equal(t, from.CloneURL, repo.Clone)\n\tassert.Equal(t, from.Owner.AvatarURL, repo.Avatar)\n\tassert.Equal(t, from.Private, repo.IsSCMPrivate)\n\tassert.True(t, repo.Perm.Admin)\n}\n\nfunc Test_fixMalformedAvatar(t *testing.T) {\n\turls := []struct {\n\t\tBefore string\n\t\tAfter  string\n\t}{\n\t\t{\n\t\t\t\"http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"http://gitea.golang.org/avatars/1\",\n\t\t\t\"http://gitea.golang.org/avatars/1\",\n\t\t},\n\t\t{\n\t\t\t\"http://gitea.golang.org//avatars/1\",\n\t\t\t\"http://gitea.golang.org/avatars/1\",\n\t\t},\n\t}\n\n\tfor _, url := range urls {\n\t\tgot := fixMalformedAvatar(url.Before)\n\t\tassert.Equal(t, url.After, got)\n\t}\n}\n\nfunc Test_expandAvatar(t *testing.T) {\n\turls := []struct {\n\t\tBefore string\n\t\tAfter  string\n\t}{\n\t\t{\n\t\t\t\"/avatars/1\",\n\t\t\t\"http://gitea.io/avatars/1\",\n\t\t},\n\t\t{\n\t\t\t\"//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t},\n\t\t{\n\t\t\t\"/gitea/avatars/2\",\n\t\t\t\"http://gitea.io/gitea/avatars/2\",\n\t\t},\n\t}\n\n\trepo := \"http://gitea.io/foo/bar\"\n\tfor _, url := range urls {\n\t\tgot := expandAvatar(repo, url.Before)\n\t\tassert.Equal(t, url.After, got)\n\t}\n}\n"
  },
  {
    "path": "server/forge/gitea/parse.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\thookEvent       = \"X-Gitea-Event\"\n\thookPush        = \"push\"\n\thookCreated     = \"create\"\n\thookPullRequest = \"pull_request\"\n\thookRelease     = \"release\"\n\n\tactionOpen         = \"opened\"\n\tactionSync         = \"synchronized\"\n\tactionClose        = \"closed\"\n\tactionEdited       = \"edited\"\n\tactionLabelUpdate  = \"label_updated\"\n\tactionLabelCleared = \"label_cleared\"\n\tactionMilestoned   = \"milestoned\"\n\tactionDeMilestoned = \"demilestoned\"\n\tactionAssigned     = \"assigned\"\n\tactionUnAssigned   = \"unassigned\"\n\tactionReopen       = \"reopened\"\n\n\trefBranch = \"branch\"\n\trefTag    = \"tag\"\n)\n\nvar actionList = []string{\n\tactionOpen,\n\tactionSync,\n\tactionClose,\n\tactionEdited,\n\tactionLabelUpdate,\n\tactionMilestoned,\n\tactionDeMilestoned,\n\tactionLabelCleared,\n\tactionAssigned,\n\tactionUnAssigned,\n\tactionReopen,\n}\n\nfunc supportedAction(action string) bool {\n\treturn slices.Contains(actionList, action)\n}\n\n// parseHook parses a Gitea hook from an http.Request and returns\n// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.\nfunc parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\thookType := r.Header.Get(hookEvent)\n\tswitch hookType {\n\tcase hookPush:\n\t\treturn parsePushHook(r.Body)\n\tcase hookCreated:\n\t\treturn parseCreatedHook(r.Body)\n\tcase hookPullRequest:\n\t\treturn parsePullRequestHook(r.Body)\n\tcase hookRelease:\n\t\treturn parseReleaseHook(r.Body)\n\t}\n\tlog.Debug().Msgf(\"unsupported hook type: '%s'\", hookType)\n\treturn nil, nil, &types.ErrIgnoreEvent{Event: hookType}\n}\n\n// parsePushHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {\n\tpush, err := parsePush(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// ignore push events for tags\n\tif strings.HasPrefix(push.Ref, \"refs/tags/\") {\n\t\treturn nil, nil, nil\n\t}\n\n\t// TODO: is this even needed?\n\tif push.RefType == refBranch {\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(push.Repo)\n\tpipeline = pipelineFromPush(push)\n\treturn repo, pipeline, err\n}\n\n// parseCreatedHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) {\n\tpush, err := parsePush(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif push.RefType != refTag {\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(push.Repo)\n\tpipeline = pipelineFromTag(push)\n\treturn repo, pipeline, nil\n}\n\n// parsePullRequestHook parses a pull_request hook and returns the Repo and Pipeline details.\nfunc parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {\n\tvar (\n\t\trepo     *model.Repo\n\t\tpipeline *model.Pipeline\n\t)\n\n\tpr, err := parsePullRequest(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif pr.PullRequest == nil {\n\t\t// this should never have happened but it did - so we check\n\t\treturn nil, nil, fmt.Errorf(\"parsed pull_request webhook does not contain pull_request info\")\n\t}\n\n\t// Only trigger pipelines for supported event types\n\tif !supportedAction(pr.Action) {\n\t\tlog.Debug().Msgf(\"pull_request action is '%s'. Only '%s' are supported\", pr.Action, strings.Join(actionList, \"', '\"))\n\t\treturn nil, nil, nil\n\t}\n\n\trepo = toRepo(pr.Repo)\n\tpipeline = pipelineFromPullRequest(pr)\n\n\t// all other actions return the state of labels after the actions where done ... so we should too\n\tif pr.Action == actionLabelCleared {\n\t\tpipeline.PullRequestLabels = []string{}\n\t}\n\tif pr.Action == actionDeMilestoned {\n\t\tpipeline.PullRequestMilestone = \"\"\n\t}\n\n\tfor i := range pipeline.EventReason {\n\t\tpipeline.EventReason[i] = common.NormalizeEventReason(pipeline.EventReason[i])\n\t}\n\n\treturn repo, pipeline, err\n}\n\n// parseReleaseHook parses a release hook and returns the Repo and Pipeline details.\nfunc parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {\n\tvar (\n\t\trepo     *model.Repo\n\t\tpipeline *model.Pipeline\n\t)\n\n\trelease, err := parseRelease(payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\trepo = toRepo(release.Repo)\n\tpipeline = pipelineFromRelease(release)\n\treturn repo, pipeline, err\n}\n"
  },
  {
    "path": "server/forge/gitea/parse_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestGiteaParser(t *testing.T) {\n\tpullMetaWebhookRepo := &model.Repo{\n\t\tForgeRemoteID: \"1234\",\n\t\tOwner:         \"a_nice_user\",\n\t\tName:          \"hello_world_ci\",\n\t\tFullName:      \"a_nice_user/hello_world_ci\",\n\t\tAvatar:        \"https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15\",\n\t\tForgeURL:      \"https://gitea.com/a_nice_user/hello_world_ci\",\n\t\tClone:         \"https://gitea.com/a_nice_user/hello_world_ci.git\",\n\t\tCloneSSH:      \"ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git\",\n\t\tBranch:        \"main\",\n\t\tPREnabled:     true,\n\t\tPerm: &model.Perm{\n\t\t\tPull:  true,\n\t\t\tPush:  true,\n\t\t\tAdmin: true,\n\t\t},\n\t}\n\n\ttests := []struct {\n\t\tname  string\n\t\tdata  string\n\t\tevent string\n\t\terr   error\n\t\trepo  *model.Repo\n\t\tpipe  *model.Pipeline\n\t}{\n\t\t{\n\t\t\tname:  \"should ignore unsupported hook events\",\n\t\t\tdata:  fixtures.HookPullRequest,\n\t\t\tevent: \"issues\",\n\t\t\terr:   &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should handle a push hook\",\n\t\t\tdata:  fixtures.HookPushBranch,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"50820\",\n\t\t\t\tOwner:         \"meisam\",\n\t\t\t\tName:          \"woodpecktester\",\n\t\t\t\tFullName:      \"meisam/woodpecktester\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/meisam/woodpecktester\",\n\t\t\t\tClone:         \"https://codeberg.org/meisam/woodpecktester.git\",\n\t\t\t\tCloneSSH:      \"git@codeberg.org:meisam/woodpecktester.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"6543\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"28c3613ae62640216bea5e7dc71aa65356e4298b\",\n\t\t\t\tBranch:       \"fdsafdsa\",\n\t\t\t\tRef:          \"refs/heads/fdsafdsa\",\n\t\t\t\tMessage:      \"Delete '.woodpecker/.check.yml'\\n\",\n\t\t\t\tSender:       \"6543\",\n\t\t\t\tAvatar:       \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:        \"6543@obermui.de\",\n\t\t\t\tForgeURL:     \"https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b\",\n\t\t\t\tChangedFiles: []string{\".woodpecker/.check.yml\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should extract repository and pipeline details\",\n\t\t\tdata:  fixtures.HookPush,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"1\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"http://gitea.golang.org/gordon/hello-world\",\n\t\t\t\tForgeURL:      \"http://gitea.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"http://gitea.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"git@gitea.golang.org:gordon/hello-world.git\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"gordon\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tBranch:       \"main\",\n\t\t\t\tRef:          \"refs/heads/main\",\n\t\t\t\tMessage:      \"bump\\n\",\n\t\t\t\tSender:       \"gordon\",\n\t\t\t\tAvatar:       \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:        \"gordon@golang.org\",\n\t\t\t\tForgeURL:     \"http://gitea.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tChangedFiles: []string{\"CHANGELOG.md\", \"app/controller/application.rb\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"push event should handle multi commit push\",\n\t\t\tdata:  fixtures.HookPushMulti,\n\t\t\tevent: \"push\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"6\",\n\t\t\t\tOwner:         \"Test-CI\",\n\t\t\t\tName:          \"multi-line-secrets\",\n\t\t\t\tFullName:      \"Test-CI/multi-line-secrets\",\n\t\t\t\tAvatar:        \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n\t\t\t\tForgeURL:      \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n\t\t\t\tClone:         \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:       \"test-user\",\n\t\t\t\tEvent:        \"push\",\n\t\t\t\tCommit:       \"29be01c073851cf0db0c6a466e396b725a670453\",\n\t\t\t\tBranch:       \"main\",\n\t\t\t\tRef:          \"refs/heads/main\",\n\t\t\t\tMessage:      \"add some text\\n\",\n\t\t\t\tSender:       \"test-user\",\n\t\t\t\tAvatar:       \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n\t\t\t\tEmail:        \"test@noreply.localhost\",\n\t\t\t\tForgeURL:     \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453\",\n\t\t\t\tChangedFiles: []string{\"aaa\", \"aa\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"tag event should handle a tag hook\",\n\t\t\tdata:  fixtures.HookTag,\n\t\t\tevent: \"create\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"12\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tForgeURL:      \"http://gitea.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"http://gitea.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"git@gitea.golang.org:gordon/hello-world.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"gordon\",\n\t\t\t\tEvent:    \"tag\",\n\t\t\t\tCommit:   \"ef98532add3b2feb7a137426bba1248724367df5\",\n\t\t\t\tRef:      \"refs/tags/v1.0.0\",\n\t\t\t\tMessage:  \"created tag v1.0.0\",\n\t\t\t\tSender:   \"gordon\",\n\t\t\t\tAvatar:   \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:    \"gordon@golang.org\",\n\t\t\t\tForgeURL: \"http://gitea.golang.org/gordon/hello-world/src/tag/v1.0.0\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR hook when PR got created\",\n\t\t\tdata:  fixtures.HookPullRequest,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"35129377\",\n\t\t\t\tOwner:         \"gordon\",\n\t\t\t\tName:          \"hello-world\",\n\t\t\t\tFullName:      \"gordon/hello-world\",\n\t\t\t\tAvatar:        \"https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tForgeURL:      \"http://gitea.golang.org/gordon/hello-world\",\n\t\t\t\tClone:         \"https://gitea.golang.org/gordon/hello-world.git\",\n\t\t\t\tCloneSSH:      \"\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"gordon\",\n\t\t\t\tEvent:             \"pull_request\",\n\t\t\t\tCommit:            \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"feature/changes:main\",\n\t\t\t\tTitle:             \"Update the README with new information\",\n\t\t\t\tMessage:           \"Update the README with new information\",\n\t\t\t\tSender:            \"gordon\",\n\t\t\t\tAvatar:            \"http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87\",\n\t\t\t\tEmail:             \"gordon@golang.org\",\n\t\t\t\tForgeURL:          \"http://gitea.golang.org/gordon/hello-world/pull/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request reopen events should handle a PR as it was first created\",\n\t\t\tdata:  fixtures.HookPullRequestReopened,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"138564\",\n\t\t\t\tOwner:         \"test_it\",\n\t\t\t\tName:          \"test_ci_thing\",\n\t\t\t\tFullName:      \"test_it/test_ci_thing\",\n\t\t\t\tAvatar:        \"https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb\",\n\t\t\t\tForgeURL:      \"https://codeberg.org/test_it/test_ci_thing\",\n\t\t\t\tClone:         \"https://codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tCloneSSH:      \"git@codeberg.org/test_it/test_ci_thing.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  false,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"6543\",\n\t\t\t\tEvent:             \"pull_request\",\n\t\t\t\tCommit:            \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"6543-patch-1:main\",\n\t\t\t\tTitle:             \"Some ned more AAAA\",\n\t\t\t\tMessage:           \"Some ned more AAAA\",\n\t\t\t\tSender:            \"6543\",\n\t\t\t\tAvatar:            \"https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173\",\n\t\t\t\tEmail:             \"6543@noreply.codeberg.org\",\n\t\t\t\tForgeURL:          \"https://codeberg.org/test_it/test_ci_thing/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR hook when PR got updated\",\n\t\t\tdata:  fixtures.HookPullRequestUpdated,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"6\",\n\t\t\t\tOwner:         \"Test-CI\",\n\t\t\t\tName:          \"multi-line-secrets\",\n\t\t\t\tFullName:      \"Test-CI/multi-line-secrets\",\n\t\t\t\tAvatar:        \"http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb\",\n\t\t\t\tForgeURL:      \"http://127.0.0.1:3000/Test-CI/multi-line-secrets\",\n\t\t\t\tClone:         \"http://127.0.0.1:3000/Test-CI/multi-line-secrets.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  false,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"test\",\n\t\t\t\tEvent:    \"pull_request\",\n\t\t\t\tCommit:   \"788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25\",\n\t\t\t\tBranch:   \"main\",\n\t\t\t\tRef:      \"refs/pull/2/head\",\n\t\t\t\tRefspec:  \"test-patch-1:main\",\n\t\t\t\tTitle:    \"New Pull\",\n\t\t\t\tMessage:  \"New Pull\",\n\t\t\t\tSender:   \"test\",\n\t\t\t\tAvatar:   \"http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea\",\n\t\t\t\tEmail:    \"test@noreply.localhost\",\n\t\t\t\tForgeURL: \"http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2\",\n\t\t\t\tPullRequestLabels: []string{\n\t\t\t\t\t\"Kind/Bug\",\n\t\t\t\t\t\"Kind/Security\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR closed hook when PR got closed\",\n\t\t\tdata:  fixtures.HookPullRequestClosed,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"46534\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"test-repo\",\n\t\t\t\tFullName:      \"anbraten/test-repo\",\n\t\t\t\tAvatar:        \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tForgeURL:      \"https://gitea.com/anbraten/test-repo\",\n\t\t\t\tClone:         \"https://gitea.com/anbraten/test-repo.git\",\n\t\t\t\tCloneSSH:      \"git@gitea.com:anbraten/test-repo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"anbraten\",\n\t\t\t\tEvent:             \"pull_request_closed\",\n\t\t\t\tCommit:            \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"anbraten-patch-1:main\",\n\t\t\t\tTitle:             \"Adjust file\",\n\t\t\t\tMessage:           \"Adjust file\",\n\t\t\t\tSender:            \"anbraten\",\n\t\t\t\tAvatar:            \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tEmail:             \"anbraten@sender.gitea.com\",\n\t\t\t\tForgeURL:          \"https://gitea.com/anbraten/test-repo/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR title change hook\",\n\t\t\tdata:  fixtures.HookPullRequestChangeTitle,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"edited\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"Edit pull title :D\",\n\t\t\t\tMessage:           \"Edit pull title :D\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR body change hook\",\n\t\t\tdata:  fixtures.HookPullRequestChangeBody,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"edited\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"pull-request events should ignore a PR add review request hook\",\n\t\t\tdata: fixtures.HookPullRequestAddReviewRequest,\n\t\t\terr:  &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname: \"pull-request events should ignore a PR add approval review request hook\",\n\t\t\tdata: fixtures.HookPullRequestReviewAck,\n\t\t\terr:  &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname: \"pull-request events should ignore a PR add reject review request hook\",\n\t\t\tdata: fixtures.HookPullRequestReviewDeny,\n\t\t\terr:  &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname: \"pull-request events should ignore a PR add comment review request hook\",\n\t\t\tdata: fixtures.HookPullRequestReviewComment,\n\t\t\terr:  &types.ErrIgnoreEvent{},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR add label hook\",\n\t\t\tdata:  fixtures.HookPullRequestAddLabel,\n\t\t\tevent: \"pull_request\", // type: pull_request_label\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"label_updated\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{\"bug\", \"help wanted\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR change label hook\",\n\t\t\tdata:  fixtures.HookPullRequestChangeLabel,\n\t\t\tevent: \"pull_request\", // type: pull_request_label\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"label_updated\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{\"bug\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR remove label hook\",\n\t\t\tdata:  fixtures.HookPullRequestRemoveLabel,\n\t\t\tevent: \"pull_request\", // type: pull_request_label\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"label_cleared\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR add milestone hook\",\n\t\t\tdata:  fixtures.HookPullRequestAddMile,\n\t\t\tevent: \"pull_request\", // type: pull_request_milestone\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"jony\",\n\t\t\t\tEvent:                model.EventPullMetadata,\n\t\t\t\tEventReason:          []string{\"milestoned\"},\n\t\t\t\tCommit:               \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/7/head\",\n\t\t\t\tRefspec:              \"jony-patch-1:main\",\n\t\t\t\tTitle:                \"somepull\",\n\t\t\t\tMessage:              \"somepull\",\n\t\t\t\tSender:               \"a_nice_user\",\n\t\t\t\tAvatar:               \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:                \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:             \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels:    []string{\"bug\", \"help wanted\"},\n\t\t\t\tPullRequestMilestone: \"new mile\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR change milestone hook\",\n\t\t\tdata:  fixtures.HookPullRequestChangeMile,\n\t\t\tevent: \"pull_request\", // type: pull_request_milestone\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"jony\",\n\t\t\t\tEvent:                model.EventPullMetadata,\n\t\t\t\tEventReason:          []string{\"milestoned\"},\n\t\t\t\tCommit:               \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/7/head\",\n\t\t\t\tRefspec:              \"jony-patch-1:main\",\n\t\t\t\tTitle:                \"somepull\",\n\t\t\t\tMessage:              \"somepull\",\n\t\t\t\tSender:               \"a_nice_user\",\n\t\t\t\tAvatar:               \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:                \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:             \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels:    []string{\"bug\", \"help wanted\"},\n\t\t\t\tPullRequestMilestone: \"closed mile\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR remove milestone hook\",\n\t\t\tdata:  fixtures.HookPullRequestRemoveMile,\n\t\t\tevent: \"pull_request\", // type: pull_request_milestone\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:               \"jony\",\n\t\t\t\tEvent:                model.EventPullMetadata,\n\t\t\t\tEventReason:          []string{\"demilestoned\"},\n\t\t\t\tCommit:               \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:               \"main\",\n\t\t\t\tRef:                  \"refs/pull/7/head\",\n\t\t\t\tRefspec:              \"jony-patch-1:main\",\n\t\t\t\tTitle:                \"somepull\",\n\t\t\t\tMessage:              \"somepull\",\n\t\t\t\tSender:               \"a_nice_user\",\n\t\t\t\tAvatar:               \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:                \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:             \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels:    []string{\"bug\", \"help wanted\"},\n\t\t\t\tPullRequestMilestone: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR add assignee hook\",\n\t\t\tdata:  fixtures.HookPullRequestAssigneesAdded,\n\t\t\tevent: \"pull_request\", // type: pull_request_assign\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"assigned\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{\"bug\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR remove assignee hook\",\n\t\t\tdata:  fixtures.HookPullRequestAssigneesRemoved,\n\t\t\tevent: \"pull_request\", // type: pull_request_assign\n\t\t\trepo:  pullMetaWebhookRepo,\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"jony\",\n\t\t\t\tEvent:             model.EventPullMetadata,\n\t\t\t\tEventReason:       []string{\"unassigned\"},\n\t\t\t\tCommit:            \"07977177c2cd7d46bad37b8472a9d50e7acb9d1f\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/7/head\",\n\t\t\t\tRefspec:           \"jony-patch-1:main\",\n\t\t\t\tTitle:             \"somepull\",\n\t\t\t\tMessage:           \"somepull\",\n\t\t\t\tSender:            \"a_nice_user\",\n\t\t\t\tAvatar:            \"https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e\",\n\t\t\t\tEmail:             \"a_nice_user@noreply.example.org\",\n\t\t\t\tForgeURL:          \"https://gitea.com/a_nice_user/hello_world_ci/pulls/7\",\n\t\t\t\tPullRequestLabels: []string{\"bug\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"pull-request events should handle a PR closed hook when PR was merged\",\n\t\t\tdata:  fixtures.HookPullRequestMerged,\n\t\t\tevent: \"pull_request\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"46534\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"test-repo\",\n\t\t\t\tFullName:      \"anbraten/test-repo\",\n\t\t\t\tAvatar:        \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tForgeURL:      \"https://gitea.com/anbraten/test-repo\",\n\t\t\t\tClone:         \"https://gitea.com/anbraten/test-repo.git\",\n\t\t\t\tCloneSSH:      \"git@gitea.com:anbraten/test-repo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:            \"anbraten\",\n\t\t\t\tEvent:             \"pull_request_closed\",\n\t\t\t\tCommit:            \"d555a5dd07f4d0148a58d4686ec381502ae6a2d4\",\n\t\t\t\tBranch:            \"main\",\n\t\t\t\tRef:               \"refs/pull/1/head\",\n\t\t\t\tRefspec:           \"anbraten-patch-1:main\",\n\t\t\t\tTitle:             \"Adjust file\",\n\t\t\t\tMessage:           \"Adjust file\",\n\t\t\t\tSender:            \"anbraten\",\n\t\t\t\tAvatar:            \"https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon\",\n\t\t\t\tEmail:             \"anbraten@noreply.gitea.com\",\n\t\t\t\tForgeURL:          \"https://gitea.com/anbraten/test-repo/pulls/1\",\n\t\t\t\tPullRequestLabels: []string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"release events should handle release hook\",\n\t\t\tdata:  fixtures.HookRelease,\n\t\t\tevent: \"release\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tForgeRemoteID: \"77\",\n\t\t\t\tOwner:         \"anbraten\",\n\t\t\t\tName:          \"demo\",\n\t\t\t\tFullName:      \"anbraten/demo\",\n\t\t\t\tAvatar:        \"https://git.xxx/user/avatar/anbraten/-1\",\n\t\t\t\tForgeURL:      \"https://git.xxx/anbraten/demo\",\n\t\t\t\tClone:         \"https://git.xxx/anbraten/demo.git\",\n\t\t\t\tCloneSSH:      \"ssh://git@git.xxx:22/anbraten/demo.git\",\n\t\t\t\tBranch:        \"main\",\n\t\t\t\tPREnabled:     true,\n\t\t\t\tIsSCMPrivate:  true,\n\t\t\t\tPerm: &model.Perm{\n\t\t\t\t\tPull:  true,\n\t\t\t\t\tPush:  true,\n\t\t\t\t\tAdmin: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\tpipe: &model.Pipeline{\n\t\t\t\tAuthor:   \"anbraten\",\n\t\t\t\tEvent:    \"release\",\n\t\t\t\tBranch:   \"main\",\n\t\t\t\tRef:      \"refs/tags/0.0.5\",\n\t\t\t\tMessage:  \"created release Version 0.0.5\",\n\t\t\t\tSender:   \"anbraten\",\n\t\t\t\tAvatar:   \"https://git.xxx/user/avatar/anbraten/-1\",\n\t\t\t\tEmail:    \"anbraten@noreply.xxx\",\n\t\t\t\tForgeURL: \"https://git.xxx/anbraten/demo/releases/tag/0.0.5\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(http.MethodPost, \"/api/hook\", bytes.NewBufferString(tc.data))\n\t\t\treq.Header = http.Header{}\n\t\t\treq.Header.Set(hookEvent, tc.event)\n\t\t\tr, p, err := parseHook(req)\n\t\t\tif tc.err != nil {\n\t\t\t\tassert.ErrorIs(t, err, tc.err)\n\t\t\t} else if assert.NoError(t, err) {\n\t\t\t\tassert.EqualValues(t, tc.repo, r)\n\t\t\t\tp.Timestamp = 0\n\t\t\t\tassert.EqualValues(t, tc.pipe, p)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/forge/gitea/types.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitea\n\nimport \"code.gitea.io/sdk/gitea\"\n\ntype pushHook struct {\n\tSha     string `json:\"sha\"`\n\tRef     string `json:\"ref\"`\n\tBefore  string `json:\"before\"`\n\tAfter   string `json:\"after\"`\n\tCompare string `json:\"compare_url\"`\n\tRefType string `json:\"ref_type\"`\n\n\tPusher *gitea.User `json:\"pusher\"`\n\n\tRepo *gitea.Repository `json:\"repository\"`\n\n\tCommits []gitea.PayloadCommit `json:\"commits\"`\n\n\tHeadCommit gitea.PayloadCommit `json:\"head_commit\"`\n\n\tSender *gitea.User `json:\"sender\"`\n}\n\ntype pullRequestHook struct {\n\tAction      string             `json:\"action\"`\n\tNumber      int64              `json:\"number\"`\n\tPullRequest *gitea.PullRequest `json:\"pull_request\"`\n\tRepo        *gitea.Repository  `json:\"repository\"`\n\tSender      *gitea.User        `json:\"sender\"`\n}\n\ntype releaseHook struct {\n\tAction  string            `json:\"action\"`\n\tRepo    *gitea.Repository `json:\"repository\"`\n\tSender  *gitea.User       `json:\"sender\"`\n\tRelease *gitea.Release\n}\n"
  },
  {
    "path": "server/forge/github/convert.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/google/go-github/v86/github\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\tstatusPending = \"pending\"\n\tstatusSuccess = \"success\"\n\tstatusFailure = \"failure\"\n\tstatusError   = \"error\"\n)\n\nconst (\n\tdescPending  = \"this pipeline is pending\"\n\tdescSuccess  = \"the pipeline was successful\"\n\tdescFailure  = \"the pipeline failed\"\n\tdescBlocked  = \"the pipeline requires approval\"\n\tdescDeclined = \"the pipeline was rejected\"\n\tdescError    = \"oops, something went wrong\"\n)\n\nconst (\n\theadRefs  = \"refs/pull/%d/head\"  // pull request unmerged\n\tmergeRefs = \"refs/pull/%d/merge\" // pull request merged with base\n\trefSpec   = \"%s:%s\"\n)\n\n// convertStatus is a helper function used to convert a Woodpecker status to a\n// GitHub commit status.\nfunc convertStatus(status model.StatusValue) string {\n\tswitch status {\n\tcase model.StatusPending, model.StatusRunning, model.StatusBlocked, model.StatusSkipped, model.StatusCanceled:\n\t\treturn statusPending\n\tcase model.StatusFailure, model.StatusDeclined:\n\t\treturn statusFailure\n\tcase model.StatusSuccess:\n\t\treturn statusSuccess\n\tdefault:\n\t\treturn statusError\n\t}\n}\n\n// convertDesc is a helper function used to convert a Woodpecker status to a\n// GitHub status description.\nfunc convertDesc(status model.StatusValue) string {\n\tswitch status {\n\tcase model.StatusPending, model.StatusRunning:\n\t\treturn descPending\n\tcase model.StatusSuccess:\n\t\treturn descSuccess\n\tcase model.StatusFailure:\n\t\treturn descFailure\n\tcase model.StatusBlocked:\n\t\treturn descBlocked\n\tcase model.StatusDeclined:\n\t\treturn descDeclined\n\tdefault:\n\t\treturn descError\n\t}\n}\n\n// convertRepo is a helper function used to convert a GitHub repository\n// structure to the common Woodpecker repository structure.\nfunc convertRepo(from *github.Repository) *model.Repo {\n\trepo := &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.GetID())),\n\t\tName:          from.GetName(),\n\t\tFullName:      from.GetFullName(),\n\t\tForgeURL:      from.GetHTMLURL(),\n\t\tIsSCMPrivate:  from.GetPrivate(),\n\t\tClone:         from.GetCloneURL(),\n\t\tCloneSSH:      from.GetSSHURL(),\n\t\tBranch:        from.GetDefaultBranch(),\n\t\tOwner:         from.GetOwner().GetLogin(),\n\t\tAvatar:        from.GetOwner().GetAvatarURL(),\n\t\tPerm:          convertPerm(from.GetPermissions()),\n\t\tPREnabled:     true,\n\t}\n\treturn repo\n}\n\n// convertPerm is a helper function used to convert a GitHub repository\n// permissions to the common Woodpecker permissions structure.\nfunc convertPerm(perm *github.RepositoryPermissions) *model.Perm {\n\treturn &model.Perm{\n\t\tAdmin: perm.GetAdmin(),\n\t\tPush:  perm.GetPush(),\n\t\tPull:  perm.GetPull(),\n\t}\n}\n\n// convertRepoList is a helper function used to convert a GitHub repository\n// list to the common Woodpecker repository structure.\nfunc convertRepoList(from []*github.Repository) []*model.Repo {\n\tvar repos []*model.Repo\n\tfor _, repo := range from {\n\t\trepos = append(repos, convertRepo(repo))\n\t}\n\treturn repos\n}\n\n// convertTeamList is a helper function used to convert a GitHub team list to\n// the common Woodpecker repository structure.\nfunc convertTeamList(from []*github.Organization) []*model.Team {\n\tvar teams []*model.Team\n\tfor _, team := range from {\n\t\tteams = append(teams, convertTeam(team))\n\t}\n\treturn teams\n}\n\n// convertTeam is a helper function used to convert a GitHub team structure\n// to the common Woodpecker repository structure.\nfunc convertTeam(from *github.Organization) *model.Team {\n\treturn &model.Team{\n\t\tLogin:  from.GetLogin(),\n\t\tAvatar: from.GetAvatarURL(),\n\t}\n}\n\n// convertRepoHook is a helper function used to extract the Repository details\n// from a webhook and convert to the common Woodpecker repository structure.\nfunc convertRepoHook(eventRepo *github.PushEventRepository) *model.Repo {\n\trepo := &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(eventRepo.GetID())),\n\t\tOwner:         eventRepo.GetOwner().GetLogin(),\n\t\tName:          eventRepo.GetName(),\n\t\tFullName:      eventRepo.GetFullName(),\n\t\tForgeURL:      eventRepo.GetHTMLURL(),\n\t\tIsSCMPrivate:  eventRepo.GetPrivate(),\n\t\tClone:         eventRepo.GetCloneURL(),\n\t\tCloneSSH:      eventRepo.GetSSHURL(),\n\t\tBranch:        eventRepo.GetDefaultBranch(),\n\t\tPREnabled:     true,\n\t}\n\tif repo.FullName == \"\" {\n\t\trepo.FullName = repo.Owner + \"/\" + repo.Name\n\t}\n\treturn repo\n}\n\n// convertLabels is a helper function used to convert a GitHub label list to\n// the common Woodpecker label structure.\nfunc convertLabels(from []*github.Label) []string {\n\tlabels := make([]string, len(from))\n\tfor i, label := range from {\n\t\tlabels[i] = *label.Name\n\t}\n\treturn labels\n}\n"
  },
  {
    "path": "server/forge/github/convert_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/go-github/v86/github\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Test_convertStatus(t *testing.T) {\n\tassert.Equal(t, statusSuccess, convertStatus(model.StatusSuccess))\n\tassert.Equal(t, statusPending, convertStatus(model.StatusPending))\n\tassert.Equal(t, statusPending, convertStatus(model.StatusRunning))\n\tassert.Equal(t, statusFailure, convertStatus(model.StatusFailure))\n\tassert.Equal(t, statusError, convertStatus(model.StatusKilled))\n\tassert.Equal(t, statusError, convertStatus(model.StatusError))\n}\n\nfunc Test_convertDesc(t *testing.T) {\n\tassert.Equal(t, descSuccess, convertDesc(model.StatusSuccess))\n\tassert.Equal(t, descPending, convertDesc(model.StatusPending))\n\tassert.Equal(t, descPending, convertDesc(model.StatusRunning))\n\tassert.Equal(t, descFailure, convertDesc(model.StatusFailure))\n\tassert.Equal(t, descError, convertDesc(model.StatusKilled))\n\tassert.Equal(t, descError, convertDesc(model.StatusError))\n}\n\nfunc Test_convertRepoList(t *testing.T) {\n\tfrom := []*github.Repository{\n\t\t{\n\t\t\tPrivate:  github.Ptr(false),\n\t\t\tFullName: github.Ptr(\"octocat/hello-world\"),\n\t\t\tName:     github.Ptr(\"hello-world\"),\n\t\t\tOwner: &github.User{\n\t\t\t\tAvatarURL: github.Ptr(\"http://...\"),\n\t\t\t\tLogin:     github.Ptr(\"octocat\"),\n\t\t\t},\n\t\t\tHTMLURL:  github.Ptr(\"https://github.com/octocat/hello-world\"),\n\t\t\tCloneURL: github.Ptr(\"https://github.com/octocat/hello-world.git\"),\n\t\t\tPermissions: &github.RepositoryPermissions{\n\t\t\t\tAdmin: github.Ptr(true),\n\t\t\t\tPush:  github.Ptr(true),\n\t\t\t\tPull:  github.Ptr(true),\n\t\t\t},\n\t\t},\n\t}\n\n\tto := convertRepoList(from)\n\tassert.Equal(t, \"http://...\", to[0].Avatar)\n\tassert.Equal(t, \"octocat/hello-world\", to[0].FullName)\n\tassert.Equal(t, \"octocat\", to[0].Owner)\n\tassert.Equal(t, \"hello-world\", to[0].Name)\n}\n\nfunc Test_convertRepo(t *testing.T) {\n\tfrom := github.Repository{\n\t\tFullName:      github.Ptr(\"octocat/hello-world\"),\n\t\tName:          github.Ptr(\"hello-world\"),\n\t\tHTMLURL:       github.Ptr(\"https://github.com/octocat/hello-world\"),\n\t\tCloneURL:      github.Ptr(\"https://github.com/octocat/hello-world.git\"),\n\t\tDefaultBranch: github.Ptr(\"develop\"),\n\t\tPrivate:       github.Ptr(true),\n\t\tOwner: &github.User{\n\t\t\tAvatarURL: github.Ptr(\"http://...\"),\n\t\t\tLogin:     github.Ptr(\"octocat\"),\n\t\t},\n\t\tPermissions: &github.RepositoryPermissions{\n\t\t\tAdmin: github.Ptr(true),\n\t\t\tPush:  github.Ptr(true),\n\t\t\tPull:  github.Ptr(true),\n\t\t},\n\t}\n\n\tto := convertRepo(&from)\n\tassert.Equal(t, \"http://...\", to.Avatar)\n\tassert.Equal(t, \"octocat/hello-world\", to.FullName)\n\tassert.Equal(t, \"octocat\", to.Owner)\n\tassert.Equal(t, \"hello-world\", to.Name)\n\tassert.Equal(t, \"develop\", to.Branch)\n\tassert.True(t, to.IsSCMPrivate)\n\tassert.Equal(t, \"https://github.com/octocat/hello-world.git\", to.Clone)\n\tassert.Equal(t, \"https://github.com/octocat/hello-world\", to.ForgeURL)\n}\n\nfunc Test_convertPerm(t *testing.T) {\n\tfrom := &github.Repository{\n\t\tPermissions: &github.RepositoryPermissions{\n\t\t\tAdmin: github.Ptr(true),\n\t\t\tPush:  github.Ptr(true),\n\t\t\tPull:  github.Ptr(true),\n\t\t},\n\t}\n\n\tto := convertPerm(from.GetPermissions())\n\tassert.True(t, to.Push)\n\tassert.True(t, to.Pull)\n\tassert.True(t, to.Admin)\n}\n\nfunc Test_convertTeam(t *testing.T) {\n\tfrom := &github.Organization{\n\t\tLogin:     github.Ptr(\"octocat\"),\n\t\tAvatarURL: github.Ptr(\"http://...\"),\n\t}\n\tto := convertTeam(from)\n\tassert.Equal(t, \"octocat\", to.Login)\n\tassert.Equal(t, \"http://...\", to.Avatar)\n}\n\nfunc Test_convertTeamList(t *testing.T) {\n\tfrom := []*github.Organization{\n\t\t{\n\t\t\tLogin:     github.Ptr(\"octocat\"),\n\t\t\tAvatarURL: github.Ptr(\"http://...\"),\n\t\t},\n\t}\n\tto := convertTeamList(from)\n\tassert.Equal(t, \"octocat\", to[0].Login)\n\tassert.Equal(t, \"http://...\", to[0].Avatar)\n}\n\nfunc Test_convertRepoHook(t *testing.T) {\n\tt.Run(\"should convert a repository from webhook\", func(t *testing.T) {\n\t\tfrom := &github.PushEventRepository{Owner: &github.User{}}\n\t\tfrom.Owner.Login = github.Ptr(\"octocat\")\n\t\tfrom.Owner.Name = github.Ptr(\"octocat\")\n\t\tfrom.Name = github.Ptr(\"hello-world\")\n\t\tfrom.FullName = github.Ptr(\"octocat/hello-world\")\n\t\tfrom.Private = github.Ptr(true)\n\t\tfrom.HTMLURL = github.Ptr(\"https://github.com/octocat/hello-world\")\n\t\tfrom.CloneURL = github.Ptr(\"https://github.com/octocat/hello-world.git\")\n\t\tfrom.DefaultBranch = github.Ptr(\"develop\")\n\n\t\trepo := convertRepoHook(from)\n\t\tassert.Equal(t, *from.Owner.Login, repo.Owner)\n\t\tassert.Equal(t, *from.Name, repo.Name)\n\t\tassert.Equal(t, *from.FullName, repo.FullName)\n\t\tassert.Equal(t, *from.Private, repo.IsSCMPrivate)\n\t\tassert.Equal(t, *from.HTMLURL, repo.ForgeURL)\n\t\tassert.Equal(t, *from.CloneURL, repo.Clone)\n\t\tassert.Equal(t, *from.DefaultBranch, repo.Branch)\n\t})\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookDeploy.json",
    "content": "{\n  \"deployment\": {\n    \"url\": \"https://api.github.com/repos/baxterthehacker/public-repo/deployments/710692\",\n    \"id\": 710692,\n    \"sha\": \"9049f1265b7d61be4a8904a9a27120d2064dab3b\",\n    \"ref\": \"main\",\n    \"task\": \"deploy\",\n    \"payload\": {},\n    \"environment\": \"production\",\n    \"description\": null,\n    \"creator\": {\n      \"login\": \"baxterthehacker\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n    }\n  },\n  \"repository\": {\n    \"id\": 35129377,\n    \"name\": \"public-repo\",\n    \"full_name\": \"baxterthehacker/public-repo\",\n    \"owner\": {\n      \"login\": \"baxterthehacker\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n    },\n    \"private\": true,\n    \"html_url\": \"https://github.com/baxterthehacker/public-repo\",\n    \"clone_url\": \"https://github.com/baxterthehacker/public-repo.git\",\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"baxterthehacker\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequest.json",
    "content": "{\n  \"action\": \"opened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/baxterthehacker/public-repo/pulls/1\",\n    \"html_url\": \"https://github.com/baxterthehacker/public-repo/pull/1\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"title\": \"Update the README with new information\",\n    \"user\": {\n      \"login\": \"baxterthehacker\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n    },\n    \"base\": {\n      \"label\": \"baxterthehacker:main\",\n      \"ref\": \"main\",\n      \"sha\": \"9353195a19e45482665306e466c832c46560532d\"\n    },\n    \"head\": {\n      \"label\": \"baxterthehacker:changes\",\n      \"ref\": \"changes\",\n      \"sha\": \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\"\n    }\n  },\n  \"repository\": {\n    \"id\": 35129377,\n    \"name\": \"public-repo\",\n    \"full_name\": \"baxterthehacker/public-repo\",\n    \"owner\": {\n      \"login\": \"baxterthehacker\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n    },\n    \"private\": true,\n    \"html_url\": \"https://github.com/baxterthehacker/public-repo\",\n    \"clone_url\": \"https://github.com/baxterthehacker/public-repo.git\",\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"octocat\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/6752317?v=3\"\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestAssigneeAdded.json",
    "content": "{\n  \"action\": \"assigned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-30T00:05:47Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": {\n      \"login\": \"demoaccount2-commits\",\n      \"id\": 223550959,\n      \"node_id\": \"U_kgDODVMd7w\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"assignees\": [\n      {\n        \"login\": \"demoaccount2-commits\",\n        \"id\": 223550959,\n        \"node_id\": \"U_kgDODVMd7w\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      }\n    ],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465370,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp2g\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/bug\",\n        \"name\": \"bug\",\n        \"color\": \"d73a4a\",\n        \"default\": true,\n        \"description\": \"Something isn't working\"\n      }\n    ],\n    \"milestone\": null,\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"assignee\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestAssigneeRemoved.json",
    "content": "{\n  \"action\": \"unassigned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-30T00:06:11Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465370,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp2g\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/bug\",\n        \"name\": \"bug\",\n        \"color\": \"d73a4a\",\n        \"default\": true,\n        \"description\": \"Something isn't working\"\n      }\n    ],\n    \"milestone\": null,\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"assignee\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestClosed.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 62,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\",\n    \"id\": 1630965956,\n    \"node_id\": \"PR_kwDOIl-VNc5hNpDE\",\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62\",\n    \"diff_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.diff\",\n    \"patch_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.patch\",\n    \"issue_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\",\n    \"number\": 62,\n    \"state\": \"closed\",\n    \"locked\": false,\n    \"title\": \"Change file\",\n    \"user\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"body\": null,\n    \"created_at\": \"2023-12-05T18:13:16Z\",\n    \"updated_at\": \"2023-12-05T18:14:13Z\",\n    \"closed_at\": \"2023-12-05T18:14:13Z\",\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"79fd3b2a13c462ef9b3169b9dee9cb39605fda1b\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n    \"head\": {\n      \"label\": \"anbraten:anbraten-patch-3\",\n      \"ref\": \"anbraten-patch-3\",\n      \"sha\": \"c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:13:17Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"anbraten:main\",\n      \"ref\": \"main\",\n      \"sha\": \"26fd46e0d1237cdabfe84ec6a0f37466fc716952\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:13:17Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/anbraten/test-ready-release-go/pull/62\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": false,\n    \"mergeable_state\": \"clean\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"repository\": {\n    \"id\": 576689461,\n    \"node_id\": \"R_kgDOIl-VNQ\",\n    \"name\": \"test-ready-release-go\",\n    \"full_name\": \"anbraten/test-ready-release-go\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n    \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n    \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n    \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n    \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n    \"created_at\": \"2022-12-10T16:59:42Z\",\n    \"updated_at\": \"2023-07-11T17:00:26Z\",\n    \"pushed_at\": \"2023-12-05T18:13:17Z\",\n    \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n    \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n    \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n    \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"homepage\": null,\n    \"size\": 11198,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"anbraten\",\n    \"id\": 6918444,\n    \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/anbraten\",\n    \"html_url\": \"https://github.com/anbraten\",\n    \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n    \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n    \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n    \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n    \"type\": \"User\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestEdited.json",
    "content": "{\n  \"action\": \"edited\",\n  \"number\": 62,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\",\n    \"id\": 1630965956,\n    \"node_id\": \"PR_kwDOIl-VNc5hNpDE\",\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62\",\n    \"diff_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.diff\",\n    \"patch_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.patch\",\n    \"issue_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\",\n    \"number\": 62,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Change file\",\n    \"user\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"body\": null,\n    \"created_at\": \"2023-12-05T18:13:16Z\",\n    \"updated_at\": \"2023-12-05T18:14:13Z\",\n    \"closed_at\": \"2023-12-05T18:14:13Z\",\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"79fd3b2a13c462ef9b3169b9dee9cb39605fda1b\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n    \"head\": {\n      \"label\": \"anbraten:anbraten-patch-3\",\n      \"ref\": \"anbraten-patch-3\",\n      \"sha\": \"c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:13:17Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"anbraten:main\",\n      \"ref\": \"main\",\n      \"sha\": \"26fd46e0d1237cdabfe84ec6a0f37466fc716952\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:13:17Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/anbraten/test-ready-release-go/pull/62\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": false,\n    \"mergeable_state\": \"clean\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"repository\": {\n    \"id\": 576689461,\n    \"node_id\": \"R_kgDOIl-VNQ\",\n    \"name\": \"test-ready-release-go\",\n    \"full_name\": \"anbraten/test-ready-release-go\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n    \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n    \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n    \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n    \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n    \"created_at\": \"2022-12-10T16:59:42Z\",\n    \"updated_at\": \"2023-07-11T17:00:26Z\",\n    \"pushed_at\": \"2023-12-05T18:13:17Z\",\n    \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n    \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n    \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n    \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"homepage\": null,\n    \"size\": 11198,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"anbraten\",\n    \"id\": 6918444,\n    \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/anbraten\",\n    \"html_url\": \"https://github.com/anbraten\",\n    \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n    \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n    \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n    \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n    \"type\": \"User\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestLabelAdded.json",
    "content": "{\n  \"action\": \"labeled\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-29T23:46:36Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465376,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp4A\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/documentation\",\n        \"name\": \"documentation\",\n        \"color\": \"0075ca\",\n        \"default\": true,\n        \"description\": \"Improvements or additions to documentation\"\n      },\n      {\n        \"id\": 9024465382,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp5g\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/enhancement\",\n        \"name\": \"enhancement\",\n        \"color\": \"a2eeef\",\n        \"default\": true,\n        \"description\": \"New feature or request\"\n      }\n    ],\n    \"milestone\": {\n      \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones/2\",\n      \"id\": 13392101,\n      \"node_id\": \"MI_kwDOPU9UaM4AzFjl\",\n      \"number\": 2,\n      \"title\": \"open mile\",\n      \"description\": \"ongoing\",\n      \"creator\": {\n        \"login\": \"demoaccount2-commits\",\n        \"id\": 223550959,\n        \"node_id\": \"U_kgDODVMd7w\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"state\": \"open\",\n      \"created_at\": \"2025-07-29T23:46:08Z\",\n      \"updated_at\": \"2025-07-29T23:46:29Z\",\n      \"due_on\": null,\n      \"closed_at\": null\n    },\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"label\": {\n    \"id\": 9024465376,\n    \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp4A\",\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/documentation\",\n    \"name\": \"documentation\",\n    \"color\": \"0075ca\",\n    \"default\": true,\n    \"description\": \"Improvements or additions to documentation\"\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestLabelRemoved.json",
    "content": "{\n  \"action\": \"unlabeled\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-29T23:54:55Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465370,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp2g\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/bug\",\n        \"name\": \"bug\",\n        \"color\": \"d73a4a\",\n        \"default\": true,\n        \"description\": \"Something isn't working\"\n      }\n    ],\n    \"milestone\": {\n      \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones/2\",\n      \"id\": 13392101,\n      \"node_id\": \"MI_kwDOPU9UaM4AzFjl\",\n      \"number\": 2,\n      \"title\": \"open mile\",\n      \"description\": \"ongoing\",\n      \"creator\": {\n        \"login\": \"demoaccount2-commits\",\n        \"id\": 223550959,\n        \"node_id\": \"U_kgDODVMd7w\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"state\": \"open\",\n      \"created_at\": \"2025-07-29T23:46:08Z\",\n      \"updated_at\": \"2025-07-29T23:46:29Z\",\n      \"due_on\": null,\n      \"closed_at\": null\n    },\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"label\": {\n    \"id\": 9024465380,\n    \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp5A\",\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/duplicate\",\n    \"name\": \"duplicate\",\n    \"color\": \"cfd3d7\",\n    \"default\": true,\n    \"description\": \"This issue or pull request already exists\"\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestLabelsCleared.json",
    "content": "{\n  \"action\": \"unlabeled\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"html_url\": \"https://github.com/6543/test_ci_tmp/pull/1\",\n    \"diff_url\": \"https://github.com/6543/test_ci_tmp/pull/1.diff\",\n    \"patch_url\": \"https://github.com/6543/test_ci_tmp/pull/1.patch\",\n    \"issue_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"html_url\": \"https://github.com/6543\",\n      \"followers_url\": \"https://api.github.com/users/6543/followers\",\n      \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n      \"repos_url\": \"https://api.github.com/users/6543/repos\",\n      \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-09-22T12:34:38Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"c449d9571e3cfabc9ee42cc6725196497e16151a\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"html_url\": \"https://github.com/6543\",\n        \"followers_url\": \"https://api.github.com/users/6543/followers\",\n        \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n        \"repos_url\": \"https://api.github.com/users/6543/repos\",\n        \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"html_url\": \"https://github.com/6543\",\n          \"followers_url\": \"https://api.github.com/users/6543/followers\",\n          \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n          \"repos_url\": \"https://api.github.com/users/6543/repos\",\n          \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n        \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n        \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n        \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n        \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n        \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n        \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"html_url\": \"https://github.com/6543\",\n        \"followers_url\": \"https://api.github.com/users/6543/followers\",\n        \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n        \"repos_url\": \"https://api.github.com/users/6543/repos\",\n        \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"html_url\": \"https://github.com/6543\",\n          \"followers_url\": \"https://api.github.com/users/6543/followers\",\n          \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n          \"repos_url\": \"https://api.github.com/users/6543/repos\",\n          \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n        \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n        \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n        \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n        \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n        \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n        \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"label\": {\n    \"id\": 9024465382,\n    \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp5g\",\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/enhancement\",\n    \"name\": \"enhancement\",\n    \"color\": \"a2eeef\",\n    \"default\": true,\n    \"description\": \"New feature or request\"\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"html_url\": \"https://github.com/6543\",\n      \"followers_url\": \"https://api.github.com/users/6543/followers\",\n      \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n      \"repos_url\": \"https://api.github.com/users/6543/repos\",\n      \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n    \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n    \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n    \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n    \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n    \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n    \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"html_url\": \"https://github.com/6543\",\n    \"followers_url\": \"https://api.github.com/users/6543/followers\",\n    \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n    \"repos_url\": \"https://api.github.com/users/6543/repos\",\n    \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"action\": \"closed\",\n  \"number\": 62,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\",\n    \"id\": 1630965956,\n    \"node_id\": \"PR_kwDOIl-VNc5hNpDE\",\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62\",\n    \"diff_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.diff\",\n    \"patch_url\": \"https://github.com/anbraten/test-ready-release-go/pull/62.patch\",\n    \"issue_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\",\n    \"number\": 62,\n    \"state\": \"closed\",\n    \"locked\": false,\n    \"title\": \"Change file\",\n    \"user\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"body\": null,\n    \"created_at\": \"2023-12-05T18:13:16Z\",\n    \"updated_at\": \"2023-12-05T18:34:19Z\",\n    \"closed_at\": \"2023-12-05T18:34:19Z\",\n    \"merged_at\": \"2023-12-05T18:34:19Z\",\n    \"merge_commit_sha\": \"473d70eb7c50a54ae62bf9b124efa1c3eb245be8\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n    \"head\": {\n      \"label\": \"anbraten:anbraten-patch-3\",\n      \"ref\": \"anbraten-patch-3\",\n      \"sha\": \"c88b9ee719285134957cbc698c9b7ef9b78007bf\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:34:19Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"anbraten:main\",\n      \"ref\": \"main\",\n      \"sha\": \"26fd46e0d1237cdabfe84ec6a0f37466fc716952\",\n      \"user\": {\n        \"login\": \"anbraten\",\n        \"id\": 6918444,\n        \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/anbraten\",\n        \"html_url\": \"https://github.com/anbraten\",\n        \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n        \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n        \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n        \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 576689461,\n        \"node_id\": \"R_kgDOIl-VNQ\",\n        \"name\": \"test-ready-release-go\",\n        \"full_name\": \"anbraten/test-ready-release-go\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"anbraten\",\n          \"id\": 6918444,\n          \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/anbraten\",\n          \"html_url\": \"https://github.com/anbraten\",\n          \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n          \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n          \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n          \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n          \"type\": \"User\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n        \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n        \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n        \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n        \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n        \"created_at\": \"2022-12-10T16:59:42Z\",\n        \"updated_at\": \"2023-07-11T17:00:26Z\",\n        \"pushed_at\": \"2023-12-05T18:34:19Z\",\n        \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n        \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n        \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n        \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n        \"homepage\": null,\n        \"size\": 11198,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Go\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 0,\n        \"license\": {\n          \"key\": \"apache-2.0\",\n          \"name\": \"Apache License 2.0\",\n          \"spdx_id\": \"Apache-2.0\",\n          \"url\": \"https://api.github.com/licenses/apache-2.0\",\n          \"node_id\": \"MDc6TGljZW5zZTI=\"\n        },\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 0,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/anbraten/test-ready-release-go/pull/62\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": true,\n    \"mergeable\": null,\n    \"rebaseable\": null,\n    \"mergeable_state\": \"unknown\",\n    \"merged_by\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"repository\": {\n    \"id\": 576689461,\n    \"node_id\": \"R_kgDOIl-VNQ\",\n    \"name\": \"test-ready-release-go\",\n    \"full_name\": \"anbraten/test-ready-release-go\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"anbraten\",\n      \"id\": 6918444,\n      \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/anbraten\",\n      \"html_url\": \"https://github.com/anbraten\",\n      \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n      \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n      \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n      \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/anbraten/test-ready-release-go\",\n    \"forks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/forks\",\n    \"keys_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/events\",\n    \"assignees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/merges\",\n    \"archive_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/anbraten/test-ready-release-go/deployments\",\n    \"created_at\": \"2022-12-10T16:59:42Z\",\n    \"updated_at\": \"2023-07-11T17:00:26Z\",\n    \"pushed_at\": \"2023-12-05T18:34:19Z\",\n    \"git_url\": \"git://github.com/anbraten/test-ready-release-go.git\",\n    \"ssh_url\": \"git@github.com:anbraten/test-ready-release-go.git\",\n    \"clone_url\": \"https://github.com/anbraten/test-ready-release-go.git\",\n    \"svn_url\": \"https://github.com/anbraten/test-ready-release-go\",\n    \"homepage\": null,\n    \"size\": 11198,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"anbraten\",\n    \"id\": 6918444,\n    \"node_id\": \"MDQ6VXNlcjY5MTg0NDQ=\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/6918444?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/anbraten\",\n    \"html_url\": \"https://github.com/anbraten\",\n    \"followers_url\": \"https://api.github.com/users/anbraten/followers\",\n    \"following_url\": \"https://api.github.com/users/anbraten/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/anbraten/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/anbraten/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/anbraten/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/anbraten/orgs\",\n    \"repos_url\": \"https://api.github.com/users/anbraten/repos\",\n    \"events_url\": \"https://api.github.com/users/anbraten/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/anbraten/received_events\",\n    \"type\": \"User\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestMilestoneAdded.json",
    "content": "{\n  \"action\": \"milestoned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-29T23:46:29Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": {\n      \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones/2\",\n      \"id\": 13392101,\n      \"node_id\": \"MI_kwDOPU9UaM4AzFjl\",\n      \"number\": 2,\n      \"title\": \"open mile\",\n      \"description\": \"ongoing\",\n      \"creator\": {\n        \"login\": \"demoaccount2-commits\",\n        \"id\": 223550959,\n        \"node_id\": \"U_kgDODVMd7w\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"open_issues\": 1,\n      \"closed_issues\": 0,\n      \"state\": \"open\",\n      \"created_at\": \"2025-07-29T23:46:08Z\",\n      \"updated_at\": \"2025-07-29T23:46:29Z\",\n      \"due_on\": null,\n      \"closed_at\": null\n    },\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"milestone\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones/2\",\n    \"id\": 13392101,\n    \"node_id\": \"MI_kwDOPU9UaM4AzFjl\",\n    \"number\": 2,\n    \"title\": \"open mile\",\n    \"description\": \"ongoing\",\n    \"creator\": {\n      \"login\": \"demoaccount2-commits\",\n      \"id\": 223550959,\n      \"node_id\": \"U_kgDODVMd7w\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"open_issues\": 1,\n    \"closed_issues\": 0,\n    \"state\": \"open\",\n    \"created_at\": \"2025-07-29T23:46:08Z\",\n    \"updated_at\": \"2025-07-29T23:46:29Z\",\n    \"due_on\": null,\n    \"closed_at\": null\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestMilestoneRemoved.json",
    "content": "{\n  \"action\": \"demilestoned\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-30T00:01:25Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465370,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp2g\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/bug\",\n        \"name\": \"bug\",\n        \"color\": \"d73a4a\",\n        \"default\": true,\n        \"description\": \"Something isn't working\"\n      }\n    ],\n    \"milestone\": null,\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"milestone\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones/1\",\n    \"id\": 13392100,\n    \"node_id\": \"MI_kwDOPU9UaM4AzFjk\",\n    \"number\": 1,\n    \"title\": \"closed mile\",\n    \"description\": \"\",\n    \"creator\": {\n      \"login\": \"demoaccount2-commits\",\n      \"id\": 223550959,\n      \"node_id\": \"U_kgDODVMd7w\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"open_issues\": 0,\n    \"closed_issues\": 0,\n    \"state\": \"closed\",\n    \"created_at\": \"2025-07-29T23:45:30Z\",\n    \"updated_at\": \"2025-07-30T00:01:25Z\",\n    \"due_on\": \"2029-03-16T07:00:00Z\",\n    \"closed_at\": \"2025-07-29T23:45:35Z\"\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestReopened.json",
    "content": "{\n  \"action\": \"reopened\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"html_url\": \"https://github.com/6543/test_ci_tmp/pull/1\",\n    \"diff_url\": \"https://github.com/6543/test_ci_tmp/pull/1.diff\",\n    \"patch_url\": \"https://github.com/6543/test_ci_tmp/pull/1.patch\",\n    \"issue_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"html_url\": \"https://github.com/6543\",\n      \"followers_url\": \"https://api.github.com/users/6543/followers\",\n      \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n      \"repos_url\": \"https://api.github.com/users/6543/repos\",\n      \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-08-05T14:34:19Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": null,\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [],\n    \"requested_teams\": [],\n    \"labels\": [\n      {\n        \"id\": 9024465376,\n        \"node_id\": \"LA_kwDOPU9UaM8AAAACGeZp4A\",\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels/documentation\",\n        \"name\": \"documentation\",\n        \"color\": \"0075ca\",\n        \"default\": true,\n        \"description\": \"Improvements or additions to documentation\"\n      }\n    ],\n    \"milestone\": null,\n    \"draft\": false,\n    \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\",\n    \"review_comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\",\n    \"review_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\",\n    \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\",\n    \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"html_url\": \"https://github.com/6543\",\n        \"followers_url\": \"https://api.github.com/users/6543/followers\",\n        \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n        \"repos_url\": \"https://api.github.com/users/6543/repos\",\n        \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"html_url\": \"https://github.com/6543\",\n          \"followers_url\": \"https://api.github.com/users/6543/followers\",\n          \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n          \"repos_url\": \"https://api.github.com/users/6543/repos\",\n          \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n        \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n        \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n        \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n        \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n        \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n        \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"html_url\": \"https://github.com/6543\",\n        \"followers_url\": \"https://api.github.com/users/6543/followers\",\n        \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n        \"repos_url\": \"https://api.github.com/users/6543/repos\",\n        \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"html_url\": \"https://github.com/6543\",\n          \"followers_url\": \"https://api.github.com/users/6543/followers\",\n          \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n          \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n          \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n          \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n          \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n          \"repos_url\": \"https://api.github.com/users/6543/repos\",\n          \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n          \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n        \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n        \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n        \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n        \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n        \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n        \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": null,\n    \"rebaseable\": null,\n    \"mergeable_state\": \"unknown\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"html_url\": \"https://github.com/6543\",\n      \"followers_url\": \"https://api.github.com/users/6543/followers\",\n      \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n      \"repos_url\": \"https://api.github.com/users/6543/repos\",\n      \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n    \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n    \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n    \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n    \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n    \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n    \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"html_url\": \"https://github.com/6543\",\n    \"followers_url\": \"https://api.github.com/users/6543/followers\",\n    \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n    \"repos_url\": \"https://api.github.com/users/6543/repos\",\n    \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPullRequestReviewRequested.json",
    "content": "{\n  \"action\": \"review_requested\",\n  \"number\": 1,\n  \"pull_request\": {\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\",\n    \"id\": 2705176047,\n    \"node_id\": \"PR_kwDOPU9UaM6hPbXv\",\n    \"number\": 1,\n    \"state\": \"open\",\n    \"locked\": false,\n    \"title\": \"Some ned more AAAA\",\n    \"user\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"body\": \"yeaaa\",\n    \"created_at\": \"2025-07-29T20:00:54Z\",\n    \"updated_at\": \"2025-07-29T23:20:52Z\",\n    \"closed_at\": null,\n    \"merged_at\": null,\n    \"merge_commit_sha\": \"b5fafd8b1c043723a38c99775bc807075bce9235\",\n    \"assignee\": null,\n    \"assignees\": [],\n    \"requested_reviewers\": [\n      {\n        \"login\": \"demoaccount2-commits\",\n        \"id\": 223550959,\n        \"node_id\": \"U_kgDODVMd7w\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      }\n    ],\n    \"requested_teams\": [],\n    \"labels\": [],\n    \"milestone\": null,\n    \"draft\": false,\n    \"head\": {\n      \"label\": \"6543:6543-patch-1\",\n      \"ref\": \"6543-patch-1\",\n      \"sha\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"base\": {\n      \"label\": \"6543:main\",\n      \"ref\": \"main\",\n      \"sha\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n      \"user\": {\n        \"login\": \"6543\",\n        \"id\": 24977596,\n        \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/6543\",\n        \"type\": \"User\",\n        \"user_view_type\": \"public\",\n        \"site_admin\": false\n      },\n      \"repo\": {\n        \"id\": 1028609128,\n        \"node_id\": \"R_kgDOPU9UaA\",\n        \"name\": \"test_ci_tmp\",\n        \"full_name\": \"6543/test_ci_tmp\",\n        \"private\": false,\n        \"owner\": {\n          \"login\": \"6543\",\n          \"id\": 24977596,\n          \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n          \"gravatar_id\": \"\",\n          \"url\": \"https://api.github.com/users/6543\",\n          \"type\": \"User\",\n          \"user_view_type\": \"public\",\n          \"site_admin\": false\n        },\n        \"description\": null,\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n        \"created_at\": \"2025-07-29T19:35:41Z\",\n        \"updated_at\": \"2025-07-29T19:36:23Z\",\n        \"pushed_at\": \"2025-07-29T19:36:21Z\",\n        \"homepage\": null,\n        \"size\": 3,\n        \"stargazers_count\": 0,\n        \"watchers_count\": 0,\n        \"language\": \"Dockerfile\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"has_discussions\": false,\n        \"forks_count\": 0,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 1,\n        \"license\": null,\n        \"allow_forking\": true,\n        \"is_template\": false,\n        \"web_commit_signoff_required\": false,\n        \"topics\": [],\n        \"visibility\": \"public\",\n        \"forks\": 0,\n        \"open_issues\": 1,\n        \"watchers\": 0,\n        \"default_branch\": \"main\",\n        \"allow_squash_merge\": true,\n        \"allow_merge_commit\": true,\n        \"allow_rebase_merge\": true,\n        \"allow_auto_merge\": false,\n        \"delete_branch_on_merge\": false,\n        \"allow_update_branch\": false,\n        \"use_squash_pr_title_as_default\": false,\n        \"squash_merge_commit_message\": \"COMMIT_MESSAGES\",\n        \"squash_merge_commit_title\": \"COMMIT_OR_PR_TITLE\",\n        \"merge_commit_message\": \"PR_TITLE\",\n        \"merge_commit_title\": \"MERGE_MESSAGE\"\n      }\n    },\n    \"_links\": {\n      \"self\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1\"\n      },\n      \"html\": {\n        \"href\": \"https://github.com/6543/test_ci_tmp/pull/1\"\n      },\n      \"issue\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1\"\n      },\n      \"comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments\"\n      },\n      \"review_comments\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments\"\n      },\n      \"review_comment\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}\"\n      },\n      \"commits\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits\"\n      },\n      \"statuses\": {\n        \"href\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e\"\n      }\n    },\n    \"author_association\": \"OWNER\",\n    \"auto_merge\": null,\n    \"active_lock_reason\": null,\n    \"merged\": false,\n    \"mergeable\": true,\n    \"rebaseable\": true,\n    \"mergeable_state\": \"unstable\",\n    \"merged_by\": null,\n    \"comments\": 0,\n    \"review_comments\": 0,\n    \"maintainer_can_modify\": false,\n    \"commits\": 1,\n    \"additions\": 1,\n    \"deletions\": 0,\n    \"changed_files\": 1\n  },\n  \"requested_reviewer\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  },\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"created_at\": \"2025-07-29T19:35:41Z\",\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": \"2025-07-29T19:36:21Z\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 1,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 1,\n    \"watchers\": 0,\n    \"default_branch\": \"main\"\n  },\n  \"sender\": {\n    \"login\": \"demoaccount2-commits\",\n    \"id\": 223550959,\n    \"node_id\": \"U_kgDODVMd7w\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/demoaccount2-commits\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookPush.json",
    "content": "{\n  \"ref\": \"refs/heads/main\",\n  \"before\": \"2f780193b136b72bfea4eeb640786a8c4450c7a2\",\n  \"after\": \"366701fde727cb7a9e7f21eb88264f59f6f9b89c\",\n  \"repository\": {\n    \"id\": 179344069,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxNzkzNDQwNjk=\",\n    \"name\": \"woodpecker\",\n    \"full_name\": \"woodpecker-ci/woodpecker\",\n    \"private\": false,\n    \"owner\": {\n      \"name\": \"woodpecker-ci\",\n      \"email\": null,\n      \"login\": \"woodpecker-ci\",\n      \"id\": 84780935,\n      \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjg0NzgwOTM1\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/84780935?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/woodpecker-ci\",\n      \"html_url\": \"https://github.com/woodpecker-ci\",\n      \"followers_url\": \"https://api.github.com/users/woodpecker-ci/followers\",\n      \"following_url\": \"https://api.github.com/users/woodpecker-ci/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/woodpecker-ci/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/woodpecker-ci/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/woodpecker-ci/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/woodpecker-ci/orgs\",\n      \"repos_url\": \"https://api.github.com/users/woodpecker-ci/repos\",\n      \"events_url\": \"https://api.github.com/users/woodpecker-ci/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/woodpecker-ci/received_events\",\n      \"type\": \"Organization\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/woodpecker-ci/woodpecker\",\n    \"description\": \"Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.\",\n    \"fork\": false,\n    \"url\": \"https://github.com/woodpecker-ci/woodpecker\",\n    \"forks_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/forks\",\n    \"keys_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/events\",\n    \"assignees_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/merges\",\n    \"archive_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/woodpecker-ci/woodpecker/deployments\",\n    \"created_at\": 1554314798,\n    \"updated_at\": \"2022-01-16T20:19:33Z\",\n    \"pushed_at\": 1642370257,\n    \"git_url\": \"git://github.com/woodpecker-ci/woodpecker.git\",\n    \"ssh_url\": \"git@github.com:woodpecker-ci/woodpecker.git\",\n    \"clone_url\": \"https://github.com/woodpecker-ci/woodpecker.git\",\n    \"svn_url\": \"https://github.com/woodpecker-ci/woodpecker\",\n    \"homepage\": \"https://woodpecker-ci.org\",\n    \"size\": 81324,\n    \"stargazers_count\": 659,\n    \"watchers_count\": 659,\n    \"language\": \"Go\",\n    \"has_issues\": true,\n    \"has_projects\": false,\n    \"has_downloads\": true,\n    \"has_wiki\": false,\n    \"has_pages\": false,\n    \"forks_count\": 84,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 123,\n    \"license\": {\n      \"key\": \"apache-2.0\",\n      \"name\": \"Apache License 2.0\",\n      \"spdx_id\": \"Apache-2.0\",\n      \"url\": \"https://api.github.com/licenses/apache-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTI=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"topics\": [\"ci\", \"devops\", \"docker\", \"hacktoberfest\", \"hacktoberfest2021\", \"woodpeckerci\"],\n    \"visibility\": \"public\",\n    \"forks\": 84,\n    \"open_issues\": 123,\n    \"watchers\": 659,\n    \"default_branch\": \"main\",\n    \"stargazers\": 659,\n    \"main_branch\": \"main\",\n    \"organization\": \"woodpecker-ci\"\n  },\n  \"pusher\": {\n    \"name\": \"6543\",\n    \"email\": \"noreply@6543.de\"\n  },\n  \"organization\": {\n    \"login\": \"woodpecker-ci\",\n    \"id\": 84780935,\n    \"node_id\": \"MDEyOk9yZ2FuaXphdGlvbjg0NzgwOTM1\",\n    \"url\": \"https://api.github.com/orgs/woodpecker-ci\",\n    \"repos_url\": \"https://api.github.com/orgs/woodpecker-ci/repos\",\n    \"events_url\": \"https://api.github.com/orgs/woodpecker-ci/events\",\n    \"hooks_url\": \"https://api.github.com/orgs/woodpecker-ci/hooks\",\n    \"issues_url\": \"https://api.github.com/orgs/woodpecker-ci/issues\",\n    \"members_url\": \"https://api.github.com/orgs/woodpecker-ci/members{/member}\",\n    \"public_members_url\": \"https://api.github.com/orgs/woodpecker-ci/public_members{/member}\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/84780935?v=4\",\n    \"description\": \"Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.\"\n  },\n  \"sender\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"html_url\": \"https://github.com/6543\",\n    \"followers_url\": \"https://api.github.com/users/6543/followers\",\n    \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n    \"repos_url\": \"https://api.github.com/users/6543/repos\",\n    \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n    \"type\": \"User\",\n    \"site_admin\": false\n  },\n  \"created\": false,\n  \"deleted\": false,\n  \"forced\": false,\n  \"base_ref\": null,\n  \"compare\": \"https://github.com/woodpecker-ci/woodpecker/compare/2f780193b136...366701fde727\",\n  \"commits\": [\n    {\n      \"id\": \"366701fde727cb7a9e7f21eb88264f59f6f9b89c\",\n      \"tree_id\": \"638e046f1e1e15dbed1ddf40f9471bf1af4d64ce\",\n      \"distinct\": true,\n      \"message\": \"Fix multiline secrets replacer (#700)\\n\\n* Fix multiline secrets replacer\\r\\n\\r\\n* Add tests\",\n      \"timestamp\": \"2022-01-16T22:57:37+01:00\",\n      \"url\": \"https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c\",\n      \"author\": {\n        \"name\": \"Philipp\",\n        \"email\": \"noreply@philipp.xzy\",\n        \"username\": \"nupplaphil\"\n      },\n      \"committer\": {\n        \"name\": \"GitHub\",\n        \"email\": \"noreply@github.com\",\n        \"username\": \"web-flow\"\n      },\n      \"added\": [],\n      \"removed\": [],\n      \"modified\": [\"pipeline/shared/replace_secrets.go\", \"pipeline/shared/replace_secrets_test.go\"]\n    }\n  ],\n  \"head_commit\": {\n    \"id\": \"366701fde727cb7a9e7f21eb88264f59f6f9b89c\",\n    \"tree_id\": \"638e046f1e1e15dbed1ddf40f9471bf1af4d64ce\",\n    \"distinct\": true,\n    \"message\": \"Fix multiline secrets replacer (#700)\\n\\n* Fix multiline secrets replacer\\r\\n\\r\\n* Add tests\",\n    \"timestamp\": \"2022-01-16T22:57:37+01:00\",\n    \"url\": \"https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c\",\n    \"author\": {\n      \"name\": \"Philipp\",\n      \"email\": \"admin@philipp.info\",\n      \"username\": \"nupplaphil\"\n    },\n    \"committer\": {\n      \"name\": \"GitHub\",\n      \"email\": \"noreply@github.com\",\n      \"username\": \"web-flow\"\n    },\n    \"added\": [],\n    \"removed\": [],\n    \"modified\": [\"pipeline/shared/replace_secrets.go\", \"pipeline/shared/replace_secrets_test.go\"]\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookRelease.json",
    "content": "{\n  \"action\": \"released\",\n  \"release\": {\n    \"url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2\",\n    \"assets_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2/assets\",\n    \"upload_url\": \"https://octocoders.github.io/api/uploads/repos/Codertocat/Hello-World/releases/2/assets{?name,label}\",\n    \"html_url\": \"https://octocoders.github.io/Codertocat/Hello-World/releases/tag/0.0.1\",\n    \"id\": 2,\n    \"node_id\": \"MDc6UmVsZWFzZTI=\",\n    \"tag_name\": \"0.0.1\",\n    \"target_commitish\": \"master\",\n    \"name\": null,\n    \"draft\": false,\n    \"author\": {\n      \"login\": \"Codertocat\",\n      \"id\": 4,\n      \"node_id\": \"MDQ6VXNlcjQ=\",\n      \"avatar_url\": \"https://octocoders.github.io/avatars/u/4?\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://octocoders.github.io/api/v3/users/Codertocat\",\n      \"html_url\": \"https://octocoders.github.io/Codertocat\",\n      \"followers_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/followers\",\n      \"following_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}\",\n      \"gists_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}\",\n      \"starred_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/subscriptions\",\n      \"organizations_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/orgs\",\n      \"repos_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/repos\",\n      \"events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}\",\n      \"received_events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"prerelease\": false,\n    \"created_at\": \"2019-05-15T19:37:08Z\",\n    \"published_at\": \"2019-05-15T19:38:20Z\",\n    \"assets\": [],\n    \"tarball_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tarball/0.0.1\",\n    \"zipball_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/zipball/0.0.1\",\n    \"body\": null\n  },\n  \"repository\": {\n    \"id\": 118,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMTg=\",\n    \"name\": \"Hello-World\",\n    \"full_name\": \"Codertocat/Hello-World\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"Codertocat\",\n      \"id\": 4,\n      \"node_id\": \"MDQ6VXNlcjQ=\",\n      \"avatar_url\": \"https://octocoders.github.io/avatars/u/4?\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://octocoders.github.io/api/v3/users/Codertocat\",\n      \"html_url\": \"https://octocoders.github.io/Codertocat\",\n      \"followers_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/followers\",\n      \"following_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}\",\n      \"gists_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}\",\n      \"starred_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/subscriptions\",\n      \"organizations_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/orgs\",\n      \"repos_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/repos\",\n      \"events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}\",\n      \"received_events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://octocoders.github.io/Codertocat/Hello-World\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World\",\n    \"forks_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/forks\",\n    \"keys_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/keys{/key_id}\",\n    \"collaborators_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/collaborators{/collaborator}\",\n    \"teams_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/teams\",\n    \"hooks_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/hooks\",\n    \"issue_events_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/events{/number}\",\n    \"events_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/events\",\n    \"assignees_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/assignees{/user}\",\n    \"branches_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/branches{/branch}\",\n    \"tags_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tags\",\n    \"blobs_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/tags{/sha}\",\n    \"git_refs_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/refs{/sha}\",\n    \"trees_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/trees{/sha}\",\n    \"statuses_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/statuses/{sha}\",\n    \"languages_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/languages\",\n    \"stargazers_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/stargazers\",\n    \"contributors_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contributors\",\n    \"subscribers_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscribers\",\n    \"subscription_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscription\",\n    \"commits_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/commits{/sha}\",\n    \"git_commits_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/commits{/sha}\",\n    \"comments_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/comments{/number}\",\n    \"issue_comment_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/comments{/number}\",\n    \"contents_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contents/{+path}\",\n    \"compare_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/compare/{base}...{head}\",\n    \"merges_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/merges\",\n    \"archive_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/downloads\",\n    \"issues_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues{/number}\",\n    \"pulls_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/pulls{/number}\",\n    \"milestones_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/milestones{/number}\",\n    \"notifications_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/labels{/name}\",\n    \"releases_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases{/id}\",\n    \"deployments_url\": \"https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/deployments\",\n    \"created_at\": \"2019-05-15T19:37:07Z\",\n    \"updated_at\": \"2019-05-15T19:38:15Z\",\n    \"pushed_at\": \"2019-05-15T19:38:19Z\",\n    \"git_url\": \"git://octocoders.github.io/Codertocat/Hello-World.git\",\n    \"ssh_url\": \"git@octocoders.github.io:Codertocat/Hello-World.git\",\n    \"clone_url\": \"https://octocoders.github.io/Codertocat/Hello-World.git\",\n    \"svn_url\": \"https://octocoders.github.io/Codertocat/Hello-World\",\n    \"homepage\": null,\n    \"size\": 0,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Ruby\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": true,\n    \"forks_count\": 1,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 2,\n    \"license\": null,\n    \"forks\": 1,\n    \"open_issues\": 2,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  \"enterprise\": {\n    \"id\": 1,\n    \"slug\": \"github\",\n    \"name\": \"GitHub\",\n    \"node_id\": \"MDg6QnVzaW5lc3Mx\",\n    \"avatar_url\": \"https://octocoders.github.io/avatars/b/1?\",\n    \"description\": null,\n    \"website_url\": null,\n    \"html_url\": \"https://octocoders.github.io/businesses/github\",\n    \"created_at\": \"2019-05-14T19:31:12Z\",\n    \"updated_at\": \"2019-05-14T19:31:12Z\"\n  },\n  \"sender\": {\n    \"login\": \"Codertocat\",\n    \"id\": 4,\n    \"node_id\": \"MDQ6VXNlcjQ=\",\n    \"avatar_url\": \"https://octocoders.github.io/avatars/u/4?\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://octocoders.github.io/api/v3/users/Codertocat\",\n    \"html_url\": \"https://octocoders.github.io/Codertocat\",\n    \"followers_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/followers\",\n    \"following_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}\",\n    \"gists_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}\",\n    \"starred_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/subscriptions\",\n    \"organizations_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/orgs\",\n    \"repos_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/repos\",\n    \"events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}\",\n    \"received_events_url\": \"https://octocoders.github.io/api/v3/users/Codertocat/received_events\",\n    \"type\": \"User\",\n    \"site_admin\": false\n  },\n  \"installation\": {\n    \"id\": 5,\n    \"node_id\": \"MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNQ==\"\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/HookTag.json",
    "content": "{\n  \"ref\": \"refs/tags/the-tag-v1\",\n  \"before\": \"0000000000000000000000000000000000000000\",\n  \"after\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n  \"repository\": {\n    \"id\": 1028609128,\n    \"node_id\": \"R_kgDOPU9UaA\",\n    \"name\": \"test_ci_tmp\",\n    \"full_name\": \"6543/test_ci_tmp\",\n    \"private\": false,\n    \"owner\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"login\": \"6543\",\n      \"id\": 24977596,\n      \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/6543\",\n      \"html_url\": \"https://github.com/6543\",\n      \"followers_url\": \"https://api.github.com/users/6543/followers\",\n      \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n      \"repos_url\": \"https://api.github.com/users/6543/repos\",\n      \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n      \"type\": \"User\",\n      \"user_view_type\": \"public\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/6543/test_ci_tmp\",\n    \"forks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/forks\",\n    \"keys_url\": \"https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/6543/test_ci_tmp/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/6543/test_ci_tmp/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/6543/test_ci_tmp/events\",\n    \"assignees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/6543/test_ci_tmp/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/6543/test_ci_tmp/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/6543/test_ci_tmp/merges\",\n    \"archive_url\": \"https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/6543/test_ci_tmp/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/6543/test_ci_tmp/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/6543/test_ci_tmp/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/6543/test_ci_tmp/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/6543/test_ci_tmp/deployments\",\n    \"created_at\": 1753817741,\n    \"updated_at\": \"2025-07-29T19:36:23Z\",\n    \"pushed_at\": 1760097372,\n    \"git_url\": \"git://github.com/6543/test_ci_tmp.git\",\n    \"ssh_url\": \"git@github.com:6543/test_ci_tmp.git\",\n    \"clone_url\": \"https://github.com/6543/test_ci_tmp.git\",\n    \"svn_url\": \"https://github.com/6543/test_ci_tmp\",\n    \"homepage\": null,\n    \"size\": 3,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Dockerfile\",\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"main\",\n    \"stargazers\": 0,\n    \"master_branch\": \"main\"\n  },\n  \"pusher\": {\n    \"name\": \"6543\",\n    \"email\": \"6543@obermui.de\"\n  },\n  \"sender\": {\n    \"login\": \"6543\",\n    \"id\": 24977596,\n    \"node_id\": \"MDQ6VXNlcjI0OTc3NTk2\",\n    \"avatar_url\": \"https://avatars.githubusercontent.com/u/24977596?v=4\",\n    \"gravatar_id\": \"\",\n    \"url\": \"https://api.github.com/users/6543\",\n    \"html_url\": \"https://github.com/6543\",\n    \"followers_url\": \"https://api.github.com/users/6543/followers\",\n    \"following_url\": \"https://api.github.com/users/6543/following{/other_user}\",\n    \"gists_url\": \"https://api.github.com/users/6543/gists{/gist_id}\",\n    \"starred_url\": \"https://api.github.com/users/6543/starred{/owner}{/repo}\",\n    \"subscriptions_url\": \"https://api.github.com/users/6543/subscriptions\",\n    \"organizations_url\": \"https://api.github.com/users/6543/orgs\",\n    \"repos_url\": \"https://api.github.com/users/6543/repos\",\n    \"events_url\": \"https://api.github.com/users/6543/events{/privacy}\",\n    \"received_events_url\": \"https://api.github.com/users/6543/received_events\",\n    \"type\": \"User\",\n    \"user_view_type\": \"public\",\n    \"site_admin\": false\n  },\n  \"created\": true,\n  \"deleted\": false,\n  \"forced\": false,\n  \"base_ref\": \"refs/heads/main\",\n  \"compare\": \"https://github.com/6543/test_ci_tmp/compare/the-tag-v1\",\n  \"commits\": [],\n  \"head_commit\": {\n    \"id\": \"67012991d6c69b1c58378346fca366b864d8d1a1\",\n    \"tree_id\": \"8fb363ff1374abf0b7f3598e28a15ebdc443cb02\",\n    \"distinct\": true,\n    \"message\": \"Update .woodpecker.yml\",\n    \"timestamp\": \"2025-07-29T16:41:24+02:00\",\n    \"url\": \"https://github.com/6543/test_ci_tmp/commit/67012991d6c69b1c58378346fca366b864d8d1a1\",\n    \"author\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"6543\"\n    },\n    \"committer\": {\n      \"name\": \"6543\",\n      \"email\": \"6543@obermui.de\",\n      \"username\": \"6543\"\n    },\n    \"added\": [],\n    \"removed\": [],\n    \"modified\": [\".woodpecker.yml\"]\n  }\n}\n"
  },
  {
    "path": "server/forge/github/fixtures/handler.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler returns an http.Handler that is capable of handling a variety of mock\n// Bitbucket requests and returning mock responses.\nfunc Handler() http.Handler {\n\tgin.SetMode(gin.TestMode)\n\n\te := gin.New()\n\te.GET(\"/api/v3/repos/:owner/:name\", getRepo)\n\te.GET(\"/api/v3/repositories/:id\", getRepoByID)\n\te.GET(\"/api/v3/orgs/:org/memberships/:user\", getMembership)\n\te.GET(\"/api/v3/user/memberships/orgs/:org\", getMembership)\n\n\treturn e\n}\n\nfunc getRepo(c *gin.Context) {\n\tswitch c.Param(\"name\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc getRepoByID(c *gin.Context) {\n\tswitch c.Param(\"id\") {\n\tcase \"repo_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tdefault:\n\t\tc.String(http.StatusOK, repoPayload)\n\t}\n}\n\nfunc getMembership(c *gin.Context) {\n\tswitch c.Param(\"org\") {\n\tcase \"org_not_found\":\n\t\tc.String(http.StatusNotFound, \"\")\n\tcase \"github\":\n\t\tc.String(http.StatusOK, membershipIsMemberPayload)\n\tdefault:\n\t\tc.String(http.StatusOK, membershipIsOwnerPayload)\n\t}\n}\n\nvar repoPayload = `\n{\n\t\"id\": 5,\n\t\"owner\": {\n\t\t\"login\": \"octocat\",\n\t\t\"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\"\n\t},\n\t\"name\": \"Hello-World\",\n\t\"full_name\": \"octocat/Hello-World\",\n\t\"private\": true,\n\t\"html_url\": \"https://github.com/octocat/Hello-World\",\n\t\"clone_url\": \"https://github.com/octocat/Hello-World.git\",\n\t\"language\": null,\n\t\"permissions\": {\n\t\t\"admin\": true,\n\t\t\"push\": true,\n\t\t\"pull\": true\n\t}\n}\n`\n\nvar membershipIsOwnerPayload = `\n{\n\t\"url\": \"https://api.github.com/orgs/octocat/memberships/octocat\",\n\t\"state\": \"active\",\n\t\"role\": \"admin\",\n\t\"organization_url\": \"https://api.github.com/orgs/octocat\",\n\t\"user\": {\n\t\t\"login\": \"octocat\",\n\t\t\"id\": 5555555,\n\t\t\"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n\t\t\"gravatar_id\": \"\",\n\t\t\"url\": \"https://api.github.com/users/octocat\",\n\t\t\"html_url\": \"https://github.com/octocat\",\n\t\t\"followers_url\": \"https://api.github.com/users/octocat/followers\",\n\t\t\"following_url\": \"https://api.github.com/users/octocat/following{/other_user}\",\n\t\t\"gists_url\": \"https://api.github.com/users/octocat/gists{/gist_id}\",\n\t\t\"starred_url\": \"https://api.github.com/users/octocat/starred{/owner}{/repo}\",\n\t\t\"subscriptions_url\": \"https://api.github.com/users/octocat/subscriptions\",\n\t\t\"organizations_url\": \"https://api.github.com/users/octocat/orgs\",\n\t\t\"repos_url\": \"https://api.github.com/users/octocat/repos\",\n\t\t\"events_url\": \"https://api.github.com/users/octocat/events{/privacy}\",\n\t\t\"received_events_url\": \"https://api.github.com/users/octocat/received_events\",\n\t\t\"type\": \"User\",\n\t\t\"site_admin\": false\n\t},\n\t\"organization\": {\n\t\t\"login\": \"octocat\",\n\t\t\"id\": 5555556,\n\t\t\"url\": \"https://api.github.com/orgs/octocat\",\n\t\t\"repos_url\": \"https://api.github.com/orgs/octocat/repos\",\n\t\t\"events_url\": \"https://api.github.com/orgs/octocat/events\",\n\t\t\"hooks_url\": \"https://api.github.com/orgs/octocat/hooks\",\n\t\t\"issues_url\": \"https://api.github.com/orgs/octocat/issues\",\n\t\t\"members_url\": \"https://api.github.com/orgs/octocat/members{/member}\",\n\t\t\"public_members_url\": \"https://api.github.com/orgs/octocat/public_members{/member}\",\n\t\t\"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n\t\t\"description\": \"\"\n\t}\n}\n`\n\nvar membershipIsMemberPayload = `\n{\n\t\"url\": \"https://api.github.com/orgs/github/memberships/octocat\",\n\t\"state\": \"active\",\n\t\"role\": \"member\",\n\t\"organization_url\": \"https://api.github.com/orgs/github\",\n\t\"user\": {\n\t\t\"login\": \"octocat\",\n\t\t\"id\": 5555555,\n\t\t\"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n\t\t\"gravatar_id\": \"\",\n\t\t\"url\": \"https://api.github.com/users/octocat\",\n\t\t\"html_url\": \"https://github.com/octocat\",\n\t\t\"followers_url\": \"https://api.github.com/users/octocat/followers\",\n\t\t\"following_url\": \"https://api.github.com/users/octocat/following{/other_user}\",\n\t\t\"gists_url\": \"https://api.github.com/users/octocat/gists{/gist_id}\",\n\t\t\"starred_url\": \"https://api.github.com/users/octocat/starred{/owner}{/repo}\",\n\t\t\"subscriptions_url\": \"https://api.github.com/users/octocat/subscriptions\",\n\t\t\"organizations_url\": \"https://api.github.com/users/octocat/orgs\",\n\t\t\"repos_url\": \"https://api.github.com/users/octocat/repos\",\n\t\t\"events_url\": \"https://api.github.com/users/octocat/events{/privacy}\",\n\t\t\"received_events_url\": \"https://api.github.com/users/octocat/received_events\",\n\t\t\"type\": \"User\",\n\t\t\"site_admin\": false\n\t},\n\t\"organization\": {\n\t\t\"login\": \"octocat\",\n\t\t\"id\": 5555557,\n\t\t\"url\": \"https://api.github.com/orgs/github\",\n\t\t\"repos_url\": \"https://api.github.com/orgs/github/repos\",\n\t\t\"events_url\": \"https://api.github.com/orgs/github/events\",\n\t\t\"hooks_url\": \"https://api.github.com/orgs/github/hooks\",\n\t\t\"issues_url\": \"https://api.github.com/orgs/github/issues\",\n\t\t\"members_url\": \"https://api.github.com/orgs/github/members{/member}\",\n\t\t\"public_members_url\": \"https://api.github.com/orgs/github/public_members{/member}\",\n\t\t\"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\",\n\t\t\"description\": \"\"\n\t}\n}\n`\n"
  },
  {
    "path": "server/forge/github/fixtures/hooks.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport _ \"embed\"\n\n// HookPush is a sample push hook.\n// https://developer.github.com/v3/activity/events/types/#pushevent\n//\n//go:embed HookPush.json\nvar HookPush string\n\n// HookPushDeleted is a sample push hook that is marked as deleted, and is expected to be ignored.\nconst HookPushDeleted = `\n{\n  \"deleted\": true\n}\n`\n\n// HookPullRequest is a sample hook pull request\n// https://developer.github.com/v3/activity/events/types/#pullrequestevent\n//\n//go:embed HookPullRequest.json\nvar HookPullRequest string\n\n// HookPullRequestInvalidAction is a sample hook pull request that has an\n// action not equal to synchronize or opened, and is expected to be ignored.\nconst HookPullRequestInvalidAction = `\n{\n  \"action\": \"reopened\",\n  \"number\": 1\n}\n`\n\n// HookPullRequestInvalidState is a sample hook pull request that has a state\n// not equal to open, and is expected to be ignored.\nconst HookPullRequestInvalidState = `\n{\n  \"action\": \"synchronize\",\n  \"pull_request\": {\n    \"number\": 1,\n    \"state\": \"closed\"\n  }\n}\n`\n\n// HookPush is a sample deployment hook.\n// https://developer.github.com/v3/activity/events/types/#deploymentevent\n//\n//go:embed HookDeploy.json\nvar HookDeploy string\n\n//go:embed HookPullRequestMerged.json\nvar HookPullRequestMerged string\n\n// HookPullRequest is a sample hook pull request\n// https://developer.github.com/v3/activity/events/types/#pullrequestevent\n//\n//go:embed HookPullRequestClosed.json\nvar HookPullRequestClosed string\n\n//go:embed HookPullRequestEdited.json\nvar HookPullRequestEdited string\n\n//go:embed HookRelease.json\nvar HookRelease string\n\n//go:embed HookTag.json\nvar HookTag string\n\n//go:embed HookPullRequestReviewRequested.json\nvar HookPullRequestReviewRequested string\n\n//go:embed HookPullRequestMilestoneAdded.json\nvar HookPullRequestMilestoneAdded string\n\n//go:embed HookPullRequestMilestoneRemoved.json\nvar HookPullRequestMilestoneRemoved string\n\n//go:embed HookPullRequestLabelAdded.json\nvar HookPullRequestLabelAdded string\n\n//go:embed HookPullRequestLabelRemoved.json\nvar HookPullRequestLabelRemoved string\n\n//go:embed HookPullRequestAssigneeAdded.json\nvar HookPullRequestAssigneeAdded string\n\n//go:embed HookPullRequestAssigneeRemoved.json\nvar HookPullRequestAssigneeRemoved string\n\n//go:embed HookPullRequestReopened.json\nvar HookPullRequestReopened string\n\n//go:embed HookPullRequestLabelsCleared.json\nvar HookPullRequestLabelsCleared string\n"
  },
  {
    "path": "server/forge/github/fixtures/mock_server.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n"
  },
  {
    "path": "server/forge/github/github.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/go-github/v86/github\"\n\t\"github.com/rs/zerolog/log\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\ntype contextKey string\n\nconst (\n\tdefaultURL                 = \"https://github.com\"      // Default GitHub URL\n\tdefaultAPI                 = \"https://api.github.com/\" // Default GitHub API URL\n\tdefaultPageSize            = 100\n\tgithubClientKey contextKey = \"github_client\"\n)\n\n// Opts defines configuration options.\ntype Opts struct {\n\tURL               string // GitHub server url.\n\tOAuthClientID     string // GitHub oauth client id.\n\tOAuthClientSecret string // GitHub oauth client secret.\n\tSkipVerify        bool   // Skip ssl verification.\n\tMergeRef          bool   // Clone pull requests using the merge ref.\n\tOnlyPublic        bool   // Only obtain OAuth tokens with access to public repos.\n\tOAuthHost         string // Public url for oauth if different from url.\n}\n\n// New returns a Forge implementation that integrates with a GitHub Cloud or\n// GitHub Enterprise version control hosting provider.\nfunc New(id int64, opts Opts) (forge.Forge, error) {\n\tr := &client{\n\t\tid:         id,\n\t\tAPI:        defaultAPI,\n\t\turl:        defaultURL,\n\t\tClient:     opts.OAuthClientID,\n\t\tSecret:     opts.OAuthClientSecret,\n\t\toAuthHost:  opts.OAuthHost,\n\t\tSkipVerify: opts.SkipVerify,\n\t\tMergeRef:   opts.MergeRef,\n\t\tOnlyPublic: opts.OnlyPublic,\n\t}\n\tif opts.URL != defaultURL {\n\t\tr.url = strings.TrimSuffix(opts.URL, \"/\")\n\t\tr.API = r.url + \"/api/v3/\"\n\t}\n\n\treturn r, nil\n}\n\ntype client struct {\n\tid         int64\n\turl        string\n\tAPI        string\n\tClient     string\n\tSecret     string\n\tSkipVerify bool\n\tMergeRef   bool\n\tOnlyPublic bool\n\toAuthHost  string\n}\n\n// Name returns the string name of this driver.\nfunc (c *client) Name() string {\n\treturn \"github\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (c *client) URL() string {\n\treturn c.url\n}\n\n// Login authenticates the session and returns the forge user details.\nfunc (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig := c.newConfig()\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\t// check the OAuth code\n\tif len(req.Code) == 0 {\n\t\t// TODO(bradrydzewski) we really should be using a random value here and\n\t\t// storing in a cookie for verification in the next stage of the workflow.\n\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(c.newContext(ctx), req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tclient := c.newClientToken(ctx, token.AccessToken)\n\tuser, _, err := client.Users.Get(ctx, \"\")\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\temails, _, err := client.Users.ListEmails(ctx, nil)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\temail := matchingEmail(emails, c.API)\n\tif email == nil {\n\t\treturn nil, redirectURL, fmt.Errorf(\"no verified Email address for GitHub account\")\n\t}\n\n\treturn &model.User{\n\t\tLogin:         user.GetLogin(),\n\t\tEmail:         email.GetEmail(),\n\t\tAccessToken:   token.AccessToken,\n\t\tRefreshToken:  token.RefreshToken,\n\t\tExpiry:        token.Expiry.UTC().Unix(),\n\t\tAvatar:        user.GetAvatarURL(),\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(user.GetID())),\n\t}, redirectURL, nil\n}\n\n// Refresh refreshes the Gitlab oauth2 access token. If the token is\n// refreshed the user is updated and a true value is returned.\nfunc (c *client) Refresh(ctx context.Context, user *model.User) (bool, error) {\n\t// when using Github oAuth app no refresh token is provided\n\tif user.RefreshToken == \"\" {\n\t\treturn false, nil\n\t}\n\n\tconfig := c.newConfig()\n\n\tsource := config.TokenSource(ctx, &oauth2.Token{\n\t\tAccessToken:  user.AccessToken,\n\t\tRefreshToken: user.RefreshToken,\n\t\tExpiry:       time.Unix(user.Expiry, 0),\n\t})\n\n\ttoken, err := source.Token()\n\tif err != nil || len(token.AccessToken) == 0 {\n\t\treturn false, err\n\t}\n\n\tuser.AccessToken = token.AccessToken\n\tuser.RefreshToken = token.RefreshToken\n\tuser.Expiry = token.Expiry.UTC().Unix()\n\treturn true, nil\n}\n\n// Teams returns a list of all team membership for the GitHub account.\nfunc (c *client) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\tlist, _, err := client.Organizations.List(ctx, \"\", &github.ListOptions{\n\t\tPage:    p.Page,\n\t\tPerPage: perPage(p.PerPage),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn convertTeamList(list), nil\n}\n\n// Repo returns the GitHub repository.\nfunc (c *client) Repo(ctx context.Context, u *model.User, id model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\tif id.IsValid() {\n\t\tintID, err := strconv.ParseInt(string(id), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trepo, resp, err := client.Repositories.GetByID(ctx, intID)\n\t\tif err != nil {\n\t\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\treturn convertRepo(repo), nil\n\t}\n\n\trepo, resp, err := client.Repositories.Get(ctx, owner, name)\n\tif err != nil {\n\t\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn convertRepo(repo), nil\n}\n\n// Repos returns a list of all repositories for GitHub account, including\n// organization repositories.\nfunc (c *client) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\t// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)\n\tif p.Page != 1 {\n\t\treturn nil, nil\n\t}\n\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\topts := new(github.RepositoryListByAuthenticatedUserOptions)\n\topts.PerPage = 100\n\topts.Page = 1\n\n\tvar repos []*model.Repo\n\tfor opts.Page > 0 {\n\t\tlist, resp, err := client.Repositories.ListByAuthenticatedUser(ctx, opts)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, repo := range list {\n\t\t\tif repo.GetArchived() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trepos = append(repos, convertRepo(repo))\n\t\t}\n\t\topts.Page = resp.NextPage\n\t}\n\treturn repos, nil\n}\n\n// File fetches the file from the GitHub repository and returns its contents.\nfunc (c *client) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\topts := new(github.RepositoryContentGetOptions)\n\topts.Ref = b.Commit\n\tcontent, _, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts)\n\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif content == nil {\n\t\treturn nil, fmt.Errorf(\"%s is a folder not a file use Dir(..)\", f)\n\t}\n\tdata, err := content.GetContent()\n\treturn []byte(data), err\n}\n\nfunc (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\topts := new(github.RepositoryContentGetOptions)\n\topts.Ref = b.Commit\n\t_, data, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts)\n\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfc := make(chan *forge_types.FileMeta)\n\terrChan := make(chan error)\n\n\tfor _, file := range data {\n\t\tgo func(path string) {\n\t\t\tcontent, err := c.File(ctx, u, r, b, path)\n\t\t\tif err != nil {\n\t\t\t\tif errors.Is(err, &forge_types.ErrConfigNotFound{}) {\n\t\t\t\t\terr = fmt.Errorf(\"git tree reported existence of file but we got: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\terrChan <- err\n\t\t\t} else {\n\t\t\t\tfc <- &forge_types.FileMeta{\n\t\t\t\t\tName: path,\n\t\t\t\t\tData: content,\n\t\t\t\t}\n\t\t\t}\n\t\t}(f + \"/\" + *file.Name)\n\t}\n\n\tvar files []*forge_types.FileMeta\n\n\tfor range data {\n\t\tselect {\n\t\tcase err := <-errChan:\n\t\t\treturn nil, err\n\t\tcase fileMeta := <-fc:\n\t\t\tfiles = append(files, fileMeta)\n\t\t}\n\t}\n\n\tclose(fc)\n\tclose(errChan)\n\n\treturn files, nil\n}\n\nfunc (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient := c.newClientToken(ctx, token)\n\n\tpullRequests, _, err := client.PullRequests.List(ctx, r.Owner, r.Name, &github.PullRequestListOptions{\n\t\tListOptions: github.ListOptions{\n\t\t\tPage:    p.Page,\n\t\t\tPerPage: perPage(p.PerPage),\n\t\t},\n\t\tState: \"open\",\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]*model.PullRequest, len(pullRequests))\n\tfor i := range pullRequests {\n\t\tresult[i] = &model.PullRequest{\n\t\t\tIndex: model.ForgeRemoteID(strconv.Itoa(pullRequests[i].GetNumber())),\n\t\t\tTitle: pullRequests[i].GetTitle(),\n\t\t}\n\t}\n\treturn result, err\n}\n\n// Netrc returns a netrc file capable of authenticating GitHub requests and\n// cloning GitHub repositories. The netrc will use the global machine account\n// when configured.\nfunc (c *client) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\tlogin := \"\"\n\ttoken := \"\"\n\n\tif u != nil {\n\t\tlogin = u.AccessToken\n\t\ttoken = \"x-oauth-basic\"\n\t}\n\n\thost, err := common.ExtractHostFromCloneURL(r.Clone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Netrc{\n\t\tLogin:    login,\n\t\tPassword: token,\n\t\tMachine:  host,\n\t\tType:     model.ForgeTypeGithub,\n\t}, nil\n}\n\n// Deactivate deactivates the repository be removing registered push hooks from\n// the GitHub repository.\nfunc (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\t// make sure a repo rename does not trick us\n\tforgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thooks, _, err := client.Repositories.ListHooks(ctx, forgeRepo.Owner, forgeRepo.Name, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmatch := matchingHooks(hooks, link)\n\tif match == nil {\n\t\treturn nil\n\t}\n\t_, err = client.Repositories.DeleteHook(ctx, forgeRepo.Owner, forgeRepo.Name, *match.ID)\n\treturn err\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (c *client) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\torg, _, err := client.Organizations.GetOrgMembership(ctx, u.Login, owner)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.OrgPerm{Member: org.GetState() == \"active\", Admin: org.GetRole() == \"admin\"}, nil\n}\n\nfunc (c *client) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\n\torg, _, err := client.Organizations.Get(ctx, owner)\n\tlog.Trace().Msgf(\"GitHub organization for owner %s = %v\", owner, org)\n\tif org != nil && err == nil {\n\t\treturn &model.Org{\n\t\t\tName:   org.GetLogin(),\n\t\t\tIsUser: false,\n\t\t}, nil\n\t}\n\n\tuser, _, err := client.Users.Get(ctx, owner)\n\tlog.Trace().Msgf(\"GitHub user for owner %s = %v\", owner, user)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Org{\n\t\tName:   user.GetLogin(),\n\t\tIsUser: true,\n\t}, nil\n}\n\n// newContext returns the GitHub oauth2 context using an HTTPClient that\n// disables TLS verification if disabled in the forge settings.\nfunc (c *client) newContext(ctx context.Context) context.Context {\n\tif !c.SkipVerify {\n\t\treturn ctx\n\t}\n\treturn context.WithValue(ctx, oauth2.HTTPClient, &http.Client{\n\t\tTransport: &http.Transport{\n\t\t\tProxy: http.ProxyFromEnvironment,\n\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\tInsecureSkipVerify: true,\n\t\t\t},\n\t\t},\n\t})\n}\n\n// newConfig returns the GitHub oauth2 config.\nfunc (c *client) newConfig() *oauth2.Config {\n\tscopes := []string{\"user:email\", \"read:org\"}\n\tif c.OnlyPublic {\n\t\tscopes = append(scopes, []string{\"admin:repo_hook\", \"repo:status\"}...)\n\t} else {\n\t\tscopes = append(scopes, \"repo\")\n\t}\n\n\tpublicOAuthURL := c.oAuthHost\n\tif publicOAuthURL == \"\" {\n\t\tpublicOAuthURL = c.url\n\t}\n\n\treturn &oauth2.Config{\n\t\tClientID:     c.Client,\n\t\tClientSecret: c.Secret,\n\t\tScopes:       scopes,\n\t\tEndpoint: oauth2.Endpoint{\n\t\t\tAuthURL:  fmt.Sprintf(\"%s/login/oauth/authorize\", publicOAuthURL),\n\t\t\tTokenURL: fmt.Sprintf(\"%s/login/oauth/access_token\", c.url),\n\t\t},\n\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t}\n}\n\n// newClientToken returns the GitHub oauth2 client.\n// It first checks if a client is available in the context, otherwise creates a new one.\nfunc (c *client) newClientToken(ctx context.Context, token string) *github.Client {\n\t// Check if a client is already in the context\n\tif ctxClient, ok := ctx.Value(githubClientKey).(*github.Client); ok {\n\t\treturn ctxClient\n\t}\n\n\tts := oauth2.StaticTokenSource(\n\t\t&oauth2.Token{AccessToken: token},\n\t)\n\ttc := oauth2.NewClient(ctx, ts)\n\n\t// Get the oauth2 transport to set custom base\n\ttp, _ := tc.Transport.(*oauth2.Transport)\n\n\tbaseTransport := &http.Transport{\n\t\tProxy: http.ProxyFromEnvironment,\n\t}\n\tif c.SkipVerify {\n\t\tbaseTransport.TLSClientConfig = &tls.Config{\n\t\t\tInsecureSkipVerify: true,\n\t\t}\n\t}\n\n\t// Wrap the base transport with User-Agent support\n\ttp.Base = httputil.NewUserAgentRoundTripper(baseTransport, \"forge-github\")\n\n\tclient := github.NewClient(tc)\n\tclient.BaseURL, _ = url.Parse(c.API)\n\treturn client\n}\n\n// matchingEmail returns matching user email.\nfunc matchingEmail(emails []*github.UserEmail, rawURL string) *github.UserEmail {\n\tfor _, email := range emails {\n\t\tif email.Email == nil || email.Primary == nil || email.Verified == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif *email.Primary && *email.Verified {\n\t\t\treturn email\n\t\t}\n\t}\n\t// github enterprise does not support verified email addresses so instead\n\t// we'll return the first email address in the list.\n\tif len(emails) != 0 && rawURL != defaultAPI {\n\t\treturn emails[0]\n\t}\n\treturn nil\n}\n\n// matchingHooks returns matching hook.\nfunc matchingHooks(hooks []*github.Hook, rawURL string) *github.Hook {\n\tlink, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tfor _, hook := range hooks {\n\t\tif hook.ID == nil {\n\t\t\tcontinue\n\t\t}\n\t\thookURL, err := url.Parse(hook.Config.GetURL())\n\t\tif err == nil && hookURL.Host == link.Host {\n\t\t\treturn hook\n\t\t}\n\t}\n\treturn nil\n}\n\nvar reDeploy = regexp.MustCompile(`.+/deployments/(\\d+)`)\n\n// Status sends the commit status to the forge.\n// An example would be the GitHub pull request status.\nfunc (c *client) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tclient := c.newClientToken(ctx, user.AccessToken)\n\n\tif pipeline.Event == model.EventDeploy {\n\t\t// Get id from url. If not found, skip.\n\t\tmatches := reDeploy.FindStringSubmatch(pipeline.ForgeURL)\n\t\t//nolint:mnd\n\t\tif len(matches) != 2 {\n\t\t\treturn nil\n\t\t}\n\t\tid, _ := strconv.Atoi(matches[1])\n\n\t\t_, _, err := client.Repositories.CreateDeploymentStatus(ctx, repo.Owner, repo.Name, int64(id), &github.DeploymentStatusRequest{\n\t\t\tState:       github.Ptr(convertStatus(pipeline.Status)),\n\t\t\tDescription: github.Ptr(common.GetPipelineStatusDescription(pipeline.Status)),\n\t\t\tLogURL:      github.Ptr(common.GetPipelineStatusURL(repo, pipeline, nil)),\n\t\t})\n\t\treturn err\n\t}\n\n\t_, _, err := client.Repositories.CreateStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, github.RepoStatus{\n\t\tContext:     github.Ptr(common.GetPipelineStatusContext(repo, pipeline, workflow)),\n\t\tState:       github.Ptr(convertStatus(workflow.State)),\n\t\tDescription: github.Ptr(common.GetPipelineStatusDescription(workflow.State)),\n\t\tTargetURL:   github.Ptr(common.GetPipelineStatusURL(repo, pipeline, workflow)),\n\t})\n\treturn err\n}\n\n// Activate activates a repository by creating the post-commit hook and\n// adding the SSH deploy key, if applicable.\nfunc (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tif err := c.Deactivate(ctx, u, r, link); err != nil {\n\t\treturn err\n\t}\n\tclient := c.newClientToken(ctx, u.AccessToken)\n\thook := &github.Hook{\n\t\tName: github.Ptr(\"web\"),\n\t\tEvents: []string{\n\t\t\t\"push\",\n\t\t\t\"pull_request\",\n\t\t\t\"pull_request_review\",\n\t\t\t\"deployment\",\n\t\t},\n\t\tConfig: &github.HookConfig{\n\t\t\tURL:         &link,\n\t\t\tContentType: github.Ptr(\"form\"),\n\t\t},\n\t}\n\t_, _, err := client.Repositories.CreateHook(ctx, r.Owner, r.Name, hook)\n\treturn err\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient := c.newClientToken(ctx, token)\n\n\tgithubBranches, _, err := client.Repositories.ListBranches(ctx, r.Owner, r.Name, &github.BranchListOptions{\n\t\tListOptions: github.ListOptions{\n\t\t\tPage:    p.Page,\n\t\t\tPerPage: perPage(p.PerPage),\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbranches := make([]string, 0)\n\tfor _, branch := range githubBranches {\n\t\tbranches = append(branches, *branch.Name)\n\t}\n\treturn branches, nil\n}\n\n// BranchHead returns the sha of the head (latest commit) of the specified branch.\nfunc (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tb, _, err := c.newClientToken(ctx, token).Repositories.GetBranch(ctx, r.Owner, r.Name, branch, 1)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &model.Commit{\n\t\tSHA:      b.GetCommit().GetSHA(),\n\t\tForgeURL: b.GetCommit().GetHTMLURL(),\n\t}, nil\n}\n\n// Hook parses the post-commit hook from the Request body\n// and returns the required data in a standard format.\nfunc (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\tpull, repo, pipeline, currCommit, prevCommit, err := parseHook(r, c.MergeRef)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == \"\" {\n\t\ttagName := strings.Split(pipeline.Ref, \"/\")[2]\n\t\tsha, err := c.getTagCommitSHA(ctx, repo, tagName)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t\tpipeline.Commit = sha\n\t}\n\n\tif pull != nil {\n\t\tpipeline, err = c.loadChangedFilesFromPullRequest(ctx, pull, repo, pipeline)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t} else if pipeline != nil && pipeline.Event == model.EventPush {\n\t\t// GitHub has removed commit summaries from Events API payloads from 7th October 2025 onwards.\n\t\tpipeline, err = c.loadChangedFilesFromCommits(ctx, repo, pipeline, currCommit, prevCommit)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\t}\n\n\treturn repo, pipeline, nil\n}\n\nfunc (c *client) loadChangedFilesFromPullRequest(ctx context.Context, pull *github.PullRequest, tmpRepo *model.Repo, pipeline *model.Pipeline) (*model.Pipeline, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn pipeline, nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Refresh the OAuth token before making API calls.\n\t// The token may be expired, and without this refresh the API calls below\n\t// would fail with an authentication error.\n\tforge.Refresh(ctx, c, _store, user)\n\n\tgh := c.newClientToken(ctx, user.AccessToken)\n\tfileList := make([]string, 0, 16)\n\n\topts := &github.ListOptions{Page: 1}\n\tfor opts.Page > 0 {\n\t\tfiles, resp, err := gh.PullRequests.ListFiles(ctx, repo.Owner, repo.Name, pull.GetNumber(), opts)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, file := range files {\n\t\t\tfileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())\n\t\t}\n\n\t\topts.Page = resp.NextPage\n\t}\n\n\tpipeline.ChangedFiles = utils.DeduplicateStrings(fileList)\n\treturn pipeline, err\n}\n\nfunc (c *client) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn \"\", nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Refresh the OAuth token before making API calls.\n\t// The token may be expired, and without this refresh the API calls below\n\t// would fail with an authentication error.\n\tforge.Refresh(ctx, c, _store, user)\n\n\tgh := c.newClientToken(ctx, user.AccessToken)\n\n\tpage := 1\n\tvar tag *github.RepositoryTag\n\tfor {\n\t\ttags, _, err := gh.Repositories.ListTags(ctx, repo.Owner, repo.Name, &github.ListOptions{Page: page})\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tfor _, t := range tags {\n\t\t\tif t.GetName() == tagName {\n\t\t\t\ttag = t\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif tag != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\tif tag == nil {\n\t\treturn \"\", fmt.Errorf(\"could not find tag %s\", tagName)\n\t}\n\treturn tag.GetCommit().GetSHA(), nil\n}\n\nfunc (c *client) loadChangedFilesFromCommits(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, curr, prev string) (*model.Pipeline, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn pipeline, nil\n\t}\n\n\tswitch prev {\n\tcase curr:\n\t\tlog.Error().Msg(\"GitHub push event contains the same commit before and after, no changes detected\")\n\t\treturn pipeline, nil\n\tcase \"0000000000000000000000000000000000000000\":\n\t\tprev = \"\"\n\t\tfallthrough\n\tcase \"\":\n\t\t// For tag events, prev is empty, but we can still fetch the changed files using the current commit\n\t\tlog.Trace().Msg(\"GitHub tag event, fetching changed files using current commit\")\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Refresh the OAuth token before making API calls.\n\t// The token may be expired, and without this refresh the API calls below\n\t// would fail with an authentication error.\n\tforge.Refresh(ctx, c, _store, user)\n\n\tgh := c.newClientToken(ctx, user.AccessToken)\n\tfileList := make([]string, 0, 16)\n\n\tif prev == \"\" {\n\t\topts := &github.ListOptions{Page: 1}\n\t\tfor opts.Page > 0 {\n\t\t\tcommit, resp, err := gh.Repositories.GetCommit(ctx, repo.Owner, repo.Name, curr, opts)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, file := range commit.Files {\n\t\t\t\tfileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())\n\t\t\t}\n\t\t\topts.Page = resp.NextPage\n\t\t}\n\t} else {\n\t\topts := &github.ListOptions{Page: 1}\n\t\tfor opts.Page > 0 {\n\t\t\tcomp, resp, err := gh.Repositories.CompareCommits(ctx, repo.Owner, repo.Name, prev, curr, opts)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, file := range comp.Files {\n\t\t\t\tfileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())\n\t\t\t}\n\t\t\topts.Page = resp.NextPage\n\t\t}\n\t}\n\n\tpipeline.ChangedFiles = utils.DeduplicateStrings(fileList)\n\treturn pipeline, err\n}\n\nfunc perPage(custom int) int {\n\tif custom < 1 || custom > defaultPageSize {\n\t\treturn defaultPageSize\n\t}\n\treturn custom\n}\n"
  },
  {
    "path": "server/forge/github/github_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"context\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/go-github/v86/github\"\n\tgithub_mock \"github.com/migueleliasweb/go-github-mock/src/mock\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/github/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestNew(t *testing.T) {\n\tforge, _ := New(1, Opts{\n\t\tURL:               \"http://localhost:8080/\",\n\t\tOAuthClientID:     \"0ZXh0IjoiI\",\n\t\tOAuthClientSecret: \"I1NiIsInR5\",\n\t\tSkipVerify:        true,\n\t})\n\tf, _ := forge.(*client)\n\tassert.Equal(t, \"http://localhost:8080\", f.url)\n\tassert.Equal(t, \"http://localhost:8080/api/v3/\", f.API)\n\tassert.Equal(t, \"0ZXh0IjoiI\", f.Client)\n\tassert.Equal(t, \"I1NiIsInR5\", f.Secret)\n\tassert.True(t, f.SkipVerify)\n}\n\nfunc Test_github(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ts := httptest.NewServer(fixtures.Handler())\n\tc, _ := New(1, Opts{\n\t\tURL:        s.URL,\n\t\tSkipVerify: true,\n\t})\n\n\tdefer s.Close()\n\n\tctx := t.Context()\n\n\tt.Run(\"netrc with user token\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(fakeUser, fakeRepo)\n\t\tassert.Equal(t, \"github.com\", netrc.Machine)\n\t\tassert.Equal(t, fakeUser.AccessToken, netrc.Login)\n\t\tassert.Equal(t, \"x-oauth-basic\", netrc.Password)\n\t\tassert.Equal(t, model.ForgeTypeGithub, netrc.Type)\n\t})\n\tt.Run(\"netrc with machine account\", func(t *testing.T) {\n\t\tforge, _ := New(1, Opts{})\n\t\tnetrc, _ := forge.Netrc(nil, fakeRepo)\n\t\tassert.Equal(t, \"github.com\", netrc.Machine)\n\t\tassert.Empty(t, netrc.Login)\n\t\tassert.Empty(t, netrc.Password)\n\t})\n\n\tt.Run(\"Should return the repository details\", func(t *testing.T) {\n\t\trepo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, fakeRepo.ForgeRemoteID, repo.ForgeRemoteID)\n\t\tassert.Equal(t, fakeRepo.Owner, repo.Owner)\n\t\tassert.Equal(t, fakeRepo.Name, repo.Name)\n\t\tassert.Equal(t, fakeRepo.FullName, repo.FullName)\n\t\tassert.True(t, repo.IsSCMPrivate)\n\t\tassert.Equal(t, fakeRepo.Clone, repo.Clone)\n\t\tassert.Equal(t, fakeRepo.ForgeURL, repo.ForgeURL)\n\t})\n\tt.Run(\"repo not found error\", func(t *testing.T) {\n\t\t_, err := c.Repo(ctx, fakeUser, \"0\", fakeRepoNotFound.Owner, fakeRepoNotFound.Name)\n\t\tassert.Error(t, err)\n\t})\n}\n\nvar (\n\tfakeUser = &model.User{\n\t\tLogin:       \"6543\",\n\t\tAccessToken: \"cfcd2084\",\n\t}\n\n\tfakeRepo = &model.Repo{\n\t\tForgeRemoteID: \"5\",\n\t\tOwner:         \"octocat\",\n\t\tName:          \"Hello-World\",\n\t\tFullName:      \"octocat/Hello-World\",\n\t\tAvatar:        \"https://github.com/images/error/octocat_happy.gif\",\n\t\tForgeURL:      \"https://github.com/octocat/Hello-World\",\n\t\tClone:         \"https://github.com/octocat/Hello-World.git\",\n\t\tIsSCMPrivate:  true,\n\t}\n\n\tfakeRepoNotFound = &model.Repo{\n\t\tOwner:    \"test_name\",\n\t\tName:     \"repo_not_found\",\n\t\tFullName: \"test_name/repo_not_found\",\n\t}\n)\n\nfunc TestHook(t *testing.T) {\n\t// Mock GitHub API for changed files\n\tmockedHTTPClient := github_mock.NewMockedHTTPClient(\n\t\tgithub_mock.WithRequestMatch(\n\t\t\tgithub_mock.GetReposCommitsByOwnerByRepoByRef,\n\t\t\tgithub.RepositoryCommit{\n\t\t\t\tFiles: []*github.CommitFile{\n\t\t\t\t\t{Filename: github.Ptr(\"README.md\")},\n\t\t\t\t\t{Filename: github.Ptr(\"main.go\")},\n\t\t\t\t},\n\t\t\t},\n\t\t),\n\t\tgithub_mock.WithRequestMatch(\n\t\t\tgithub_mock.GetReposCompareByOwnerByRepoByBasehead,\n\t\t\tgithub.CommitsComparison{\n\t\t\t\tFiles: []*github.CommitFile{\n\t\t\t\t\t{Filename: github.Ptr(\"main.go\")},\n\t\t\t\t},\n\t\t\t},\n\t\t),\n\t\tgithub_mock.WithRequestMatch(\n\t\t\tgithub_mock.GetReposPullsFilesByOwnerByRepoByPullNumber,\n\t\t\t[]*github.CommitFile{\n\t\t\t\t{Filename: github.Ptr(\"README.md\")},\n\t\t\t\t{Filename: github.Ptr(\"main.go\")},\n\t\t\t},\n\t\t),\n\t)\n\n\t// Create a GitHub client with the mocked HTTP client\n\tgh := github.NewClient(mockedHTTPClient)\n\n\t// Use the custom type as the key\n\tctx := context.WithValue(context.Background(), githubClientKey, gh)\n\n\t// Create a mock store using the proper mocking pattern\n\tmockStore := store_mocks.NewMockStore(t)\n\tmockStore.On(\"GetUser\", mock.Anything).Return(&model.User{\n\t\tID:          1,\n\t\tLogin:       \"6543\",\n\t\tAccessToken: \"token\",\n\t}, nil)\n\tmockStore.On(\"GetRepoNameFallback\", mock.Anything, mock.Anything, mock.Anything).Return(&model.Repo{\n\t\tID:            1,\n\t\tForgeRemoteID: \"1\",\n\t\tOwner:         \"6543\",\n\t\tName:          \"hello-world\",\n\t\tUserID:        1,\n\t}, nil)\n\n\t// Set up context with mock store\n\tctx = store.InjectToContext(ctx, mockStore)\n\n\t// Create a mock client\n\tc := &client{\n\t\tAPI: defaultAPI,\n\t\turl: defaultURL,\n\t}\n\n\tt.Run(\"convert push from webhook\", func(t *testing.T) {\n\t\t// Create a mock HTTP request with a push event payload\n\t\treq := httptest.NewRequest(\"POST\", \"/hook\", strings.NewReader(fixtures.HookPush))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-GitHub-Event\", \"push\")\n\n\t\t// Call the Hook function\n\t\trepo, pipeline, err := c.Hook(ctx, req)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, repo)\n\t\tassert.NotNil(t, pipeline)\n\t\tassert.Equal(t, model.EventPush, pipeline.Event)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"refs/heads/main\", pipeline.Ref)\n\t\tassert.Equal(t, \"366701fde727cb7a9e7f21eb88264f59f6f9b89c\", pipeline.Commit)\n\t\tassert.Equal(t, \"Fix multiline secrets replacer (#700)\\n\\n* Fix multiline secrets replacer\\r\\n\\r\\n* Add tests\", pipeline.Message)\n\t\tassert.Equal(t, \"https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"6543\", pipeline.Author)\n\t\tassert.Equal(t, \"https://avatars.githubusercontent.com/u/24977596?v=4\", pipeline.Avatar)\n\t\tassert.Equal(t, \"admin@philipp.info\", pipeline.Email)\n\t\tassert.Equal(t, []string{\"main.go\"}, pipeline.ChangedFiles)\n\t})\n\n\tt.Run(\"convert pull request from webhook\", func(t *testing.T) {\n\t\t// Create a mock HTTP request with a pull request event payload\n\t\treq := httptest.NewRequest(\"POST\", \"/hook\", strings.NewReader(fixtures.HookPullRequest))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-GitHub-Event\", \"pull_request\")\n\n\t\t// Call the Hook function\n\t\trepo, pipeline, err := c.Hook(ctx, req)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, repo)\n\t\tassert.NotNil(t, pipeline)\n\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"refs/pull/1/head\", pipeline.Ref)\n\t\tassert.Equal(t, \"changes:main\", pipeline.Refspec)\n\t\tassert.Equal(t, \"0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c\", pipeline.Commit)\n\t\tassert.Equal(t, \"Update the README with new information\", pipeline.Message)\n\t\tassert.Equal(t, \"Update the README with new information\", pipeline.Title)\n\t\tassert.Equal(t, \"baxterthehacker\", pipeline.Author)\n\t\tassert.Equal(t, \"https://avatars.githubusercontent.com/u/6752317?v=3\", pipeline.Avatar)\n\t\tassert.Equal(t, \"octocat\", pipeline.Sender)\n\t\tassert.Equal(t, []string{\"README.md\", \"main.go\"}, pipeline.ChangedFiles)\n\t})\n\n\tt.Run(\"convert deployment from webhook\", func(t *testing.T) {\n\t\t// Create a mock HTTP request with a deployment event payload\n\t\treq := httptest.NewRequest(\"POST\", \"/hook\", strings.NewReader(fixtures.HookDeploy))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-GitHub-Event\", \"deployment\")\n\n\t\t// Call the Hook function\n\t\trepo, pipeline, err := c.Hook(ctx, req)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, repo)\n\t\tassert.NotNil(t, pipeline)\n\t\tassert.Equal(t, model.EventDeploy, pipeline.Event)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"refs/heads/main\", pipeline.Ref)\n\t\tassert.Equal(t, \"9049f1265b7d61be4a8904a9a27120d2064dab3b\", pipeline.Commit)\n\t\tassert.Equal(t, \"\", pipeline.Message)\n\t\tassert.Equal(t, \"https://api.github.com/repos/baxterthehacker/public-repo/deployments/710692\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"baxterthehacker\", pipeline.Author)\n\t\tassert.Equal(t, \"https://avatars.githubusercontent.com/u/6752317?v=3\", pipeline.Avatar)\n\t})\n\n\tt.Run(\"convert tag from webhook\", func(t *testing.T) {\n\t\t// Create a mock HTTP request with a tag event payload but push event header (tags create push events at github)\n\t\treq := httptest.NewRequest(\"POST\", \"/hook\", strings.NewReader(fixtures.HookTag))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-GitHub-Event\", \"push\")\n\n\t\t// Call the Hook function\n\t\trepo, pipeline, err := c.Hook(ctx, req)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, repo)\n\t\tassert.NotNil(t, pipeline)\n\t\tassert.Equal(t, model.EventTag, pipeline.Event)\n\t\tassert.Equal(t, \"main\", pipeline.Branch)\n\t\tassert.Equal(t, \"refs/tags/the-tag-v1\", pipeline.Ref)\n\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", pipeline.Commit)\n\t\tassert.Equal(t, \"Update .woodpecker.yml\", pipeline.Message)\n\t\tassert.Equal(t, \"https://github.com/6543/test_ci_tmp/commit/67012991d6c69b1c58378346fca366b864d8d1a1\", pipeline.ForgeURL)\n\t\tassert.Equal(t, \"6543\", pipeline.Author)\n\t\tassert.Equal(t, \"https://avatars.githubusercontent.com/u/24977596?v=4\", pipeline.Avatar)\n\t\tassert.Equal(t, \"6543@obermui.de\", pipeline.Email)\n\t\tassert.Empty(t, pipeline.ChangedFiles)\n\t})\n}\n"
  },
  {
    "path": "server/forge/github/parse.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/google/go-github/v86/github\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\thookField = \"payload\"\n\n\tactionOpen             = \"opened\"\n\tactionReopen           = \"reopened\"\n\tactionClose            = \"closed\"\n\tactionSync             = \"synchronize\"\n\tactionReleased         = \"released\"\n\tactionAssigned         = \"assigned\"\n\tactionConvertedToDraft = \"converted_to_draft\"\n\tactionDemilestoned     = \"demilestoned\"\n\tactionEdited           = \"edited\"\n\tactionLabeled          = \"labeled\"\n\tactionLocked           = \"locked\"\n\tactionMilestoned       = \"milestoned\"\n\tactionReadyForReview   = \"ready_for_review\"\n\tactionUnassigned       = \"unassigned\"\n\tactionUnlabeled        = \"unlabeled\"\n\tactionUnlocked         = \"unlocked\"\n\n\tlabelCleared = \"label_cleared\"\n\tlabelUpdated = \"label_updated\"\n)\n\n// parseHook parses a GitHub hook from an http.Request request and returns\n// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.\nfunc parseHook(r *http.Request, merge bool) (_ *github.PullRequest, _ *model.Repo, _ *model.Pipeline, currCommit, prevCommit string, _ error) {\n\tvar reader io.Reader = r.Body\n\n\tif payload := r.FormValue(hookField); payload != \"\" {\n\t\treader = bytes.NewBufferString(payload)\n\t}\n\n\traw, err := io.ReadAll(reader)\n\tif err != nil {\n\t\treturn nil, nil, nil, \"\", \"\", err\n\t}\n\n\tpayload, err := github.ParseWebHook(github.WebHookType(r), raw)\n\tif err != nil {\n\t\treturn nil, nil, nil, \"\", \"\", err\n\t}\n\n\tswitch hook := payload.(type) {\n\tcase *github.PushEvent:\n\t\trepo, pipeline, curr, prev := parsePushHook(hook)\n\t\treturn nil, repo, pipeline, curr, prev, nil\n\tcase *github.DeploymentEvent:\n\t\trepo, pipeline := parseDeployHook(hook)\n\t\treturn nil, repo, pipeline, \"\", \"\", nil\n\tcase *github.PullRequestEvent:\n\t\tpr, repo, pipeline, err := parsePullHook(hook, merge)\n\t\treturn pr, repo, pipeline, \"\", \"\", err\n\tcase *github.ReleaseEvent:\n\t\trepo, pipeline := parseReleaseHook(hook)\n\t\treturn nil, repo, pipeline, \"\", \"\", nil\n\tdefault:\n\t\treturn nil, nil, nil, \"\", \"\", &types.ErrIgnoreEvent{Event: github.Stringify(hook)}\n\t}\n}\n\n// parsePushHook parses a push hook and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parsePushHook(hook *github.PushEvent) (_ *model.Repo, _ *model.Pipeline, curr, prev string) {\n\tif hook.Deleted != nil && *hook.Deleted {\n\t\treturn nil, nil, \"\", \"\"\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:    model.EventPush,\n\t\tCommit:   hook.GetHeadCommit().GetID(),\n\t\tRef:      hook.GetRef(),\n\t\tForgeURL: hook.GetHeadCommit().GetURL(),\n\t\tBranch:   strings.ReplaceAll(hook.GetRef(), \"refs/heads/\", \"\"),\n\t\tMessage:  hook.GetHeadCommit().GetMessage(),\n\t\tEmail:    hook.GetHeadCommit().GetAuthor().GetEmail(),\n\t\tAvatar:   hook.GetSender().GetAvatarURL(),\n\t\tAuthor:   hook.GetSender().GetLogin(),\n\t\tSender:   hook.GetSender().GetLogin(),\n\t}\n\trepo := convertRepoHook(hook.GetRepo())\n\n\tif len(pipeline.Author) == 0 {\n\t\tpipeline.Author = hook.GetHeadCommit().GetAuthor().GetLogin()\n\t}\n\tif strings.HasPrefix(pipeline.Ref, \"refs/tags/\") {\n\t\t// just kidding, this is actually a tag event. Why did this come as a push\n\t\t// event we'll never know!\n\t\tpipeline.Event = model.EventTag\n\t\t// For tags, if the base_ref (tag's base branch) is set, we're using it\n\t\t// as pipeline's branch so that we can filter events base on it\n\t\tif strings.HasPrefix(hook.GetBaseRef(), \"refs/heads/\") {\n\t\t\tpipeline.Branch = strings.ReplaceAll(hook.GetBaseRef(), \"refs/heads/\", \"\")\n\t\t}\n\t\treturn repo, pipeline, \"\", \"\"\n\t}\n\n\treturn repo, pipeline, hook.GetHeadCommit().GetID(), hook.GetBefore()\n}\n\n// parseDeployHook parses a deployment and returns the Repo and Pipeline details.\n// If the commit type is unsupported nil values are returned.\nfunc parseDeployHook(hook *github.DeploymentEvent) (*model.Repo, *model.Pipeline) {\n\tpipeline := &model.Pipeline{\n\t\tEvent:      model.EventDeploy,\n\t\tCommit:     hook.GetDeployment().GetSHA(),\n\t\tForgeURL:   hook.GetDeployment().GetURL(),\n\t\tMessage:    hook.GetDeployment().GetDescription(),\n\t\tRef:        hook.GetDeployment().GetRef(),\n\t\tBranch:     hook.GetDeployment().GetRef(),\n\t\tAvatar:     hook.GetSender().GetAvatarURL(),\n\t\tAuthor:     hook.GetSender().GetLogin(),\n\t\tSender:     hook.GetSender().GetLogin(),\n\t\tDeployTo:   hook.GetDeployment().GetEnvironment(),\n\t\tDeployTask: hook.GetDeployment().GetTask(),\n\t}\n\t// if the ref is a sha or short sha we need to manually construct the ref.\n\tif strings.HasPrefix(pipeline.Commit, pipeline.Ref) || pipeline.Commit == pipeline.Ref {\n\t\tpipeline.Branch = hook.GetRepo().GetDefaultBranch()\n\t\tpipeline.Ref = fmt.Sprintf(\"refs/heads/%s\", pipeline.Branch)\n\t}\n\t// if the ref is a branch we should make sure it has refs/heads prefix\n\tif !strings.HasPrefix(pipeline.Ref, \"refs/\") { // branch or tag\n\t\tpipeline.Ref = fmt.Sprintf(\"refs/heads/%s\", pipeline.Branch)\n\t}\n\n\treturn convertRepo(hook.GetRepo()), pipeline\n}\n\n// parsePullHook parses a pull request hook and returns the Repo and Pipeline\n// details.\nfunc parsePullHook(hook *github.PullRequestEvent, merge bool) (*github.PullRequest, *model.Repo, *model.Pipeline, error) {\n\tevent := model.EventPull\n\teventAction := \"\"\n\n\tswitch hook.GetAction() {\n\tcase actionOpen, actionReopen, actionSync:\n\t\t// default case nothing to do\n\tcase actionClose:\n\t\tevent = model.EventPullClosed\n\tcase actionAssigned,\n\t\tactionConvertedToDraft,\n\t\tactionDemilestoned,\n\t\tactionEdited,\n\t\tactionLabeled,\n\t\tactionLocked,\n\t\tactionMilestoned,\n\t\tactionReadyForReview,\n\t\tactionUnassigned,\n\t\tactionUnlabeled,\n\t\tactionUnlocked:\n\t\t// metadata pull events\n\t\tevent = model.EventPullMetadata\n\t\teventAction = common.NormalizeEventReason(hook.GetAction())\n\tdefault:\n\t\treturn nil, nil, nil, &types.ErrIgnoreEvent{\n\t\t\tEvent:  string(model.EventPullMetadata),\n\t\t\tReason: fmt.Sprintf(\"action %s is not supported\", hook.GetAction()),\n\t\t}\n\t}\n\n\tfromFork := hook.GetPullRequest().GetHead().GetRepo().GetID() != hook.GetPullRequest().GetBase().GetRepo().GetID()\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:       event,\n\t\tEventReason: []string{eventAction},\n\t\tCommit:      hook.GetPullRequest().GetHead().GetSHA(),\n\t\tForgeURL:    hook.GetPullRequest().GetHTMLURL(),\n\t\tRef:         fmt.Sprintf(headRefs, hook.GetPullRequest().GetNumber()),\n\t\tBranch:      hook.GetPullRequest().GetBase().GetRef(),\n\t\tMessage:     hook.GetPullRequest().GetTitle(),\n\t\tAuthor:      hook.GetPullRequest().GetUser().GetLogin(),\n\t\tAvatar:      hook.GetPullRequest().GetUser().GetAvatarURL(),\n\t\tTitle:       hook.GetPullRequest().GetTitle(),\n\t\tSender:      hook.GetSender().GetLogin(),\n\t\tRefspec: fmt.Sprintf(refSpec,\n\t\t\thook.GetPullRequest().GetHead().GetRef(),\n\t\t\thook.GetPullRequest().GetBase().GetRef(),\n\t\t),\n\t\tPullRequestLabels:    convertLabels(hook.GetPullRequest().Labels),\n\t\tPullRequestMilestone: hook.GetPullRequest().GetMilestone().GetTitle(),\n\t\tFromFork:             fromFork,\n\t}\n\tif merge {\n\t\tpipeline.Ref = fmt.Sprintf(mergeRefs, hook.GetPullRequest().GetNumber())\n\t}\n\n\t// normalize label events to match other forges\n\tif eventAction == actionLabeled || eventAction == actionUnlabeled {\n\t\tif len(pipeline.PullRequestLabels) == 0 {\n\t\t\tpipeline.EventReason = []string{labelCleared}\n\t\t} else {\n\t\t\tpipeline.EventReason = []string{labelUpdated}\n\t\t}\n\t}\n\n\treturn hook.GetPullRequest(), convertRepo(hook.GetRepo()), pipeline, nil\n}\n\n// parseReleaseHook parses a release hook and returns the Repo and Pipeline\n// details.\nfunc parseReleaseHook(hook *github.ReleaseEvent) (*model.Repo, *model.Pipeline) {\n\tif hook.GetAction() != actionReleased {\n\t\treturn nil, nil\n\t}\n\n\tname := hook.GetRelease().GetName()\n\tif name == \"\" {\n\t\tname = hook.GetRelease().GetTagName()\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:        model.EventRelease,\n\t\tForgeURL:     hook.GetRelease().GetHTMLURL(),\n\t\tRef:          fmt.Sprintf(\"refs/tags/%s\", hook.GetRelease().GetTagName()),\n\t\tBranch:       hook.GetRelease().GetTargetCommitish(), // cspell:disable-line\n\t\tMessage:      fmt.Sprintf(\"created release %s\", name),\n\t\tAuthor:       hook.GetRelease().GetAuthor().GetLogin(),\n\t\tAvatar:       hook.GetRelease().GetAuthor().GetAvatarURL(),\n\t\tSender:       hook.GetSender().GetLogin(),\n\t\tIsPrerelease: hook.GetRelease().GetPrerelease(),\n\t}\n\n\treturn convertRepo(hook.GetRepo()), pipeline\n}\n"
  },
  {
    "path": "server/forge/github/parse_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage github\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/github/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst (\n\thookEvent   = \"X-GitHub-Event\"\n\thookDeploy  = \"deployment\"\n\thookPush    = \"push\"\n\thookPull    = \"pull_request\"\n\thookRelease = \"release\"\n)\n\nfunc testHookRequest(payload []byte, event string) *http.Request {\n\tbuf := bytes.NewBuffer(payload)\n\treq, _ := http.NewRequest(http.MethodPost, \"/hook\", buf)\n\treq.Header = http.Header{}\n\treq.Header.Set(hookEvent, event)\n\treturn req\n}\n\nfunc Test_parseHook(t *testing.T) {\n\tt.Run(\"ignore unsupported hook events\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequest), \"issues\")\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.Nil(t, r)\n\t\tassert.Nil(t, b)\n\t\tassert.Nil(t, p)\n\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t})\n\n\tt.Run(\"skip skip push hook when action is deleted\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPushDeleted), hookPush)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.Nil(t, r)\n\t\tassert.Nil(t, b)\n\t\tassert.NoError(t, err)\n\t\tassert.Nil(t, p)\n\t})\n\tt.Run(\"push hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPush), hookPush)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Equal(t, \"2f780193b136b72bfea4eeb640786a8c4450c7a2\", pc)\n\t\tassert.Equal(t, \"366701fde727cb7a9e7f21eb88264f59f6f9b89c\", cc)\n\t\tassert.NoError(t, err)\n\t\tassert.Nil(t, p)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPush, b.Event)\n\t\tsort.Strings(b.ChangedFiles)\n\t})\n\n\tt.Run(\"PR hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequest), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NotNil(t, p)\n\t\tassert.Equal(t, model.EventPull, b.Event)\n\t})\n\tt.Run(\"PR closed hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestClosed), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NotNil(t, p)\n\t\tassert.Equal(t, model.EventPullClosed, b.Event)\n\t})\n\n\tt.Run(\"reopen a pull\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestReopened), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NotNil(t, p)\n\t\tassert.Equal(t, model.EventPull, b.Event)\n\t})\n\n\tt.Run(\"PR merged hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestMerged), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NotNil(t, p)\n\t\tassert.Equal(t, model.EventPullClosed, b.Event)\n\t})\n\n\tt.Run(\"PR edited hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestEdited), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.NotNil(t, p)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"edited\"}, b.EventReason)\n\t})\n\n\tt.Run(\"deploy hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookDeploy), hookDeploy)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Nil(t, p)\n\t\tassert.Equal(t, model.EventDeploy, b.Event)\n\t\tassert.Equal(t, \"production\", b.DeployTo)\n\t\tassert.Equal(t, \"deploy\", b.DeployTask)\n\t})\n\n\tt.Run(\"release hook\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookRelease), hookRelease)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Nil(t, p)\n\t\tassert.Equal(t, model.EventRelease, b.Event)\n\t\tassert.Len(t, strings.Split(b.Ref, \"/\"), 3)\n\t\tassert.True(t, strings.HasPrefix(b.Ref, \"refs/tags/\"))\n\t})\n\n\tt.Run(\"pull review requested\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestReviewRequested), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t\tassert.Nil(t, r)\n\t\tassert.Nil(t, b)\n\t\tassert.Nil(t, p)\n\t})\n\n\tt.Run(\"pull milestoned\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestMilestoneAdded), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"milestoned\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tassert.Equal(t, \"yeaaa\", *p.Body)\n\t\t\tassert.Equal(t, false, *p.Draft)\n\t\t\tassert.Equal(t, false, *p.Merged)\n\t\t\tassert.Equal(t, true, *p.Mergeable)\n\t\t\tassert.Equal(t, \"unstable\", *p.MergeableState)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Milestone) {\n\t\t\t\tassert.Equal(t, int64(13392101), *p.Milestone.ID)\n\t\t\t\tassert.Equal(t, 2, *p.Milestone.Number)\n\t\t\t\tassert.Equal(t, \"open mile\", *p.Milestone.Title)\n\t\t\t\tassert.Equal(t, \"ongoing\", *p.Milestone.Description)\n\t\t\t\tassert.Equal(t, \"open\", *p.Milestone.State)\n\t\t\t\tif assert.NotNil(t, p.Milestone.Creator) {\n\t\t\t\t\tassert.Equal(t, \"demoaccount2-commits\", *p.Milestone.Creator.Login)\n\t\t\t\t\tassert.Equal(t, int64(223550959), *p.Milestone.Creator.ID)\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Empty(t, p.RequestedReviewers)\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\t// milestone change will result two webhooks an demilestoned and milestoned\n\n\tt.Run(\"pull request demilestoned\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestMilestoneRemoved), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"demilestoned\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.Len(t, p.Labels, 1) {\n\t\t\t\tassert.Equal(t, int64(9024465370), *p.Labels[0].ID)\n\t\t\t\tassert.Equal(t, \"bug\", *p.Labels[0].Name)\n\t\t\t\tassert.Equal(t, \"d73a4a\", *p.Labels[0].Color)\n\t\t\t\tassert.Equal(t, \"Something isn't working\", *p.Labels[0].Description)\n\t\t\t}\n\t\t\tassert.Nil(t, p.Milestone)\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"pull request labele added\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestLabelAdded), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"label_updated\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tassert.Equal(t, \"yeaaa\", *p.Body)\n\t\t\tassert.Equal(t, false, *p.Draft)\n\t\t\tassert.Equal(t, false, *p.Merged)\n\t\t\tassert.Equal(t, true, *p.Mergeable)\n\t\t\tassert.Equal(t, \"unstable\", *p.MergeableState)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.Len(t, p.Labels, 2) {\n\t\t\t\tassert.Equal(t, int64(9024465376), *p.Labels[0].ID)\n\t\t\t\tassert.Equal(t, \"documentation\", *p.Labels[0].Name)\n\t\t\t\tassert.Equal(t, \"0075ca\", *p.Labels[0].Color)\n\t\t\t\tassert.Equal(t, \"Improvements or additions to documentation\", *p.Labels[0].Description)\n\t\t\t\tassert.Equal(t, int64(9024465382), *p.Labels[1].ID)\n\t\t\t\tassert.Equal(t, \"enhancement\", *p.Labels[1].Name)\n\t\t\t\tassert.Equal(t, \"a2eeef\", *p.Labels[1].Color)\n\t\t\t\tassert.Equal(t, \"New feature or request\", *p.Labels[1].Description)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Milestone) {\n\t\t\t\tassert.Equal(t, int64(13392101), *p.Milestone.ID)\n\t\t\t\tassert.Equal(t, \"open mile\", *p.Milestone.Title)\n\t\t\t}\n\t\t\tassert.Empty(t, p.RequestedReviewers)\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\t// lable change will result two webhooks an unlable and labeled\n\n\tt.Run(\"pull request got label removed\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestLabelRemoved), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"label_updated\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.Len(t, p.Labels, 1) {\n\t\t\t\tassert.Equal(t, int64(9024465370), *p.Labels[0].ID)\n\t\t\t\tassert.Equal(t, \"bug\", *p.Labels[0].Name)\n\t\t\t\tassert.Equal(t, \"d73a4a\", *p.Labels[0].Color)\n\t\t\t\tassert.Equal(t, \"Something isn't working\", *p.Labels[0].Description)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"pull request got all label removed\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestLabelsCleared), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"label_cleared\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tassert.Empty(t, p.Labels)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"pull request assigned\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestAssigneeAdded), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"assigned\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Assignee) {\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits\", *p.Assignee.Login)\n\t\t\t\tassert.Equal(t, int64(223550959), *p.Assignee.ID)\n\t\t\t}\n\t\t\tif assert.Len(t, p.Assignees, 1) {\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits\", *p.Assignees[0].Login)\n\t\t\t\tassert.Equal(t, int64(223550959), *p.Assignees[0].ID)\n\t\t\t}\n\t\t\tif assert.Len(t, p.Labels, 1) {\n\t\t\t\tassert.Equal(t, int64(9024465370), *p.Labels[0].ID)\n\t\t\t\tassert.Equal(t, \"bug\", *p.Labels[0].Name)\n\t\t\t}\n\t\t\tassert.Nil(t, p.Milestone)\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n\n\t// assigne change will result two webhooks an assigned and unassigned\n\n\tt.Run(\"pull request unassigned\", func(t *testing.T) {\n\t\treq := testHookRequest([]byte(fixtures.HookPullRequestAssigneeRemoved), hookPull)\n\t\tp, r, b, cc, pc, err := parseHook(req, false)\n\t\tassert.Empty(t, pc)\n\t\tassert.Empty(t, cc)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, r)\n\t\tassert.NotNil(t, b)\n\t\tassert.Equal(t, model.EventPullMetadata, b.Event)\n\t\tassert.Equal(t, []string{\"unassigned\"}, b.EventReason)\n\t\tif assert.NotNil(t, p) {\n\t\t\tassert.Equal(t, int64(2705176047), *p.ID)\n\t\t\tassert.Equal(t, 1, *p.Number)\n\t\t\tassert.Equal(t, \"open\", *p.State)\n\t\t\tassert.Equal(t, \"Some ned more AAAA\", *p.Title)\n\t\t\tif assert.NotNil(t, p.User) {\n\t\t\t\tassert.Equal(t, \"6543\", *p.User.Login)\n\t\t\t\tassert.Equal(t, int64(24977596), *p.User.ID)\n\t\t\t}\n\t\t\tassert.Nil(t, p.Assignee)\n\t\t\tassert.Empty(t, p.Assignees)\n\t\t\tif assert.Len(t, p.Labels, 1) {\n\t\t\t\tassert.Equal(t, int64(9024465370), *p.Labels[0].ID)\n\t\t\t\tassert.Equal(t, \"bug\", *p.Labels[0].Name)\n\t\t\t}\n\t\t\tassert.Nil(t, p.Milestone)\n\t\t\tif assert.NotNil(t, p.Head) {\n\t\t\t\tassert.Equal(t, \"6543-patch-1\", *p.Head.Ref)\n\t\t\t\tassert.Equal(t, \"36b5813240a9d2daa29b05046d56a53e18f39a3e\", *p.Head.SHA)\n\t\t\t}\n\t\t\tif assert.NotNil(t, p.Base) {\n\t\t\t\tassert.Equal(t, \"main\", *p.Base.Ref)\n\t\t\t\tassert.Equal(t, \"67012991d6c69b1c58378346fca366b864d8d1a1\", *p.Base.SHA)\n\t\t\t}\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "server/forge/gitlab/convert.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitlab\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\tgitlab \"gitlab.com/gitlab-org/api/client-go/v2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tmergeRefs = \"refs/merge-requests/%d/head\" // merge request merged with base\n\n\t// GitLab project visibility_level values, as sent in webhook payloads.\n\t// See https://docs.gitlab.com/api/projects/#project-visibility-level.\n\tvisibilityLevelPrivate  = 0\n\tvisibilityLevelInternal = 10\n\tvisibilityLevelPublic   = 20\n\n\tstateOpened = \"opened\"\n\n\tactionOpen   = \"open\"\n\tactionClose  = \"close\"\n\tactionReopen = \"reopen\"\n\tactionMerge  = \"merge\"\n\tactionUpdate = \"update\"\n\n\tmetadataReasonAssigned          = \"assigned\"\n\tmetadataReasonUnassigned        = \"unassigned\"\n\tmetadataReasonMilestoned        = \"milestoned\"\n\tmetadataReasonDemilestoned      = \"demilestoned\"\n\tmetadataReasonTitleEdited       = \"title_edited\"\n\tmetadataReasonDescriptionEdited = \"description_edited\"\n\tmetadataReasonLabelsAdded       = \"labels_added\"\n\tmetadataReasonLabelsCleared     = \"labels_cleared\"\n\tmetadataReasonLabelsUpdated     = \"labels_updated\"\n\tmetadataReasonReviewRequested   = \"review_requested\"\n)\n\nfunc (g *GitLab) convertGitLabRepo(_repo *gitlab.Project, projectMember *gitlab.ProjectMember) (*model.Repo, error) {\n\tparts := strings.Split(_repo.PathWithNamespace, \"/\")\n\towner := strings.Join(parts[:len(parts)-1], \"/\")\n\tname := parts[len(parts)-1]\n\trepo := &model.Repo{\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(_repo.ID)),\n\t\tOwner:         owner,\n\t\tName:          name,\n\t\tFullName:      _repo.PathWithNamespace,\n\t\tAvatar:        _repo.AvatarURL,\n\t\tForgeURL:      _repo.WebURL,\n\t\tClone:         _repo.HTTPURLToRepo,\n\t\tCloneSSH:      _repo.SSHURLToRepo,\n\t\tBranch:        _repo.DefaultBranch,\n\t\tVisibility:    model.RepoVisibility(_repo.Visibility),\n\t\tIsSCMPrivate:  _repo.Visibility == gitlab.InternalVisibility || _repo.Visibility == gitlab.PrivateVisibility,\n\t\tPerm: &model.Perm{\n\t\t\tPull:  isRead(_repo, projectMember),\n\t\t\tPush:  isWrite(projectMember),\n\t\t\tAdmin: isAdmin(projectMember),\n\t\t},\n\t\tPREnabled: _repo.MergeRequestsAccessLevel != gitlab.DisabledAccessControl,\n\t}\n\n\tif len(repo.Avatar) != 0 && !strings.HasPrefix(repo.Avatar, \"http\") {\n\t\trepo.Avatar = fmt.Sprintf(\"%s/%s\", g.url, repo.Avatar)\n\t}\n\n\treturn repo, nil\n}\n\nfunc convertMergeRequestHook(hook *gitlab.MergeEvent, req *http.Request) (mergeID, milestoneID int64, repo *model.Repo, pipeline *model.Pipeline, err error) {\n\trepo = &model.Repo{}\n\tpipeline = &model.Pipeline{}\n\n\ttarget := hook.ObjectAttributes.Target\n\tsource := hook.ObjectAttributes.Source\n\tobj := hook.ObjectAttributes\n\n\tswitch obj.Action {\n\tcase actionClose, actionMerge:\n\t\t// pull close event\n\t\tpipeline.Event = model.EventPullClosed\n\n\tcase actionOpen, actionReopen:\n\t\t// pull open event -> pull event\n\t\tpipeline.Event = model.EventPull\n\n\tcase actionUpdate:\n\t\tif obj.OldRev != \"\" && obj.State == stateOpened {\n\t\t\t// if some git action happened then OldRev != \"\" -> it's a normal pull_request trigger\n\t\t\t// https://github.com/woodpecker-ci/woodpecker/pull/3338\n\t\t\t// https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events\n\t\t\tpipeline.Event = model.EventPull\n\t\t\tbreak\n\t\t}\n\n\t\tpipeline.Event = model.EventPullMetadata\n\t\t// All changes are just update actions ... so we have to look into the changes section\n\t\tvar reason []string\n\t\tif len(hook.Changes.Assignees.Current) != 0 {\n\t\t\treason = append(reason, metadataReasonAssigned)\n\t\t}\n\t\tif len(hook.Changes.Assignees.Previous) != 0 {\n\t\t\treason = append(reason, metadataReasonUnassigned)\n\t\t}\n\n\t\tif hook.Changes.MilestoneID.Current != 0 {\n\t\t\treason = append(reason, metadataReasonMilestoned)\n\t\t}\n\t\tif hook.Changes.MilestoneID.Previous != 0 {\n\t\t\treason = append(reason, metadataReasonDemilestoned)\n\t\t}\n\n\t\tif len(hook.Changes.Title.Current) != 0 || len(hook.Changes.Title.Previous) != 0 {\n\t\t\treason = append(reason, metadataReasonTitleEdited)\n\t\t}\n\n\t\tif len(hook.Changes.Description.Current) != 0 || len(hook.Changes.Description.Previous) != 0 {\n\t\t\treason = append(reason, metadataReasonDescriptionEdited)\n\t\t}\n\n\t\tswitch {\n\t\tcase len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) == 0:\n\t\t\treason = append(reason, metadataReasonLabelsAdded)\n\t\tcase len(hook.Changes.Labels.Current) == 0 && len(hook.Changes.Labels.Previous) != 0:\n\t\t\treason = append(reason, metadataReasonLabelsCleared)\n\t\tcase len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) != 0:\n\t\t\treason = append(reason, metadataReasonLabelsUpdated)\n\t\t}\n\n\t\tif len(hook.Changes.Reviewers.Current) > len(hook.Changes.Reviewers.Previous) {\n\t\t\treason = append(reason, metadataReasonReviewRequested)\n\t\t}\n\n\t\tfor i := range reason {\n\t\t\treason[i] = common.NormalizeEventReason(reason[i])\n\t\t}\n\n\t\tpipeline.EventReason = reason\n\t\tif len(pipeline.EventReason) == 0 {\n\t\t\treturn 0, 0, nil, nil, &types.ErrIgnoreEvent{\n\t\t\t\tEvent:  \"Merge Request Hook\",\n\t\t\t\tReason: fmt.Sprintf(\"Action '%s' no supported changes detected\", obj.Action),\n\t\t\t}\n\t\t}\n\tdefault:\n\t\t// non supported action\n\t\treturn 0, 0, nil, nil, &types.ErrIgnoreEvent{\n\t\t\tEvent:  \"Merge Request Hook\",\n\t\t\tReason: fmt.Sprintf(\"Action '%s' not supported\", obj.Action),\n\t\t}\n\t}\n\n\tswitch {\n\tcase target == nil && source == nil:\n\t\treturn 0, 0, nil, nil, fmt.Errorf(\"target and source keys expected in merge request hook\")\n\tcase target == nil:\n\t\treturn 0, 0, nil, nil, fmt.Errorf(\"target key expected in merge request hook\")\n\tcase source == nil:\n\t\treturn 0, 0, nil, nil, fmt.Errorf(\"source key expected in merge request hook\")\n\t}\n\n\tif target.PathWithNamespace != \"\" {\n\t\tvar err error\n\t\tif repo.Owner, repo.Name, err = extractFromPath(target.PathWithNamespace); err != nil {\n\t\t\treturn 0, 0, nil, nil, err\n\t\t}\n\t\trepo.FullName = target.PathWithNamespace\n\t} else {\n\t\trepo.Owner = req.FormValue(\"owner\")\n\t\trepo.Name = req.FormValue(\"name\")\n\t\trepo.FullName = fmt.Sprintf(\"%s/%s\", repo.Owner, repo.Name)\n\t}\n\n\trepo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(obj.TargetProjectID))\n\trepo.ForgeURL = target.WebURL\n\n\tif target.GitHTTPURL != \"\" {\n\t\trepo.Clone = target.GitHTTPURL\n\t} else {\n\t\trepo.Clone = target.HTTPURL\n\t}\n\tif target.GitSSHURL != \"\" {\n\t\trepo.CloneSSH = target.GitSSHURL\n\t} else {\n\t\trepo.CloneSSH = target.SSHURL\n\t}\n\n\trepo.Branch = target.DefaultBranch\n\n\tif target.AvatarURL != \"\" {\n\t\trepo.Avatar = target.AvatarURL\n\t}\n\n\tlastCommit := obj.LastCommit\n\n\tpipeline.Message = lastCommit.Message\n\tpipeline.Commit = lastCommit.ID\n\n\tpipeline.Ref = fmt.Sprintf(mergeRefs, obj.IID)\n\tpipeline.Branch = obj.SourceBranch\n\tpipeline.Refspec = fmt.Sprintf(\"%s:%s\", obj.SourceBranch, obj.TargetBranch)\n\n\tauthor := lastCommit.Author\n\n\tpipeline.Author = author.Name\n\tpipeline.Email = author.Email\n\n\tif len(pipeline.Email) != 0 {\n\t\tpipeline.Avatar = getUserAvatar(pipeline.Email)\n\t}\n\n\tpipeline.Title = obj.Title\n\tpipeline.ForgeURL = obj.URL\n\tpipeline.PullRequestLabels = convertLabels(hook.Labels)\n\tpipeline.FromFork = target.PathWithNamespace != source.PathWithNamespace\n\n\treturn obj.IID, hook.ObjectAttributes.MilestoneID, repo, pipeline, nil\n}\n\nfunc convertPushHook(hook *gitlab.PushEvent) (*model.Repo, *model.Pipeline, error) {\n\trepo := &model.Repo{}\n\tpipeline := &model.Pipeline{}\n\n\tvar err error\n\tif repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\trepo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID))\n\trepo.Avatar = hook.Project.AvatarURL\n\trepo.ForgeURL = hook.Project.WebURL\n\trepo.Clone = hook.Project.GitHTTPURL\n\trepo.CloneSSH = hook.Project.GitSSHURL\n\trepo.FullName = hook.Project.PathWithNamespace\n\trepo.Branch = hook.Project.DefaultBranch\n\n\t// GitLab does not send `project.visibility` (string) in push event\n\t// payloads — only `project.visibility_level` (numeric), which the\n\t// go-gitlab library does not expose on PushEventProject. So this switch\n\t// is a no-op for real-world payloads, leaving Visibility/IsSCMPrivate\n\t// at zero values. model.Repo.Update() must therefore guard against\n\t// overwriting the value previously synced via the forge API.\n\tswitch hook.Project.Visibility {\n\tcase gitlab.PrivateVisibility:\n\t\trepo.Visibility = model.VisibilityPrivate\n\t\trepo.IsSCMPrivate = true\n\tcase gitlab.InternalVisibility:\n\t\trepo.Visibility = model.VisibilityInternal\n\t\trepo.IsSCMPrivate = true\n\tcase gitlab.PublicVisibility:\n\t\trepo.Visibility = model.VisibilityPublic\n\t\trepo.IsSCMPrivate = false\n\t}\n\n\tpipeline.Event = model.EventPush\n\tpipeline.Commit = hook.After\n\tpipeline.Branch = strings.TrimPrefix(hook.Ref, \"refs/heads/\")\n\tpipeline.Ref = hook.Ref\n\n\t// assume a capacity of 4 changed files per commit\n\tfiles := make([]string, 0, len(hook.Commits)*4)\n\tfor _, cm := range hook.Commits {\n\t\tif hook.After == cm.ID {\n\t\t\tpipeline.Author = cm.Author.Name\n\t\t\tpipeline.Email = cm.Author.Email\n\t\t\tpipeline.Message = cm.Message\n\t\t\tpipeline.Timestamp = cm.Timestamp.Unix()\n\t\t\tif len(pipeline.Email) != 0 {\n\t\t\t\tpipeline.Avatar = getUserAvatar(pipeline.Email)\n\t\t\t}\n\t\t}\n\n\t\tfiles = append(files, cm.Added...)\n\t\tfiles = append(files, cm.Removed...)\n\t\tfiles = append(files, cm.Modified...)\n\t}\n\tpipeline.ChangedFiles = utils.DeduplicateStrings(files)\n\n\treturn repo, pipeline, nil\n}\n\nfunc convertTagHook(hook *gitlab.TagEvent) (*model.Repo, *model.Pipeline, string, error) {\n\trepo := &model.Repo{}\n\tpipeline := &model.Pipeline{}\n\n\tvar err error\n\tif repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil {\n\t\treturn nil, nil, \"\", err\n\t}\n\n\trepo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID))\n\trepo.Avatar = hook.Project.AvatarURL\n\trepo.ForgeURL = hook.Project.WebURL\n\trepo.Clone = hook.Project.GitHTTPURL\n\trepo.CloneSSH = hook.Project.GitSSHURL\n\trepo.FullName = hook.Project.PathWithNamespace\n\trepo.Branch = hook.Project.DefaultBranch\n\n\t// See note in convertPushHook: tag event payloads also omit\n\t// `project.visibility`, so this switch typically does nothing.\n\tswitch hook.Project.Visibility {\n\tcase gitlab.PrivateVisibility:\n\t\trepo.Visibility = model.VisibilityPrivate\n\t\trepo.IsSCMPrivate = true\n\tcase gitlab.InternalVisibility:\n\t\trepo.Visibility = model.VisibilityInternal\n\t\trepo.IsSCMPrivate = true\n\tcase gitlab.PublicVisibility:\n\t\trepo.Visibility = model.VisibilityPublic\n\t\trepo.IsSCMPrivate = false\n\t}\n\n\trefTag := strings.TrimPrefix(hook.Ref, \"refs/heads/\")\n\tpipeline.Event = model.EventTag\n\tpipeline.Commit = hook.After\n\tpipeline.Branch = refTag\n\tpipeline.Ref = hook.Ref\n\n\tfor _, cm := range hook.Commits {\n\t\tif hook.After == cm.ID {\n\t\t\tpipeline.Author = cm.Author.Name\n\t\t\tpipeline.Email = cm.Author.Email\n\t\t\tpipeline.Message = cm.Message\n\t\t\tpipeline.Timestamp = cm.Timestamp.Unix()\n\t\t\tif len(pipeline.Email) != 0 {\n\t\t\t\tpipeline.Avatar = getUserAvatar(pipeline.Email)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn repo, pipeline, hook.After, nil\n}\n\nfunc convertReleaseHook(hook *gitlab.ReleaseEvent) (*model.Repo, *model.Pipeline, error) {\n\trepo := &model.Repo{}\n\n\tvar err error\n\tif repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\trepo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.Project.ID))\n\trepo.Avatar = \"\"\n\tif hook.Project.AvatarURL != nil {\n\t\trepo.Avatar = *hook.Project.AvatarURL\n\t}\n\trepo.ForgeURL = hook.Project.WebURL\n\trepo.Clone = hook.Project.GitHTTPURL\n\trepo.CloneSSH = hook.Project.GitSSHURL\n\trepo.FullName = hook.Project.PathWithNamespace\n\trepo.Branch = hook.Project.DefaultBranch\n\n\t// Release events expose visibility as a numeric level (unlike push/tag\n\t// which omit it from the payload entirely). Map it to both Visibility\n\t// and IsSCMPrivate so model.Repo.Update() will propagate the value.\n\tswitch hook.Project.VisibilityLevel {\n\tcase visibilityLevelPrivate:\n\t\trepo.Visibility = model.VisibilityPrivate\n\t\trepo.IsSCMPrivate = true\n\tcase visibilityLevelInternal:\n\t\trepo.Visibility = model.VisibilityInternal\n\t\trepo.IsSCMPrivate = true\n\tcase visibilityLevelPublic:\n\t\trepo.Visibility = model.VisibilityPublic\n\t\trepo.IsSCMPrivate = false\n\t}\n\n\tpipeline := &model.Pipeline{\n\t\tEvent:    model.EventRelease,\n\t\tCommit:   hook.Commit.ID,\n\t\tForgeURL: hook.URL,\n\t\tMessage:  fmt.Sprintf(\"created release %s\", hook.Name),\n\t\tSender:   hook.Commit.Author.Name,\n\t\tAuthor:   hook.Commit.Author.Name,\n\t\tEmail:    hook.Commit.Author.Email,\n\n\t\t// Tag name here is the ref. We should add the refs/tags, so\n\t\t// it is known it's a tag (git-plugin looks for it)\n\t\tRef: \"refs/tags/\" + hook.Tag,\n\t}\n\tif len(pipeline.Email) != 0 {\n\t\tpipeline.Avatar = getUserAvatar(pipeline.Email)\n\t}\n\n\treturn repo, pipeline, nil\n}\n\nfunc getUserAvatar(email string) string {\n\thasher := md5.New()\n\thasher.Write([]byte(email))\n\n\treturn fmt.Sprintf(\n\t\t\"%s/%v.jpg?s=%s\",\n\t\tgravatarBase,\n\t\thex.EncodeToString(hasher.Sum(nil)),\n\t\t\"128\",\n\t)\n}\n\n// extractFromPath splits a repository path string into owner and name components.\n// It requires at least two path components, otherwise an error is returned.\nfunc extractFromPath(str string) (string, string, error) {\n\tconst minPathComponents = 2\n\n\ts := strings.Split(str, \"/\")\n\tif len(s) < minPathComponents {\n\t\treturn \"\", \"\", fmt.Errorf(\"minimum match not found\")\n\t}\n\towner := strings.Join(s[:len(s)-1], \"/\")\n\tname := s[len(s)-1]\n\treturn owner, name, nil\n}\n\nfunc convertLabels(from []*gitlab.EventLabel) []string {\n\tlabels := make([]string, len(from))\n\tfor i, label := range from {\n\t\tlabels[i] = label.Title\n\t}\n\treturn labels\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestApproved.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 2251488,\n    \"name\": \"Anbraten\",\n    \"username\": \"anbraten\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n    \"email\": \"some@mail.info\"\n  },\n  \"project\": {\n    \"id\": 32059612,\n    \"name\": \"woodpecker\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n    \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n    \"namespace\": \"Anbraten\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"anbraten/woodpecker\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 2251488,\n    \"author_id\": 2251488,\n    \"created_at\": \"2022-01-10 15:23:41 UTC\",\n    \"description\": \"\",\n    \"head_pipeline_id\": 449733536,\n    \"id\": 134400602,\n    \"iid\": 3,\n    \"last_edited_at\": \"2022-01-17 15:46:23 UTC\",\n    \"last_edited_by_id\": 2251488,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"1\"\n    },\n    \"merge_status\": \"unchecked\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"anbraten-main-patch-05373\",\n    \"source_project_id\": 32059612,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 32059612,\n    \"time_estimate\": 0,\n    \"title\": \"Update client.go 🎉\",\n    \"updated_at\": \"2022-01-17 15:47:39 UTC\",\n    \"updated_by_id\": 2251488,\n    \"url\": \"https://gitlab.com/anbraten/woodpecker/-/merge_requests/3\",\n    \"source\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"target\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"last_commit\": {\n      \"id\": \"c136499ec574e1034b24c5d306de9acda3005367\",\n      \"message\": \"Update folder/todo.txt\",\n      \"title\": \"Update folder/todo.txt\",\n      \"timestamp\": \"2022-01-17T15:47:38+00:00\",\n      \"url\": \"https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367\",\n      \"author\": {\n        \"name\": \"Anbraten\",\n        \"email\": \"some@mail.info\"\n      }\n    },\n    \"work_in_progress\": false,\n    \"total_time_spent\": 0,\n    \"time_change\": 0,\n    \"human_total_time_spent\": null,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"assignee_ids\": [2251488],\n    \"state\": \"opened\",\n    \"blocking_discussions_resolved\": true,\n    \"action\": \"approved\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2022-01-17 15:46:23 UTC\",\n      \"current\": \"2022-01-17 15:47:39 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"description\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 2251488,\n      \"name\": \"Anbraten\",\n      \"username\": \"anbraten\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n      \"email\": \"some@mail.info\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestAssigned.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 4575606,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:23:04 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [4575606],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:21:37 UTC\",\n      \"current\": \"2025-08-06 01:23:04 UTC\"\n    },\n    \"assignees\": {\n      \"previous\": [],\n      \"current\": [\n        {\n          \"id\": 4575606,\n          \"name\": \"6543\",\n          \"username\": \"real6543\",\n          \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n          \"email\": \"[REDACTED]\"\n        }\n      ]\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 4575606,\n      \"name\": \"6543\",\n      \"username\": \"real6543\",\n      \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestClosed.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 2251488,\n    \"name\": \"Anbraten\",\n    \"username\": \"anbraten\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 32059612,\n    \"name\": \"woodpecker-test\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n    \"namespace\": \"Anbraten\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"anbraten/woodpecker-test\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 2251488,\n    \"created_at\": \"2023-12-05 18:40:22 UTC\",\n    \"description\": \"\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 268189426,\n    \"iid\": 4,\n    \"last_edited_at\": null,\n    \"last_edited_by_id\": null,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"1\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"patch-1\",\n    \"source_project_id\": 32059612,\n    \"state_id\": 2,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 32059612,\n    \"time_estimate\": 0,\n    \"title\": \"Add new file\",\n    \"updated_at\": \"2023-12-05 18:40:34 UTC\",\n    \"updated_by_id\": null,\n    \"url\": \"https://gitlab.com/anbraten/woodpecker-test/-/merge_requests/4\",\n    \"source\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker-test\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker-test\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n    },\n    \"target\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker-test\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker-test\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n    },\n    \"last_commit\": {\n      \"id\": \"3e4db3586b65dd401de8c77b3ac343fd24cbf89b\",\n      \"message\": \"Add new file\",\n      \"title\": \"Add new file\",\n      \"timestamp\": \"2023-12-05T18:39:57+00:00\",\n      \"url\": \"https://gitlab.com/anbraten/woodpecker-test/-/commit/3e4db3586b65dd401de8c77b3ac343fd24cbf89b\",\n      \"author\": {\n        \"name\": \"Anbraten\",\n        \"email\": \"[redacted]\"\n      }\n    },\n    \"work_in_progress\": false,\n    \"total_time_spent\": 0,\n    \"time_change\": 0,\n    \"human_total_time_spent\": null,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"assignee_ids\": [],\n    \"reviewer_ids\": [],\n    \"labels\": [],\n    \"state\": \"closed\",\n    \"blocking_discussions_resolved\": true,\n    \"first_contribution\": false,\n    \"detailed_merge_status\": \"not_open\",\n    \"action\": \"close\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"state_id\": {\n      \"previous\": 1,\n      \"current\": 2\n    },\n    \"updated_at\": {\n      \"previous\": \"2023-12-05 18:40:28 UTC\",\n      \"current\": \"2023-12-05 18:40:34 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"woodpecker-test\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"description\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestDemilestoned.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 29352624,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:25:34 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [29352624],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"milestone_id\": {\n      \"previous\": 6088906,\n      \"current\": null\n    },\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:24:11 UTC\",\n      \"current\": \"2025-08-06 01:25:34 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 29352624,\n      \"name\": \"demoaccount2-commits\",\n      \"username\": \"demoaccount2-commits\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestEdited.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 4575606,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-05 22:01:30 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [4575606],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"description\": {\n      \"previous\": \":tada: text that you might read eventually\",\n      \"current\": \":tada: text that you might read eventually.\"\n    },\n    \"last_edited_at\": {\n      \"previous\": null,\n      \"current\": \"2025-08-05 22:01:30 UTC\"\n    },\n    \"last_edited_by_id\": {\n      \"previous\": null,\n      \"current\": 4575606\n    },\n    \"title\": {\n      \"previous\": \"Edit README.md for more text to read\",\n      \"current\": \"Edit README for more text to read\"\n    },\n    \"updated_at\": {\n      \"previous\": \"2025-08-05 21:48:27 UTC\",\n      \"current\": \"2025-08-05 22:01:30 UTC\"\n    },\n    \"updated_by_id\": {\n      \"previous\": null,\n      \"current\": 4575606\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 4575606,\n      \"name\": \"6543\",\n      \"username\": \"real6543\",\n      \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestLabelsAdded.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:21:37 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"labels\": {\n      \"previous\": [],\n      \"current\": [\n        {\n          \"id\": 41869666,\n          \"title\": \"good first issue\",\n          \"color\": \"#7057ff\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        }\n      ]\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestLabelsCleared.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:21:37 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:20:04 UTC\",\n      \"current\": \"2025-08-06 01:21:37 UTC\"\n    },\n    \"labels\": {\n      \"previous\": [\n        {\n          \"id\": 41869665,\n          \"title\": \"enhancement\",\n          \"color\": \"#a2eeef\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        },\n        {\n          \"id\": 41869667,\n          \"title\": \"help wanted\",\n          \"color\": \"#008672\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        }\n      ],\n      \"current\": []\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestLabelsUpdated.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:20:04 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869665,\n        \"title\": \"enhancement\",\n        \"color\": \"#a2eeef\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      },\n      {\n        \"id\": 41869667,\n        \"title\": \"help wanted\",\n        \"color\": \"#008672\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869665,\n      \"title\": \"enhancement\",\n      \"color\": \"#a2eeef\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    },\n    {\n      \"id\": 41869667,\n      \"title\": \"help wanted\",\n      \"color\": \"#008672\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:18:10 UTC\",\n      \"current\": \"2025-08-06 01:20:04 UTC\"\n    },\n    \"labels\": {\n      \"previous\": [\n        {\n          \"id\": 41869663,\n          \"title\": \"documentation\",\n          \"color\": \"#0075ca\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        }\n      ],\n      \"current\": [\n        {\n          \"id\": 41869665,\n          \"title\": \"enhancement\",\n          \"color\": \"#a2eeef\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        },\n        {\n          \"id\": 41869667,\n          \"title\": \"help wanted\",\n          \"color\": \"#008672\",\n          \"project_id\": 72081820,\n          \"created_at\": \"2025-07-30 00:40:00 UTC\",\n          \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n          \"template\": false,\n          \"description\": null,\n          \"type\": \"ProjectLabel\",\n          \"group_id\": null\n        }\n      ]\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestMerged.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 2251488,\n    \"name\": \"Anbraten\",\n    \"username\": \"anbraten\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 32059612,\n    \"name\": \"woodpecker-test\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n    \"namespace\": \"Anbraten\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"anbraten/woodpecker-test\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 2251488,\n    \"created_at\": \"2023-12-05 18:40:22 UTC\",\n    \"description\": \"\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 268189426,\n    \"iid\": 4,\n    \"last_edited_at\": null,\n    \"last_edited_by_id\": null,\n    \"merge_commit_sha\": \"43411b53d670203e887c4985c4e58e8e6b7c109e\",\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"1\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"patch-1\",\n    \"source_project_id\": 32059612,\n    \"state_id\": 3,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 32059612,\n    \"time_estimate\": 0,\n    \"title\": \"Add new file\",\n    \"updated_at\": \"2023-12-05 18:43:00 UTC\",\n    \"updated_by_id\": null,\n    \"url\": \"https://gitlab.com/anbraten/woodpecker-test/-/merge_requests/4\",\n    \"source\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker-test\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker-test\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n    },\n    \"target\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker-test\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker-test\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker-test.git\"\n    },\n    \"last_commit\": {\n      \"id\": \"3e4db3586b65dd401de8c77b3ac343fd24cbf89b\",\n      \"message\": \"Add new file\",\n      \"title\": \"Add new file\",\n      \"timestamp\": \"2023-12-05T18:39:57+00:00\",\n      \"url\": \"https://gitlab.com/anbraten/woodpecker-test/-/commit/3e4db3586b65dd401de8c77b3ac343fd24cbf89b\",\n      \"author\": {\n        \"name\": \"Anbraten\",\n        \"email\": \"[redacted]\"\n      }\n    },\n    \"work_in_progress\": false,\n    \"total_time_spent\": 0,\n    \"time_change\": 0,\n    \"human_total_time_spent\": null,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"assignee_ids\": [],\n    \"reviewer_ids\": [],\n    \"labels\": [],\n    \"state\": \"merged\",\n    \"blocking_discussions_resolved\": true,\n    \"first_contribution\": false,\n    \"detailed_merge_status\": \"not_open\",\n    \"action\": \"merge\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"state_id\": {\n      \"previous\": 4,\n      \"current\": 3\n    },\n    \"updated_at\": {\n      \"previous\": \"2023-12-05 18:43:00 UTC\",\n      \"current\": \"2023-12-05 18:43:00 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"woodpecker-test\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker-test.git\",\n    \"description\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker-test\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestMilestoned.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 29352624,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:27:00 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [29352624],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"milestone_id\": {\n      \"previous\": null,\n      \"current\": 6088906\n    },\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:25:34 UTC\",\n      \"current\": \"2025-08-06 01:27:00 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 29352624,\n      \"name\": \"demoaccount2-commits\",\n      \"username\": \"demoaccount2-commits\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestOpened.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 4575606,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": null,\n    \"last_edited_by_id\": null,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"checking\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README.md for more text to read\",\n    \"updated_at\": \"2025-08-05 21:48:27 UTC\",\n    \"updated_by_id\": null,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [4575606],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"checking\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"open\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"merge_status\": {\n      \"previous\": \"preparing\",\n      \"current\": \"checking\"\n    },\n    \"updated_at\": {\n      \"previous\": \"2025-08-05 21:48:25 UTC\",\n      \"current\": \"2025-08-05 21:48:27 UTC\"\n    },\n    \"prepared_at\": {\n      \"previous\": null,\n      \"current\": \"2025-08-05 21:48:27 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 4575606,\n      \"name\": \"6543\",\n      \"username\": \"real6543\",\n      \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestReopened.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 29352663,\n    \"created_at\": \"2025-07-29 20:00:54 UTC\",\n    \"description\": \"yeaaa\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 403287663,\n    \"iid\": 1,\n    \"last_edited_at\": null,\n    \"last_edited_by_id\": null,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {},\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"6543-patch-1\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Some ned more AAAA\",\n    \"updated_at\": \"2025-08-05 14:44:26 UTC\",\n    \"updated_by_id\": null,\n    \"prepared_at\": null,\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"message\": \"Some ned more AAAA\\n\",\n      \"title\": \"Some ned more AAAA\",\n      \"timestamp\": \"2025-07-29T16:45:02+02:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [29352668],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/1\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"reopen\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"state_id\": {\n      \"previous\": 2,\n      \"current\": 1\n    },\n    \"updated_at\": {\n      \"previous\": \"2025-08-05 14:44:14 UTC\",\n      \"current\": \"2025-08-05 14:44:26 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"reviewers\": [\n    {\n      \"id\": 29352668,\n      \"name\": \"Placeholder github Source User\",\n      \"username\": \"demoaccount2commits_placeholder_6s82rp\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/6d8b40c6bd417e69e359e712b55471dfc72af88c732fed9fe8d276a210aa5dd8?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestReviewRequestDel.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:18:10 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"reviewers\": {\n      \"previous\": [\n        {\n          \"id\": 4575606,\n          \"name\": \"6543\",\n          \"username\": \"real6543\",\n          \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n          \"email\": \"[REDACTED]\"\n        }\n      ],\n      \"current\": []\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestReviewRequested.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:11:08 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [4575606],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2025-08-06 01:09:39 UTC\",\n      \"current\": \"2025-08-06 01:11:08 UTC\"\n    },\n    \"reviewers\": {\n      \"previous\": [],\n      \"current\": [\n        {\n          \"id\": 4575606,\n          \"name\": \"6543\",\n          \"username\": \"real6543\",\n          \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n          \"email\": \"[REDACTED]\"\n        }\n      ]\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"reviewers\": [\n    {\n      \"id\": 4575606,\n      \"name\": \"6543\",\n      \"username\": \"real6543\",\n      \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestUnapproved.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 29352624,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:28:57 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [29352624],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"unapproved\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {},\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 29352624,\n      \"name\": \"demoaccount2-commits\",\n      \"username\": \"demoaccount2-commits\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestUnassigned.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 29352624,\n    \"author_id\": 4575606,\n    \"created_at\": \"2025-08-05 21:48:25 UTC\",\n    \"description\": \":tada: text that you might read eventually.\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 405095454,\n    \"iid\": 3,\n    \"last_edited_at\": \"2025-08-05 22:01:30 UTC\",\n    \"last_edited_by_id\": 4575606,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"0\"\n    },\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": 6088906,\n    \"source_branch\": \"real6543-main-patch-42541\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Edit README for more text to read\",\n    \"updated_at\": \"2025-08-06 01:24:11 UTC\",\n    \"updated_by_id\": 4575606,\n    \"prepared_at\": \"2025-08-05 21:48:27 UTC\",\n    \"assignee_ids\": [29352624],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869666,\n        \"title\": \"good first issue\",\n        \"color\": \"#7057ff\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"message\": \"Edit README.md\",\n      \"title\": \"Edit README.md\",\n      \"timestamp\": \"2025-08-05T21:45:58+00:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"update\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869666,\n      \"title\": \"good first issue\",\n      \"color\": \"#7057ff\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {\n    \"assignees\": {\n      \"previous\": [\n        {\n          \"id\": 4575606,\n          \"name\": \"6543\",\n          \"username\": \"real6543\",\n          \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n          \"email\": \"[REDACTED]\"\n        }\n      ],\n      \"current\": [\n        {\n          \"id\": 29352624,\n          \"name\": \"demoaccount2-commits\",\n          \"username\": \"demoaccount2-commits\",\n          \"avatar_url\": \"https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon\",\n          \"email\": \"[REDACTED]\"\n        }\n      ]\n    }\n  },\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 29352624,\n      \"name\": \"demoaccount2-commits\",\n      \"username\": \"demoaccount2-commits\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestUnsupportedAction.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 4575606,\n    \"name\": \"6543\",\n    \"username\": \"real6543\",\n    \"avatar_url\": \"https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png\",\n    \"email\": \"[REDACTED]\"\n  },\n  \"project\": {\n    \"id\": 72081820,\n    \"name\": \"test_ci_tmp\",\n    \"description\": null,\n    \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n    \"namespace\": \"demoaccount2-commits-group\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": null,\n    \"author_id\": 29352663,\n    \"created_at\": \"2025-07-29 20:00:54 UTC\",\n    \"description\": \"yeaaa\",\n    \"draft\": false,\n    \"head_pipeline_id\": null,\n    \"id\": 403287663,\n    \"iid\": 1,\n    \"last_edited_at\": null,\n    \"last_edited_by_id\": null,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {},\n    \"merge_status\": \"can_be_merged\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"6543-patch-1\",\n    \"source_project_id\": 72081820,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 72081820,\n    \"time_estimate\": 0,\n    \"title\": \"Some ned more AAAA\",\n    \"updated_at\": \"2025-07-30 00:46:56 UTC\",\n    \"updated_by_id\": null,\n    \"prepared_at\": null,\n    \"assignee_ids\": [],\n    \"blocking_discussions_resolved\": true,\n    \"detailed_merge_status\": \"mergeable\",\n    \"first_contribution\": true,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"human_total_time_spent\": null,\n    \"labels\": [\n      {\n        \"id\": 41869663,\n        \"title\": \"documentation\",\n        \"color\": \"#0075ca\",\n        \"project_id\": 72081820,\n        \"created_at\": \"2025-07-30 00:40:00 UTC\",\n        \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n        \"template\": false,\n        \"description\": null,\n        \"type\": \"ProjectLabel\",\n        \"group_id\": null\n      }\n    ],\n    \"last_commit\": {\n      \"id\": \"36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"message\": \"Some ned more AAAA\\n\",\n      \"title\": \"Some ned more AAAA\",\n      \"timestamp\": \"2025-07-29T16:45:02+02:00\",\n      \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/36b5813240a9d2daa29b05046d56a53e18f39a3e\",\n      \"author\": {\n        \"name\": \"6543\",\n        \"email\": \"[REDACTED]\"\n      }\n    },\n    \"reviewer_ids\": [29352668],\n    \"source\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"state\": \"opened\",\n    \"target\": {\n      \"id\": 72081820,\n      \"name\": \"test_ci_tmp\",\n      \"description\": null,\n      \"web_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"git_http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\",\n      \"namespace\": \"demoaccount2-commits-group\",\n      \"visibility_level\": 0,\n      \"path_with_namespace\": \"demoaccount2-commits-group/test_ci_tmp\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\",\n      \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"ssh_url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n      \"http_url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git\"\n    },\n    \"time_change\": 0,\n    \"total_time_spent\": 0,\n    \"url\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/1\",\n    \"work_in_progress\": false,\n    \"approval_rules\": [],\n    \"action\": \"action_we_do_not_support\"\n  },\n  \"labels\": [\n    {\n      \"id\": 41869663,\n      \"title\": \"documentation\",\n      \"color\": \"#0075ca\",\n      \"project_id\": 72081820,\n      \"created_at\": \"2025-07-30 00:40:00 UTC\",\n      \"updated_at\": \"2025-07-30 00:40:00 UTC\",\n      \"template\": false,\n      \"description\": null,\n      \"type\": \"ProjectLabel\",\n      \"group_id\": null\n    }\n  ],\n  \"changes\": {},\n  \"repository\": {\n    \"name\": \"test_ci_tmp\",\n    \"url\": \"git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git\",\n    \"description\": null,\n    \"homepage\": \"https://gitlab.com/demoaccount2-commits-group/test_ci_tmp\"\n  },\n  \"reviewers\": [\n    {\n      \"id\": 29352668,\n      \"name\": \"Placeholder github Source User\",\n      \"username\": \"demoaccount2commits_placeholder_6s82rp\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/6d8b40c6bd417e69e359e712b55471dfc72af88c732fed9fe8d276a210aa5dd8?s=80&d=identicon\",\n      \"email\": \"[REDACTED]\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestUpdated.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 2251488,\n    \"name\": \"Anbraten\",\n    \"username\": \"anbraten\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n    \"email\": \"some@mail.info\"\n  },\n  \"project\": {\n    \"id\": 32059612,\n    \"name\": \"woodpecker\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n    \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n    \"namespace\": \"Anbraten\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"anbraten/woodpecker\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 2251488,\n    \"author_id\": 2251488,\n    \"created_at\": \"2022-01-10 15:23:41 UTC\",\n    \"description\": \"\",\n    \"head_pipeline_id\": 449733536,\n    \"id\": 134400602,\n    \"iid\": 3,\n    \"last_edited_at\": \"2022-01-17 15:46:23 UTC\",\n    \"last_edited_by_id\": 2251488,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"1\"\n    },\n    \"merge_status\": \"unchecked\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"anbraten-main-patch-05373\",\n    \"source_project_id\": 32059612,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 32059612,\n    \"time_estimate\": 0,\n    \"title\": \"Update client.go 🎉\",\n    \"updated_at\": \"2022-01-17 15:47:39 UTC\",\n    \"updated_by_id\": 2251488,\n    \"url\": \"https://gitlab.com/anbraten/woodpecker/-/merge_requests/3\",\n    \"source\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"target\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"last_commit\": {\n      \"id\": \"c136499ec574e1034b24c5d306de9acda3005367\",\n      \"message\": \"Update folder/todo.txt\",\n      \"title\": \"Update folder/todo.txt\",\n      \"timestamp\": \"2022-01-17T15:47:38+00:00\",\n      \"url\": \"https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367\",\n      \"author\": {\n        \"name\": \"Anbraten\",\n        \"email\": \"some@mail.info\"\n      }\n    },\n    \"work_in_progress\": false,\n    \"total_time_spent\": 0,\n    \"time_change\": 0,\n    \"human_total_time_spent\": null,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"assignee_ids\": [2251488],\n    \"state\": \"opened\",\n    \"blocking_discussions_resolved\": true,\n    \"action\": \"update\",\n    \"oldrev\": \"8b641937b7340066d882b9d8a8cc5b0573a207de\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2022-01-17 15:46:23 UTC\",\n      \"current\": \"2022-01-17 15:47:39 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"description\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 2251488,\n      \"name\": \"Anbraten\",\n      \"username\": \"anbraten\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n      \"email\": \"some@mail.info\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPullRequestWithoutChanges.json",
    "content": "{\n  \"object_kind\": \"merge_request\",\n  \"event_type\": \"merge_request\",\n  \"user\": {\n    \"id\": 2251488,\n    \"name\": \"Anbraten\",\n    \"username\": \"anbraten\",\n    \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n    \"email\": \"some@mail.info\"\n  },\n  \"project\": {\n    \"id\": 32059612,\n    \"name\": \"woodpecker\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n    \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n    \"namespace\": \"Anbraten\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"anbraten/woodpecker\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n  },\n  \"object_attributes\": {\n    \"assignee_id\": 2251488,\n    \"author_id\": 2251488,\n    \"created_at\": \"2022-01-10 15:23:41 UTC\",\n    \"description\": \"\",\n    \"head_pipeline_id\": 449733536,\n    \"id\": 134400602,\n    \"iid\": 3,\n    \"last_edited_at\": \"2022-01-17 15:46:23 UTC\",\n    \"last_edited_by_id\": 2251488,\n    \"merge_commit_sha\": null,\n    \"merge_error\": null,\n    \"merge_params\": {\n      \"force_remove_source_branch\": \"1\"\n    },\n    \"merge_status\": \"unchecked\",\n    \"merge_user_id\": null,\n    \"merge_when_pipeline_succeeds\": false,\n    \"milestone_id\": null,\n    \"source_branch\": \"anbraten-main-patch-05373\",\n    \"source_project_id\": 32059612,\n    \"state_id\": 1,\n    \"target_branch\": \"main\",\n    \"target_project_id\": 32059612,\n    \"time_estimate\": 0,\n    \"title\": \"Update client.go 🎉\",\n    \"updated_at\": \"2022-01-17 15:47:39 UTC\",\n    \"updated_by_id\": 2251488,\n    \"url\": \"https://gitlab.com/anbraten/woodpecker/-/merge_requests/3\",\n    \"source\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": null,\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"target\": {\n      \"id\": 32059612,\n      \"name\": \"woodpecker\",\n      \"description\": \"\",\n      \"web_url\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n      \"git_ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"git_http_url\": \"https://gitlab.com/anbraten/woodpecker.git\",\n      \"namespace\": \"Anbraten\",\n      \"visibility_level\": 20,\n      \"path_with_namespace\": \"anbraten/woodpecker\",\n      \"default_branch\": \"main\",\n      \"ci_config_path\": \"\",\n      \"homepage\": \"https://gitlab.com/anbraten/woodpecker\",\n      \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"ssh_url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n      \"http_url\": \"https://gitlab.com/anbraten/woodpecker.git\"\n    },\n    \"last_commit\": {\n      \"id\": \"c136499ec574e1034b24c5d306de9acda3005367\",\n      \"message\": \"Update folder/todo.txt\",\n      \"title\": \"Update folder/todo.txt\",\n      \"timestamp\": \"2022-01-17T15:47:38+00:00\",\n      \"url\": \"https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367\",\n      \"author\": {\n        \"name\": \"Anbraten\",\n        \"email\": \"some@mail.info\"\n      }\n    },\n    \"work_in_progress\": false,\n    \"total_time_spent\": 0,\n    \"time_change\": 0,\n    \"human_total_time_spent\": null,\n    \"human_time_change\": null,\n    \"human_time_estimate\": null,\n    \"assignee_ids\": [2251488],\n    \"state\": \"opened\",\n    \"blocking_discussions_resolved\": true,\n    \"action\": \"update\"\n  },\n  \"labels\": [],\n  \"changes\": {\n    \"updated_at\": {\n      \"previous\": \"2022-01-17 15:46:23 UTC\",\n      \"current\": \"2022-01-17 15:47:39 UTC\"\n    }\n  },\n  \"repository\": {\n    \"name\": \"woodpecker\",\n    \"url\": \"git@gitlab.com:anbraten/woodpecker.git\",\n    \"description\": \"\",\n    \"homepage\": \"https://gitlab.com/anbraten/woodpecker\"\n  },\n  \"assignees\": [\n    {\n      \"id\": 2251488,\n      \"name\": \"Anbraten\",\n      \"username\": \"anbraten\",\n      \"avatar_url\": \"https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon\",\n      \"email\": \"some@mail.info\"\n    }\n  ]\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookPush.json",
    "content": "{\n  \"object_kind\": \"push\",\n  \"event_name\": \"push\",\n  \"before\": \"ffe8eb4f91d1fe6bc49f1e610e50e4b5767f0104\",\n  \"after\": \"16862e368d8ab812e48833b741dad720d6e2cb7f\",\n  \"ref\": \"refs/heads/main\",\n  \"checkout_sha\": \"16862e368d8ab812e48833b741dad720d6e2cb7f\",\n  \"message\": null,\n  \"user_id\": 2,\n  \"user_name\": \"the test\",\n  \"user_username\": \"test\",\n  \"user_email\": \"\",\n  \"user_avatar\": \"https://www.gravatar.com/avatar/dd46a756faad4727fb679320751f6dea?s=80&d=identicon\",\n  \"project_id\": 2,\n  \"project\": {\n    \"id\": 2,\n    \"name\": \"Woodpecker\",\n    \"description\": \"\",\n    \"web_url\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n    \"git_ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"git_http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\",\n    \"namespace\": \"the test\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"test/woodpecker\",\n    \"default_branch\": \"develop\",\n    \"ci_config_path\": null,\n    \"homepage\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\"\n  },\n  \"commits\": [\n    {\n      \"id\": \"16862e368d8ab812e48833b741dad720d6e2cb7f\",\n      \"message\": \"Update main.go\",\n      \"title\": \"Update main.go\",\n      \"timestamp\": \"2021-09-27T04:46:14+00:00\",\n      \"url\": \"http://10.40.8.5:3200/test/woodpecker/-/commit/16862e368d8ab812e48833b741dad720d6e2cb7f\",\n      \"author\": {\n        \"name\": \"the test\",\n        \"email\": \"test@test.test\"\n      },\n      \"added\": [],\n      \"modified\": [\"cmd/cli/main.go\"],\n      \"removed\": []\n    }\n  ],\n  \"total_commits_count\": 1,\n  \"push_options\": {},\n  \"repository\": {\n    \"name\": \"Woodpecker\",\n    \"url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"description\": \"\",\n    \"homepage\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"git_http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\",\n    \"git_ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"visibility_level\": 20\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/HookTag.json",
    "content": "{\n  \"object_kind\": \"tag_push\",\n  \"event_name\": \"tag_push\",\n  \"before\": \"0000000000000000000000000000000000000000\",\n  \"after\": \"fabed3d94cd03e6c2b7958afa9569c18a24d301f\",\n  \"ref\": \"refs/tags/v22\",\n  \"checkout_sha\": \"16862e368d8ab812e48833b741dad720d6e2cb7f\",\n  \"message\": \"hi\",\n  \"user_id\": 2,\n  \"user_name\": \"the test\",\n  \"user_username\": \"test\",\n  \"user_email\": \"\",\n  \"user_avatar\": \"https://www.gravatar.com/avatar/dd46a756faad4727fb679320751f6dea?s=80&d=identicon\",\n  \"project_id\": 2,\n  \"project\": {\n    \"id\": 2,\n    \"name\": \"Woodpecker\",\n    \"description\": \"\",\n    \"web_url\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"avatar_url\": \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\",\n    \"git_ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"git_http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\",\n    \"namespace\": \"the test\",\n    \"visibility_level\": 20,\n    \"path_with_namespace\": \"test/woodpecker\",\n    \"default_branch\": \"develop\",\n    \"ci_config_path\": null,\n    \"homepage\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\"\n  },\n  \"commits\": [\n    {\n      \"id\": \"16862e368d8ab812e48833b741dad720d6e2cb7f\",\n      \"message\": \"Update main.go\",\n      \"title\": \"Update main.go\",\n      \"timestamp\": \"2021-09-27T04:46:14+00:00\",\n      \"url\": \"http://10.40.8.5:3200/test/woodpecker/-/commit/16862e368d8ab812e48833b741dad720d6e2cb7f\",\n      \"author\": {\n        \"name\": \"the test\",\n        \"email\": \"test@test.test\"\n      },\n      \"added\": [],\n      \"modified\": [\"cmd/cli/main.go\"],\n      \"removed\": []\n    }\n  ],\n  \"total_commits_count\": 1,\n  \"push_options\": {},\n  \"repository\": {\n    \"name\": \"Woodpecker\",\n    \"url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"description\": \"\",\n    \"homepage\": \"http://10.40.8.5:3200/test/woodpecker\",\n    \"git_http_url\": \"http://10.40.8.5:3200/test/woodpecker.git\",\n    \"git_ssh_url\": \"git@10.40.8.5:test/woodpecker.git\",\n    \"visibility_level\": 20\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/WebhookReleaseBody.json",
    "content": "{\n  \"id\": 4268085,\n  \"created_at\": \"2022-02-09 20:19:09 UTC\",\n  \"description\": \"new version desc\",\n  \"name\": \"Awesome version 0.0.2\",\n  \"released_at\": \"2022-02-09 20:19:09 UTC\",\n  \"tag\": \"0.0.2\",\n  \"object_kind\": \"release\",\n  \"project\": {\n    \"id\": 32521798,\n    \"name\": \"ci\",\n    \"description\": \"\",\n    \"web_url\": \"https://gitlab.com/anbratens-test/ci\",\n    \"avatar_url\": null,\n    \"git_ssh_url\": \"git@gitlab.com:anbratens-test/ci.git\",\n    \"git_http_url\": \"https://gitlab.com/anbratens-test/ci.git\",\n    \"namespace\": \"anbratens-test\",\n    \"visibility_level\": 0,\n    \"path_with_namespace\": \"anbratens-test/ci\",\n    \"default_branch\": \"main\",\n    \"ci_config_path\": \"\",\n    \"homepage\": \"https://gitlab.com/anbratens-test/ci\",\n    \"url\": \"git@gitlab.com:anbratens-test/ci.git\",\n    \"ssh_url\": \"git@gitlab.com:anbratens-test/ci.git\",\n    \"http_url\": \"https://gitlab.com/anbratens-test/ci.git\"\n  },\n  \"url\": \"https://gitlab.com/anbratens-test/ci/-/releases/0.0.2\",\n  \"action\": \"create\",\n  \"assets\": {\n    \"count\": 4,\n    \"links\": [],\n    \"sources\": [\n      {\n        \"format\": \"zip\",\n        \"url\": \"https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.zip\"\n      },\n      {\n        \"format\": \"tar.gz\",\n        \"url\": \"https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.gz\"\n      },\n      {\n        \"format\": \"tar.bz2\",\n        \"url\": \"https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.bz2\"\n      },\n      {\n        \"format\": \"tar\",\n        \"url\": \"https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar\"\n      }\n    ]\n  },\n  \"commit\": {\n    \"id\": \"0b8c02955ba445ea70d22824d9589678852e2b93\",\n    \"message\": \"Initial commit\",\n    \"title\": \"Initial commit\",\n    \"timestamp\": \"2022-01-03T10:39:51+00:00\",\n    \"url\": \"https://gitlab.com/anbratens-test/ci/-/commit/0b8c02955ba445ea70d22824d9589678852e2b93\",\n    \"author\": {\n      \"name\": \"Anbraten\",\n      \"email\": \"2251488-anbraten@users.noreply.gitlab.com\"\n    }\n  }\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/hooks.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t_ \"embed\"\n\t\"net/http\"\n\t\"net/url\"\n)\n\nvar (\n\tServiceHookMethod = http.MethodPost\n\tServiceHookURL, _ = url.Parse(\n\t\t\"http://10.40.8.5:8000/hook?owner=test&name=woodpecker&access_token=dummyToken.\" +\n\t\t\t\"eyJ0ZXh0IjoidGVzdC93b29kcGVja2VyIiwidHlwZSI6Imhvb2sifQ.x3kPnmZtxZQ_9_eMhfQ1HSmj_SLhdT_Lu2hMczWjKh0\")\n\tServiceHookHeaders = http.Header{\n\t\t\"Content-Type\":   []string{\"application/json\"},\n\t\t\"User-Agent\":     []string{\"GitLab/14.3.0\"},\n\t\t\"X-Gitlab-Event\": []string{\"Service Hook\"},\n\t}\n\tReleaseHookHeaders = http.Header{\n\t\t\"Content-Type\":   []string{\"application/json\"},\n\t\t\"User-Agent\":     []string{\"GitLab/14.3.0\"},\n\t\t\"X-Gitlab-Event\": []string{\"Release Hook\"},\n\t}\n\tMergeRequestHookHeaders = http.Header{\n\t\t\"Content-Type\":   []string{\"application/json\"},\n\t\t\"User-Agent\":     []string{\"GitLab/18.3.0-pre\"},\n\t\t\"X-Gitlab-Event\": []string{\"Merge Request Hook\"},\n\t}\n)\n\n// HookPush is payload of a push event\n//\n//go:embed HookPush.json\nvar HookPush []byte\n\n// HookTag is payload of a TAG event\n//\n//go:embed HookTag.json\nvar HookTag []byte\n\n// HookPullRequest is payload of a PULL_REQUEST event\n//\n//go:embed HookPullRequestUpdated.json\nvar HookPullRequestUpdated []byte\n\n//go:embed HookPullRequestOpened.json\nvar HookPullRequestOpened []byte\n\n//go:embed HookPullRequestWithoutChanges.json\nvar HookPullRequestWithoutChanges []byte\n\n//go:embed HookPullRequestApproved.json\nvar HookPullRequestApproved []byte\n\n//go:embed HookPullRequestEdited.json\nvar HookPullRequestEdited []byte\n\n//go:embed HookPullRequestClosed.json\nvar HookPullRequestClosed []byte\n\n//go:embed HookPullRequestMerged.json\nvar HookPullRequestMerged []byte\n\n//go:embed WebhookReleaseBody.json\nvar WebhookReleaseBody []byte\n\n//go:embed HookPullRequestReopened.json\nvar HookPullRequestReopened []byte\n\n//go:embed HookPullRequestUnsupportedAction.json\nvar HookPullRequestUnsupportedAction []byte\n\n//go:embed HookPullRequestReviewRequested.json\nvar HookPullRequestReviewRequested []byte\n\n//go:embed HookPullRequestReviewRequestDel.json\nvar HookPullRequestReviewRequestDel []byte\n\n//go:embed HookPullRequestAssigned.json\nvar HookPullRequestAssigned []byte\n\n//go:embed HookPullRequestDemilestoned.json\nvar HookPullRequestDemilestoned []byte\n\n//go:embed HookPullRequestLabelsAdded.json\nvar HookPullRequestLabelsAdded []byte\n\n//go:embed HookPullRequestLabelsCleared.json\nvar HookPullRequestLabelsCleared []byte\n\n//go:embed HookPullRequestLabelsUpdated.json\nvar HookPullRequestLabelsUpdated []byte\n\n//go:embed HookPullRequestMilestoned.json\nvar HookPullRequestMilestoned []byte\n\n//go:embed HookPullRequestUnapproved.json\nvar HookPullRequestUnapproved []byte\n\n//go:embed HookPullRequestUnassigned.json\nvar HookPullRequestUnassigned []byte\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/oauth.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nvar accessTokenPayload = []byte(`access_token=sekret&scope=api&token_type=bearer`)\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/projects.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\n// sample repository list.\nvar allProjectsPayload = []byte(`\n[\n\t{\n\t\t\"id\": 4,\n\t\t\"description\": null,\n\t\t\"default_branch\": \"main\",\n\t\t\"public\": false,\n\t\t\"visibility\": \"private\",\n\t\t\"ssh_url_to_repo\": \"git@example.com:diaspora/diaspora-client.git\",\n\t\t\"http_url_to_repo\": \"http://example.com/diaspora/diaspora-client.git\",\n\t\t\"web_url\": \"http://example.com/diaspora/diaspora-client\",\n\t\t\"owner\": {\n\t\t\t\"id\": 3,\n\t\t\t\"name\": \"Diaspora\",\n\t\t\t\"username\": \"some_user\",\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"name\": \"Diaspora Client\",\n\t\t\"name_with_namespace\": \"Diaspora / Diaspora Client\",\n\t\t\"path\": \"diaspora-client\",\n\t\t\"path_with_namespace\": \"diaspora/diaspora-client\",\n\t\t\"issues_enabled\": true,\n\t\t\"merge_requests_enabled\": true,\n\t\t\"wiki_enabled\": true,\n\t\t\"snippets_enabled\": false,\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"last_activity_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"namespace\": {\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\t\"description\": \"\",\n\t\t\t\"id\": 3,\n\t\t\t\"name\": \"Diaspora\",\n\t\t\t\"owner_id\": 1,\n\t\t\t\"path\": \"diaspora\",\n\t\t\t\"updated_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"archived\": false,\n\t\t\"permissions\": {\n\t\t\t\"project_access\": {\n\t\t\t\t\"access_level\": 10,\n\t\t\t\t\"notification_level\": 3\n\t\t\t},\n\t\t\t\"group_access\": {\n\t\t\t\t\"access_level\": 50,\n\t\t\t\t\"notification_level\": 3\n\t\t\t}\n\t\t}\n\t},\n\t{\n\t\t\"id\": 6,\n\t\t\"description\": null,\n\t\t\"default_branch\": \"main\",\n\t\t\"public\": false,\n\t\t\"visibility\": \"private\",\n\t\t\"ssh_url_to_repo\": \"git@example.com:brightbox/puppet.git\",\n\t\t\"http_url_to_repo\": \"http://example.com/brightbox/puppet.git\",\n\t\t\"web_url\": \"http://example.com/brightbox/puppet\",\n\t\t\"owner\": {\n\t\t\t\"id\": 1,\n\t\t\t\"name\": \"Brightbox\",\n\t\t\t\"username\": \"test_user\",\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"name\": \"Puppet\",\n\t\t\"name_with_namespace\": \"Brightbox / Puppet\",\n\t\t\"path\": \"puppet\",\n\t\t\"path_with_namespace\": \"brightbox/puppet\",\n\t\t\"issues_enabled\": true,\n\t\t\"merge_requests_enabled\": true,\n\t\t\"wiki_enabled\": true,\n\t\t\"snippets_enabled\": false,\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"last_activity_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"namespace\": {\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\t\"description\": \"\",\n\t\t\t\"id\": 4,\n\t\t\t\"name\": \"Brightbox\",\n\t\t\t\"owner_id\": 1,\n\t\t\t\"path\": \"brightbox\",\n\t\t\t\"updated_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"archived\": true,\n\t\t\"permissions\": {\n\t\t\t\"project_access\": {\n\t\t\t\t\"access_level\": 10,\n\t\t\t\t\"notification_level\": 3\n\t\t\t},\n\t\t\t\"group_access\": {\n\t\t\t\t\"access_level\": 50,\n\t\t\t\t\"notification_level\": 3\n\t\t\t}\n\t\t}\n\t}\n]\n`)\n\nvar notArchivedProjectsPayload = []byte(`\n[\n\t{\n\t\t\"id\": 4,\n\t\t\"description\": null,\n\t\t\"default_branch\": \"main\",\n\t\t\"public\": false,\n\t\t\"visibility\": \"private\",\n\t\t\"ssh_url_to_repo\": \"git@example.com:diaspora/diaspora-client.git\",\n\t\t\"http_url_to_repo\": \"http://example.com/diaspora/diaspora-client.git\",\n\t\t\"web_url\": \"http://example.com/diaspora/diaspora-client\",\n\t\t\"owner\": {\n\t\t\t\"id\": 3,\n\t\t\t\"name\": \"Diaspora\",\n\t\t\t\"username\": \"some_user\",\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"name\": \"Diaspora Client\",\n\t\t\"name_with_namespace\": \"Diaspora / Diaspora Client\",\n\t\t\"path\": \"diaspora-client\",\n\t\t\"path_with_namespace\": \"diaspora/diaspora-client\",\n\t\t\"issues_enabled\": true,\n\t\t\"merge_requests_enabled\": true,\n\t\t\"wiki_enabled\": true,\n\t\t\"snippets_enabled\": false,\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"last_activity_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"namespace\": {\n\t\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\t\"description\": \"\",\n\t\t\t\"id\": 3,\n\t\t\t\"name\": \"Diaspora\",\n\t\t\t\"owner_id\": 1,\n\t\t\t\"path\": \"diaspora\",\n\t\t\t\"updated_at\": \"2013-09-30T13:46:02Z\"\n\t\t},\n\t\t\"archived\": false,\n\t\t\"permissions\": {\n\t\t\t\"project_access\": {\n\t\t\t\t\"access_level\": 10,\n\t\t\t\t\"notification_level\": 3\n\t\t\t},\n\t\t\t\"group_access\": {\n\t\t\t\t\"access_level\": 50,\n\t\t\t\t\"notification_level\": 3\n\t\t\t}\n\t\t}\n\t}\n]\n`)\n\nvar project4Payload = []byte(`\n{\n\t\"id\": 4,\n\t\"description\": null,\n\t\"default_branch\": \"main\",\n\t\"public\": false,\n\t\"visibility\": \"private\",\n\t\"ssh_url_to_repo\": \"git@example.com:diaspora/diaspora-client.git\",\n\t\"http_url_to_repo\": \"http://example.com/diaspora/diaspora-client.git\",\n\t\"web_url\": \"http://example.com/diaspora/diaspora-client\",\n\t\"owner\": {\n\t\t\"id\": 3,\n\t\t\"name\": \"Diaspora\",\n\t\t\"username\": \"some_user\",\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\"\n\t},\n\t\"name\": \"Diaspora Client\",\n\t\"name_with_namespace\": \"Diaspora / Diaspora Client\",\n\t\"path\": \"diaspora-client\",\n\t\"path_with_namespace\": \"diaspora/diaspora-client\",\n\t\"issues_enabled\": true,\n\t\"merge_requests_enabled\": true,\n\t\"wiki_enabled\": true,\n\t\"snippets_enabled\": false,\n\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\"last_activity_at\": \"2013-09-30T13:46:02Z\",\n\t\"namespace\": {\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"description\": \"\",\n\t\t\"id\": 3,\n\t\t\"name\": \"Diaspora\",\n\t\t\"owner_id\": 1,\n\t\t\"path\": \"diaspora\",\n\t\t\"updated_at\": \"2013-09-30T13:46:02Z\"\n\t},\n\t\"archived\": false,\n\t\"permissions\": {\n\t\t\"project_access\": {\n\t\t\t\"access_level\": 10,\n\t\t\t\"notification_level\": 3\n\t\t},\n\t\t\"group_access\": {\n\t\t\t\"access_level\": 50,\n\t\t\t\"notification_level\": 3\n\t\t}\n\t}\n}\n`)\n\nvar project6Payload = []byte(`\n{\n\t\"id\": 6,\n\t\"description\": null,\n\t\"default_branch\": \"main\",\n\t\"public\": false,\n\t\"visibility\": \"private\",\n\t\"ssh_url_to_repo\": \"git@example.com:brightbox/puppet.git\",\n\t\"http_url_to_repo\": \"http://example.com/brightbox/puppet.git\",\n\t\"web_url\": \"http://example.com/brightbox/puppet\",\n\t\"owner\": {\n\t\t\"id\": 1,\n\t\t\"name\": \"Brightbox\",\n\t\t\"username\": \"test_user\",\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\"\n\t},\n\t\"name\": \"Puppet\",\n\t\"name_with_namespace\": \"Brightbox / Puppet\",\n\t\"path\": \"puppet\",\n\t\"path_with_namespace\": \"brightbox/puppet\",\n\t\"issues_enabled\": true,\n\t\"merge_requests_enabled\": true,\n\t\"wiki_enabled\": true,\n\t\"snippets_enabled\": false,\n\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\"last_activity_at\": \"2013-09-30T13:46:02Z\",\n\t\"namespace\": {\n\t\t\"created_at\": \"2013-09-30T13:46:02Z\",\n\t\t\"description\": \"\",\n\t\t\"id\": 4,\n\t\t\"name\": \"Brightbox\",\n\t\t\"owner_id\": 1,\n\t\t\"path\": \"brightbox\",\n\t\t\"updated_at\": \"2013-09-30T13:46:02Z\"\n\t},\n\t\"archived\": false,\n\t\"permissions\": {\n\t\t\"project_access\": null,\n\t\t\"group_access\": null\n\t}\n}\n`)\n\nvar project4PayloadHook = []byte(`\n{\n\t\"id\": 10717088,\n\t\"url\": \"http://example.com/api/hook\",\n\t\"created_at\": \"2021-12-18T23:29:33.852Z\",\n\t\"push_events\": true,\n\t\"tag_push_events\": true,\n\t\"merge_requests_events\": true,\n\t\"repository_update_events\": false,\n\t\"enable_ssl_verification\": true,\n\t\"project_id\": 4,\n\t\"issues_events\": false,\n\t\"confidential_issues_events\": false,\n\t\"note_events\": false,\n\t\"confidential_note_events\": null,\n\t\"pipeline_events\": false,\n\t\"wiki_page_events\": false,\n\t\"deployment_events\": true,\n\t\"job_events\": false,\n\t\"releases_events\": false,\n\t\"push_events_branch_filter\": null\n}\n`)\n\nvar project4PayloadHooks = []byte(`\n[\n\t{\n\t\t\"id\": 10717088,\n\t\t\"url\": \"http://example.com/api/hook\",\n\t\t\"created_at\": \"2021-12-18T23:29:33.852Z\",\n\t\t\"push_events\": true,\n\t\t\"tag_push_events\": true,\n\t\t\"merge_requests_events\": true,\n\t\t\"repository_update_events\": false,\n\t\t\"enable_ssl_verification\": true,\n\t\t\"project_id\": 4,\n\t\t\"issues_events\": false,\n\t\t\"confidential_issues_events\": false,\n\t\t\"note_events\": false,\n\t\t\"confidential_note_events\": null,\n\t\t\"pipeline_events\": false,\n\t\t\"wiki_page_events\": false,\n\t\t\"deployment_events\": true,\n\t\t\"job_events\": false,\n\t\t\"releases_events\": false,\n\t\t\"push_events_branch_filter\": null\n\t}\n]\n`)\n\nvar project4PayloadMembers = []byte(`\n{\n\t\"id\": 3,\n\t\"username\": \"some_user\",\n\t\"name\": \"Diaspora\",\n\t\"state\": \"active\",\n\t\"locked\": false,\n\t\"avatar_url\": \"https://example.com/uploads/-/system/user/avatar/3/avatar.png\",\n\t\"web_url\": \"https://example.com/some_user\",\n\t\"access_level\": 50,\n\t\"created_at\": \"2024-01-16T12:39:58.912Z\",\n\t\"expires_at\": null\n}\n`)\n\nvar project6PayloadMembers = []byte(`\n{\n\t\"id\": 3,\n\t\"username\": \"some_user\",\n\t\"name\": \"Diaspora\",\n\t\"state\": \"active\",\n\t\"locked\": false,\n\t\"avatar_url\": \"https://example.com/uploads/-/system/user/avatar/3/avatar.png\",\n\t\"web_url\": \"https://example.com/some_user\",\n\t\"access_level\": 30,\n\t\"created_at\": \"2024-01-16T12:39:58.912Z\",\n\t\"expires_at\": null\n}\n`)\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/testdata.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\n// NewServer setup a mock server for testing purposes.\nfunc NewServer(t *testing.T) *httptest.Server {\n\tmux := http.NewServeMux()\n\tserver := httptest.NewServer(mux)\n\n\t// handle requests and serve mock data\n\tmux.HandleFunc(\"/\", func(w http.ResponseWriter, r *http.Request) {\n\t\tt.Logf(\"gitlab forge mock server: [%s] %s\", r.Method, r.URL.Path)\n\t\t// evaluate the path to serve a dummy data file\n\n\t\t// TODO: find source of \"/api/v4/\" requests\n\t\t// assert.EqualValues(t, \"go-gitlab\", r.Header.Get(\"user-agent\"), \"on request: \"+r.URL.Path)\n\n\t\tswitch r.URL.Path {\n\t\tcase \"/api/v4/projects\":\n\t\t\tif r.FormValue(\"archived\") == \"false\" {\n\t\t\t\t_, _ = w.Write(notArchivedProjectsPayload)\n\t\t\t} else {\n\t\t\t\t_, _ = w.Write(allProjectsPayload)\n\t\t\t}\n\n\t\t\treturn\n\t\tcase \"/api/v4/projects/diaspora/diaspora-client\":\n\t\t\t_, _ = w.Write(project4Payload)\n\t\t\treturn\n\t\tcase \"/api/v4/projects/brightbox/puppet\":\n\t\tcase \"/api/v4/projects/6\":\n\t\t\t_, _ = w.Write(project6Payload)\n\t\t\treturn\n\t\tcase \"/api/v4/projects/4/hooks\":\n\t\t\tswitch r.Method {\n\t\t\tcase http.MethodGet:\n\t\t\t\t_, _ = w.Write(project4PayloadHooks)\n\t\t\tcase http.MethodPost:\n\t\t\t\t_, _ = w.Write(project4PayloadHook)\n\t\t\t\tw.WriteHeader(201)\n\t\t\t}\n\t\t\treturn\n\t\tcase \"/api/v4/projects/4/hooks/10717088\":\n\t\t\tw.WriteHeader(201)\n\t\t\treturn\n\t\tcase \"/api/v4/projects/4/members/all/3\":\n\t\t\t_, _ = w.Write(project4PayloadMembers)\n\t\t\treturn\n\t\tcase \"/api/v4/projects/diaspora/diaspora-client/members/all/3\":\n\t\t\t_, _ = w.Write(project4PayloadMembers)\n\t\t\treturn\n\t\tcase \"/api/v4/projects/6/members/all/3\":\n\t\t\t_, _ = w.Write(project6PayloadMembers)\n\t\t\treturn\n\t\tcase \"/oauth/token\":\n\t\t\t_, _ = w.Write(accessTokenPayload)\n\t\t\treturn\n\t\tcase \"/api/v4/user\":\n\t\t\t_, _ = w.Write(currentUserPayload)\n\t\t\treturn\n\t\t}\n\n\t\t// else return a 404\n\t\thttp.NotFound(w, r)\n\t})\n\n\t// return the server to the client which\n\t// will need to know the base URL path\n\treturn server\n}\n"
  },
  {
    "path": "server/forge/gitlab/fixtures/users.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fixtures\n\nvar currentUserPayload = []byte(`\n{\n\t\"id\": 1,\n\t\"username\": \"john_smith\",\n\t\"email\": \"john@example.com\",\n\t\"name\": \"John Smith\",\n\t\"private_token\": \"dd34asd13as\",\n\t\"state\": \"active\",\n\t\"created_at\": \"2012-05-23T08:00:58Z\",\n\t\"bio\": null,\n\t\"skype\": \"\",\n\t\"linkedin\": \"\",\n\t\"twitter\": \"\",\n\t\"website_url\": \"\",\n\t\"theme_id\": 1,\n\t\"color_scheme_id\": 2,\n\t\"is_admin\": false,\n\t\"can_create_group\": true,\n\t\"can_create_project\": true,\n\t\"projects_limit\": 100\n}\n`)\n"
  },
  {
    "path": "server/forge/gitlab/gitlab.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitlab\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\tgitlab \"gitlab.com/gitlab-org/api/client-go/v2\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/common\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nconst (\n\tdefaultScope   = \"api\"\n\tdefaultPerPage = 100\n)\n\n// Opts defines configuration options.\ntype Opts struct {\n\tURL               string // Gitlab server url.\n\tOAuthClientID     string // Oauth2 client id.\n\tOAuthClientSecret string // Oauth2 client secret.\n\tSkipVerify        bool   // Skip ssl verification.\n\tOAuthHost         string // Public url for oauth if different from url.\n}\n\n// Gitlab implements \"Forge\" interface.\ntype GitLab struct {\n\tid                int64\n\turl               string\n\toAuthClientID     string\n\toAuthClientSecret string\n\tskipVerify        bool\n\thideArchives      bool\n\tsearch            bool\n\toAuthHost         string\n}\n\n// New returns a Forge implementation that integrates with Gitlab, an open\n// source Git service. See https://gitlab.com\nfunc New(id int64, opts Opts) (forge.Forge, error) {\n\treturn &GitLab{\n\t\tid:                id,\n\t\turl:               opts.URL,\n\t\toAuthClientID:     opts.OAuthClientID,\n\t\toAuthClientSecret: opts.OAuthClientSecret,\n\t\toAuthHost:         opts.OAuthHost,\n\t\tskipVerify:        opts.SkipVerify,\n\t\thideArchives:      true,\n\t}, nil\n}\n\n// Name returns the string name of this driver.\nfunc (g *GitLab) Name() string {\n\treturn \"gitlab\"\n}\n\n// URL returns the root url of a configured forge.\nfunc (g *GitLab) URL() string {\n\treturn g.url\n}\n\nfunc (g *GitLab) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) {\n\tpublicOAuthURL := g.oAuthHost\n\tif publicOAuthURL == \"\" {\n\t\tpublicOAuthURL = g.url\n\t}\n\n\treturn &oauth2.Config{\n\t\t\tClientID:     g.oAuthClientID,\n\t\t\tClientSecret: g.oAuthClientSecret,\n\t\t\tEndpoint: oauth2.Endpoint{\n\t\t\t\tAuthURL:  fmt.Sprintf(\"%s/oauth/authorize\", publicOAuthURL),\n\t\t\t\tTokenURL: fmt.Sprintf(\"%s/oauth/token\", g.url),\n\t\t\t},\n\t\t\tScopes:      []string{defaultScope},\n\t\t\tRedirectURL: fmt.Sprintf(\"%s/authorize\", server.Config.Server.OAuthHost),\n\t\t},\n\n\t\tcontext.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: g.skipVerify},\n\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t}})\n}\n\n// Login authenticates the session and returns the\n// forge user details.\nfunc (g *GitLab) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {\n\tconfig, oauth2Ctx := g.oauth2Config(ctx)\n\tredirectURL := config.AuthCodeURL(req.State)\n\n\t// check the OAuth code\n\tif len(req.Code) == 0 {\n\t\treturn nil, redirectURL, nil\n\t}\n\n\ttoken, err := config.Exchange(oauth2Ctx, req.Code)\n\tif err != nil {\n\t\treturn nil, redirectURL, fmt.Errorf(\"error exchanging token: %w\", err)\n\t}\n\n\tclient, err := newClient(g.url, token.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tlogin, _, err := client.Users.CurrentUser(gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, redirectURL, err\n\t}\n\n\tuser := &model.User{\n\t\tLogin:         login.Username,\n\t\tEmail:         login.Email,\n\t\tAvatar:        login.AvatarURL,\n\t\tForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(login.ID)),\n\t\tAccessToken:   token.AccessToken,\n\t\tRefreshToken:  token.RefreshToken,\n\t\tExpiry:        token.Expiry.UTC().Unix(),\n\t}\n\tif !strings.HasPrefix(user.Avatar, \"http\") {\n\t\tuser.Avatar = g.url + \"/\" + login.AvatarURL\n\t}\n\n\treturn user, redirectURL, nil\n}\n\n// Refresh refreshes the Gitlab oauth2 access token. If the token is\n// refreshed the user is updated and a true value is returned.\nfunc (g *GitLab) Refresh(ctx context.Context, user *model.User) (bool, error) {\n\tconfig, oauth2Ctx := g.oauth2Config(ctx)\n\tconfig.RedirectURL = \"\"\n\n\tsource := config.TokenSource(oauth2Ctx, &oauth2.Token{\n\t\tAccessToken:  user.AccessToken,\n\t\tRefreshToken: user.RefreshToken,\n\t\tExpiry:       time.Unix(user.Expiry, 0),\n\t})\n\n\ttoken, err := source.Token()\n\tif err != nil || len(token.AccessToken) == 0 {\n\t\treturn false, err\n\t}\n\n\tuser.AccessToken = token.AccessToken\n\tuser.RefreshToken = token.RefreshToken\n\tuser.Expiry = token.Expiry.UTC().Unix()\n\treturn true, nil\n}\n\n// Teams fetches a list of team memberships from the forge.\nfunc (g *GitLab) Teams(ctx context.Context, user *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tperPage := min(p.PerPage, defaultPerPage)\n\n\tgroups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    int64(p.Page),\n\t\t\tPerPage: int64(perPage),\n\t\t},\n\t\tAllAvailable:   gitlab.Ptr(false),\n\t\tMinAccessLevel: gitlab.Ptr(gitlab.DeveloperPermissions), // TODO: check what's best here\n\t}, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tteams := make([]*model.Team, 0, len(groups))\n\tfor i := range groups {\n\t\tteams = append(teams, &model.Team{\n\t\t\tLogin:  groups[i].Name,\n\t\t\tAvatar: groups[i].AvatarURL,\n\t\t},\n\t\t)\n\t}\n\n\treturn teams, nil\n}\n\n// getProject fetches the named repository from the forge.\nfunc (g *GitLab) getProject(ctx context.Context, client *gitlab.Client, forgeRemoteID model.ForgeRemoteID, owner, name string) (*gitlab.Project, error) {\n\tvar (\n\t\trepo *gitlab.Project\n\t\terr  error\n\t)\n\n\tif forgeRemoteID.IsValid() {\n\t\tintID, err := strconv.Atoi(string(forgeRemoteID))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trepo, resp, err := client.Projects.GetProject(intID, nil, gitlab.WithContext(ctx))\n\t\tif err != nil && resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t\t}\n\t\treturn repo, err\n\t}\n\n\trepo, resp, err := client.Projects.GetProject(fmt.Sprintf(\"%s/%s\", owner, name), nil, gitlab.WithContext(ctx))\n\tif err != nil && resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, forge_types.ErrRepoNotFound)\n\t}\n\treturn repo, err\n}\n\nfunc (g *GitLab) getInheritedProjectMember(ctx context.Context, client *gitlab.Client, forgeRemoteID model.ForgeRemoteID, owner, name string, userID int64) (*gitlab.ProjectMember, error) {\n\tif forgeRemoteID.IsValid() {\n\t\tintID, err := strconv.Atoi(string(forgeRemoteID))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tprojectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(intID, userID, gitlab.WithContext(ctx))\n\t\treturn projectMember, err\n\t}\n\n\tprojectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(fmt.Sprintf(\"%s/%s\", owner, name), userID, gitlab.WithContext(ctx))\n\treturn projectMember, err\n}\n\n// Repo fetches the repository from the forge.\nfunc (g *GitLab) Repo(ctx context.Context, user *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, remoteID, owner, name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tintUserID, err := strconv.Atoi(string(user.ForgeRemoteID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tprojectMember, err := g.getInheritedProjectMember(ctx, client, remoteID, owner, name, int64(intUserID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn g.convertGitLabRepo(_repo, projectMember)\n}\n\n// Repos fetches a list of repos from the forge.\nfunc (g *GitLab) Repos(ctx context.Context, user *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tperPage := min(p.PerPage, defaultPerPage)\n\n\topts := &gitlab.ListProjectsOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    int64(p.Page),\n\t\t\tPerPage: int64(perPage),\n\t\t},\n\t\tMinAccessLevel: gitlab.Ptr(gitlab.DeveloperPermissions), // TODO: check what's best here\n\t}\n\tif g.hideArchives {\n\t\topts.Archived = gitlab.Ptr(false)\n\t}\n\tintUserID, err := strconv.Atoi(string(user.ForgeRemoteID))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tprojects, _, err := client.Projects.ListProjects(opts, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trepos := make([]*model.Repo, 0, len(projects))\n\n\tfor i := range projects {\n\t\tprojectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(projects[i].ID, int64(intUserID), gitlab.WithContext(ctx))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\trepo, err := g.convertGitLabRepo(projects[i], projectMember)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\trepos = append(repos, repo)\n\t}\n\n\treturn repos, err\n}\n\nfunc (g *GitLab) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := newClient(g.url, token, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, r.ForgeRemoteID, r.Owner, r.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstate := \"opened\"\n\tpullRequests, _, err := client.MergeRequests.ListProjectMergeRequests(_repo.ID, &gitlab.ListProjectMergeRequestsOptions{\n\t\tListOptions: gitlab.ListOptions{Page: int64(p.Page), PerPage: int64(p.PerPage)},\n\t\tState:       &state,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make([]*model.PullRequest, len(pullRequests))\n\tfor i := range pullRequests {\n\t\tresult[i] = &model.PullRequest{\n\t\t\tIndex: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].ID))),\n\t\t\tTitle: pullRequests[i].Title,\n\t\t}\n\t}\n\treturn result, err\n}\n\n// File fetches a file from the forge repository and returns in string format.\nfunc (g *GitLab) File(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, fileName string) ([]byte, error) {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfile, resp, err := client.RepositoryFiles.GetRawFile(_repo.ID, fileName, &gitlab.GetRawFileOptions{Ref: &pipeline.Commit}, gitlab.WithContext(ctx))\n\tif resp != nil && resp.StatusCode == http.StatusNotFound {\n\t\treturn nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{fileName}})\n\t}\n\treturn file, err\n}\n\n// Dir fetches a folder from the forge repository.\nfunc (g *GitLab) Dir(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, path string) ([]*forge_types.FileMeta, error) {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiles := make([]*forge_types.FileMeta, 0, defaultPerPage)\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\topts := &gitlab.ListTreeOptions{\n\t\tListOptions: gitlab.ListOptions{PerPage: defaultPerPage},\n\t\tPath:        &path,\n\t\tRef:         &pipeline.Commit,\n\t\tRecursive:   gitlab.Ptr(false),\n\t}\n\n\tfor i := 1; true; i++ {\n\t\topts.Page = 1\n\t\tbatch, _, err := client.Repositories.ListTree(_repo.ID, opts, gitlab.WithContext(ctx))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor i := range batch {\n\t\t\tif batch[i].Type != \"blob\" { // no file\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdata, err := g.File(ctx, user, repo, pipeline, batch[i].Path)\n\t\t\tif err != nil {\n\t\t\t\tif errors.Is(err, &forge_types.ErrConfigNotFound{}) {\n\t\t\t\t\treturn nil, fmt.Errorf(\"git tree reported existence of file but we got: %s\", err.Error())\n\t\t\t\t}\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfiles = append(files, &forge_types.FileMeta{\n\t\t\t\tName: batch[i].Path,\n\t\t\t\tData: data,\n\t\t\t})\n\t\t}\n\n\t\tif len(batch) < defaultPerPage {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn files, nil\n}\n\n// Status sends the commit status back to gitlab.\nfunc (g *GitLab) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = client.Commits.SetCommitStatus(_repo.ID, pipeline.Commit, &gitlab.SetCommitStatusOptions{\n\t\tState:       getStatus(workflow.State),\n\t\tDescription: gitlab.Ptr(common.GetPipelineStatusDescription(workflow.State)),\n\t\tTargetURL:   gitlab.Ptr(common.GetPipelineStatusURL(repo, pipeline, workflow)),\n\t\tContext:     gitlab.Ptr(common.GetPipelineStatusContext(repo, pipeline, workflow)),\n\t}, gitlab.WithContext(ctx))\n\n\treturn err\n}\n\n// Netrc returns a netrc file capable of authenticating Gitlab requests and\n// cloning Gitlab repositories. The netrc will use the global machine account\n// when configured.\nfunc (g *GitLab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\tlogin := \"\"\n\ttoken := \"\"\n\n\tif u != nil {\n\t\tlogin = \"oauth2\"\n\t\ttoken = u.AccessToken\n\t}\n\n\thost, err := common.ExtractHostFromCloneURL(r.Clone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Netrc{\n\t\tLogin:    login,\n\t\tPassword: token,\n\t\tMachine:  host,\n\t\tType:     model.ForgeTypeGitlab,\n\t}, nil\n}\n\nfunc (g *GitLab) getTokenAndWebURL(link string) (token, webURL string, err error) {\n\turi, err := url.Parse(link)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\ttoken = uri.Query().Get(\"access_token\")\n\twebURL = fmt.Sprintf(\"%s://%s/%s\", uri.Scheme, uri.Host, strings.TrimPrefix(uri.Path, \"/\"))\n\treturn token, webURL, nil\n}\n\n// Activate activates a repository by adding a Post-commit hook and\n// a Public Deploy key, if applicable.\nfunc (g *GitLab) Activate(ctx context.Context, user *model.User, repo *model.Repo, link string) error {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoken, webURL, err := g.getTokenAndWebURL(link)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(token) == 0 {\n\t\treturn fmt.Errorf(\"no token found\")\n\t}\n\n\t_, _, err = client.Projects.AddProjectHook(_repo.ID, &gitlab.AddProjectHookOptions{\n\t\tURL:                   gitlab.Ptr(webURL),\n\t\tToken:                 gitlab.Ptr(token),\n\t\tPushEvents:            gitlab.Ptr(true),\n\t\tTagPushEvents:         gitlab.Ptr(true),\n\t\tMergeRequestsEvents:   gitlab.Ptr(true),\n\t\tDeploymentEvents:      gitlab.Ptr(true),\n\t\tEnableSSLVerification: gitlab.Ptr(!g.skipVerify),\n\t}, gitlab.WithContext(ctx))\n\n\treturn err\n}\n\n// Deactivate removes a repository by removing all the post-commit hooks\n// which are equal to link and removing the SSH deploy key.\nfunc (g *GitLab) Deactivate(ctx context.Context, user *model.User, repo *model.Repo, link string) error {\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, webURL, err := g.getTokenAndWebURL(link)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlistProjectHooksOptions := &gitlab.ListProjectHooksOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPerPage: defaultPerPage,\n\t\t\tPage:    1,\n\t\t},\n\t}\n\tfor {\n\t\thooks, resp, err := client.Projects.ListProjectHooks(_repo.ID, listProjectHooksOptions, gitlab.WithContext(ctx))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, hook := range hooks {\n\t\t\tif strings.Contains(hook.URL, webURL) {\n\t\t\t\t_, err = client.Projects.DeleteProjectHook(_repo.ID, hook.ID, gitlab.WithContext(ctx))\n\t\t\t\tlog.Info().Msg(fmt.Sprintf(\"successfully deleted hook with ID %d for repo %s\", hook.ID, repo.FullName))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif resp.CurrentPage >= resp.TotalPages {\n\t\t\tbreak\n\t\t}\n\n\t\t// Update the page number to get the next page\n\t\tlistProjectHooksOptions.Page = resp.NextPage\n\t}\n\n\treturn nil\n}\n\n// Branches returns the names of all branches for the named repository.\nfunc (g *GitLab) Branches(ctx context.Context, user *model.User, repo *model.Repo, p *model.ListOptions) ([]string, error) {\n\ttoken := common.UserToken(ctx, repo, user)\n\tclient, err := newClient(g.url, token, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgitlabBranches, _, err := client.Branches.ListBranches(_repo.ID,\n\t\t&gitlab.ListBranchesOptions{ListOptions: gitlab.ListOptions{Page: int64(p.Page), PerPage: int64(p.PerPage)}},\n\t\tgitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tbranches := make([]string, 0)\n\tfor _, branch := range gitlabBranches {\n\t\tbranches = append(branches, branch.Name)\n\t}\n\treturn branches, nil\n}\n\n// BranchHead returns the sha of the head (latest commit) of the specified branch.\nfunc (g *GitLab) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\ttoken := common.UserToken(ctx, r, u)\n\tclient, err := newClient(g.url, token, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, r.ForgeRemoteID, r.Owner, r.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tb, _, err := client.Branches.GetBranch(_repo.ID, branch, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &model.Commit{\n\t\tSHA:      b.Commit.ID,\n\t\tForgeURL: b.Commit.WebURL,\n\t}, nil\n}\n\n// Hook parses the post-commit hook from the Request body\n// and returns the required data in a standard format.\nfunc (g *GitLab) Hook(ctx context.Context, req *http.Request) (*model.Repo, *model.Pipeline, error) {\n\tdefer req.Body.Close()\n\tpayload, err := io.ReadAll(req.Body)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\teventType := gitlab.WebhookEventType(req)\n\tparsed, err := gitlab.ParseWebhook(eventType, payload)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tswitch event := parsed.(type) {\n\tcase *gitlab.MergeEvent:\n\t\tmergeID, milestoneID, repo, pipeline, err := convertMergeRequestHook(event, req)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif pipeline, err = g.loadMetadataFromMergeRequest(ctx, repo, pipeline, mergeID, milestoneID); err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\treturn repo, pipeline, nil\n\tcase *gitlab.PushEvent:\n\t\tif event.TotalCommitsCount == 0 {\n\t\t\treturn nil, nil, &forge_types.ErrIgnoreEvent{Event: string(eventType), Reason: \"no commits\"}\n\t\t}\n\t\treturn convertPushHook(event)\n\tcase *gitlab.TagEvent:\n\t\trepo, pipeline, cmID, err := convertTagHook(event)\n\t\tif err != nil || pipeline.Message != \"\" {\n\t\t\treturn repo, pipeline, err\n\t\t}\n\n\t\t// we have to fetch the commit message\n\t\tpipeline, err = g.loadCommitFromSHA(ctx, repo, pipeline, cmID)\n\t\treturn repo, pipeline, err\n\tcase *gitlab.ReleaseEvent:\n\t\treturn convertReleaseHook(event)\n\tdefault:\n\t\treturn nil, nil, &forge_types.ErrIgnoreEvent{Event: string(eventType)}\n\t}\n}\n\n// OrgMembership returns if user is member of organization and if user\n// is admin/owner in this organization.\nfunc (g *GitLab) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {\n\tclient, err := newClient(g.url, u.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgroups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    1,\n\t\t\tPerPage: defaultPerPage,\n\t\t},\n\t\tSearch: gitlab.Ptr(owner),\n\t}, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar gid int64\n\tfor _, group := range groups {\n\t\tif group.Name == owner {\n\t\t\tgid = group.ID\n\t\t\tbreak\n\t\t}\n\t}\n\tif gid == 0 {\n\t\treturn &model.OrgPerm{}, nil\n\t}\n\n\topts := &gitlab.ListGroupMembersOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    1,\n\t\t\tPerPage: defaultPerPage,\n\t\t},\n\t}\n\n\tfor i := 1; true; i++ {\n\t\topts.Page = int64(i)\n\t\tmembers, _, err := client.Groups.ListAllGroupMembers(gid, opts, gitlab.WithContext(ctx))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, member := range members {\n\t\t\tif member.Username == u.Login {\n\t\t\t\treturn &model.OrgPerm{Member: true, Admin: member.AccessLevel >= gitlab.OwnerPermissions}, nil\n\t\t\t}\n\t\t}\n\n\t\tif len(members) < int(opts.PerPage) {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn &model.OrgPerm{}, nil\n}\n\nfunc (g *GitLab) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {\n\tclient, err := newClient(g.url, u.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tusers, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    1,\n\t\t\tPerPage: 1,\n\t\t},\n\t\tUsername: gitlab.Ptr(owner),\n\t})\n\tif len(users) == 1 && err == nil {\n\t\treturn &model.Org{\n\t\t\tName:    users[0].Username,\n\t\t\tIsUser:  true,\n\t\t\tPrivate: users[0].PrivateProfile,\n\t\t}, nil\n\t}\n\n\tgroups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{\n\t\tListOptions: gitlab.ListOptions{\n\t\t\tPage:    1,\n\t\t\tPerPage: defaultPerPage,\n\t\t},\n\t\tSearch: gitlab.Ptr(owner),\n\t}, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar matchedGroup *gitlab.Group\n\tfor _, group := range groups {\n\t\tif group.FullPath == owner {\n\t\t\tmatchedGroup = group\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif matchedGroup == nil {\n\t\treturn nil, fmt.Errorf(\"could not find org %s\", owner)\n\t}\n\n\treturn &model.Org{\n\t\tName:    matchedGroup.FullPath,\n\t\tPrivate: matchedGroup.Visibility != gitlab.PublicVisibility,\n\t}, nil\n}\n\nfunc (g *GitLab) loadMetadataFromMergeRequest(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, mergeID, milestoneID int64) (*model.Pipeline, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn pipeline, nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(g.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tforge.Refresh(ctx, g, _store, user)\n\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tchanges, _, err := client.MergeRequests.ListMergeRequestDiffs(_repo.ID, mergeID, &gitlab.ListMergeRequestDiffsOptions{}, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfiles := make([]string, 0, len(changes)*2)\n\tfor _, file := range changes {\n\t\tfiles = append(files, file.NewPath, file.OldPath)\n\t}\n\tpipeline.ChangedFiles = utils.DeduplicateStrings(files)\n\n\tif milestoneID != 0 {\n\t\tmilestone, _, err := client.Milestones.GetMilestone(_repo.ID, milestoneID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tpipeline.PullRequestMilestone = milestone.Title\n\t}\n\n\treturn pipeline, nil\n}\n\nfunc (g *GitLab) loadCommitFromSHA(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, sha string) (*model.Pipeline, error) {\n\t_store, ok := store.TryFromContext(ctx)\n\tif !ok {\n\t\tlog.Error().Msg(\"could not get store from context\")\n\t\treturn pipeline, nil\n\t}\n\n\trepo, err := _store.GetRepoNameFallback(g.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tforge.Refresh(ctx, g, _store, user)\n\n\tclient, err := newClient(g.url, user.AccessToken, g.skipVerify)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcm, _, err := client.Commits.GetCommit(_repo.ID, sha, &gitlab.GetCommitOptions{}, gitlab.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpipeline.Author = cm.AuthorName\n\tpipeline.Email = cm.AuthorEmail\n\tpipeline.Message = cm.Message\n\tpipeline.Timestamp = cm.CommittedDate.Unix()\n\tif len(pipeline.Email) != 0 {\n\t\tpipeline.Avatar = getUserAvatar(pipeline.Email)\n\t}\n\n\treturn pipeline, nil\n}\n"
  },
  {
    "path": "server/forge/gitlab/gitlab_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitlab\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab/fixtures\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc load(config string) *GitLab {\n\t_url, _ := url.Parse(config)\n\tparams := _url.Query()\n\t_url.RawQuery = \"\"\n\n\tgitlab := GitLab{}\n\tgitlab.url = _url.String()\n\tgitlab.oAuthClientID = params.Get(\"client_id\")\n\tgitlab.oAuthClientSecret = params.Get(\"client_secret\")\n\tgitlab.skipVerify, _ = strconv.ParseBool(params.Get(\"skip_verify\"))\n\tgitlab.hideArchives, _ = strconv.ParseBool(params.Get(\"hide_archives\"))\n\n\t// this is a temp workaround\n\tgitlab.search, _ = strconv.ParseBool(params.Get(\"search\"))\n\n\treturn &gitlab\n}\n\nfunc Test_GitLab(t *testing.T) {\n\t// setup a dummy gitlab server\n\tserver := fixtures.NewServer(t)\n\tdefer server.Close()\n\n\tenv := server.URL + \"?client_id=test&client_secret=test\"\n\n\tclient := load(env)\n\n\tuser := model.User{\n\t\tLogin:         \"test_user\",\n\t\tAccessToken:   \"e3b0c44298fc1c149afbf4c8996fb\",\n\t\tForgeRemoteID: \"3\",\n\t}\n\n\trepo := model.Repo{\n\t\tName:  \"diaspora-client\",\n\t\tOwner: \"diaspora\",\n\t}\n\n\tctx := t.Context()\n\t// Test projects method\n\tt.Run(\"Should return only non-archived projects is hidden\", func(t *testing.T) {\n\t\tclient.hideArchives = true\n\t\t_projects, err := client.Repos(ctx, &user, &model.ListOptions{Page: 1, PerPage: 10})\n\t\tassert.NoError(t, err)\n\t\tassert.Len(t, _projects, 1)\n\t})\n\n\tt.Run(\"Should return all the projects\", func(t *testing.T) {\n\t\tclient.hideArchives = false\n\t\t_projects, err := client.Repos(ctx, &user, &model.ListOptions{Page: 1, PerPage: 10})\n\n\t\tassert.NoError(t, err)\n\t\tassert.Len(t, _projects, 2)\n\t})\n\n\t// Test repository method\n\tt.Run(\"Should return valid repo\", func(t *testing.T) {\n\t\t_repo, err := client.Repo(ctx, &user, \"0\", \"diaspora\", \"diaspora-client\")\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"diaspora-client\", _repo.Name)\n\t\tassert.Equal(t, \"diaspora\", _repo.Owner)\n\t\tassert.True(t, _repo.IsSCMPrivate)\n\t})\n\n\tt.Run(\"Should return error, when repo not exist\", func(t *testing.T) {\n\t\t_, err := client.Repo(ctx, &user, \"0\", \"not-existed\", \"not-existed\")\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"Should return repo with push access, when user inherits membership from namespace\", func(t *testing.T) {\n\t\t_repo, err := client.Repo(ctx, &user, \"6\", \"brightbox\", \"puppet\")\n\t\tassert.NoError(t, err)\n\t\tassert.True(t, _repo.Perm.Push)\n\t})\n\n\t// Test activate method\n\tt.Run(\"Activate, success\", func(t *testing.T) {\n\t\terr := client.Activate(ctx, &user, &repo, \"http://example.com/api/hook?access_token=token\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"Activate, failed no token\", func(t *testing.T) {\n\t\terr := client.Activate(ctx, &user, &repo, \"http://example.com/api/hook\")\n\n\t\tassert.Error(t, err)\n\t})\n\n\t// Test deactivate method\n\tt.Run(\"Deactivate\", func(t *testing.T) {\n\t\terr := client.Deactivate(ctx, &user, &repo, \"http://example.com/api/hook?access_token=token\")\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"test parse webhook\", func(t *testing.T) {\n\t\t// Test hook method\n\t\tt.Run(\"parse push\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPush),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, pipeline.Event, model.EventPush)\n\t\t\t\tassert.Equal(t, \"test\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"woodpecker\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\", hookRepo.Avatar)\n\t\t\t\tassert.Equal(t, \"develop\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"refs/heads/main\", pipeline.Ref)\n\t\t\t\tassert.Equal(t, []string{\"cmd/cli/main.go\"}, pipeline.ChangedFiles)\n\t\t\t\tassert.Equal(t, model.EventPush, pipeline.Event)\n\t\t\t\tassert.Empty(t, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"tag push\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookTag),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"test\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"woodpecker\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\", hookRepo.Avatar)\n\t\t\t\tassert.Equal(t, \"develop\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"refs/tags/v22\", pipeline.Ref)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventTag, pipeline.Event)\n\t\t\t\tassert.Empty(t, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestUpdated),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\t// TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg\", hookRepo.Avatar)\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"anbraten\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"woodpecker\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Update client.go 🎉\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0) // see L217\n\t\t\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\t\t\tassert.Empty(t, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request new opened\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestOpened),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\t// TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Edit README.md for more text to read\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0) // see L217\n\t\t\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\t\t\tassert.Empty(t, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"ignore merge request hook without changes\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestWithoutChanges),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.Nil(t, hookRepo)\n\t\t\tassert.Nil(t, pipeline)\n\t\t\tif assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) {\n\t\t\t\tassert.EqualValues(t,\n\t\t\t\t\t\"explicit ignored event 'Merge Request Hook', reason: Action 'update' no supported changes detected\",\n\t\t\t\t\terr.Error())\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"ignore unsupported action\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestUnsupportedAction),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.Nil(t, hookRepo)\n\t\t\tassert.Nil(t, pipeline)\n\t\t\tif assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) {\n\t\t\t\tassert.EqualValues(t,\n\t\t\t\t\t\"explicit ignored event 'Merge Request Hook', reason: Action 'action_we_do_not_support' not supported\",\n\t\t\t\t\terr.Error())\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"parse merge request closed\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestClosed),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\t// TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"anbraten\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"woodpecker-test\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Add new file\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0) // see L217\n\t\t\t\tassert.Equal(t, model.EventPullClosed, pipeline.Event)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request reopened\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestReopened),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Some ned more AAAA\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPull, pipeline.Event)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"parse merge request merged\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestMerged),\n\t\t\t)\n\t\t\treq.Header = fixtures.ServiceHookHeaders\n\n\t\t\t// TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"anbraten\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"woodpecker-test\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Add new file\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0) // see L217\n\t\t\t\tassert.Equal(t, model.EventPullClosed, pipeline.Event)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request title and description edited\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestEdited),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\t// TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Edit README for more text to read\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0) // see L217\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"title_edited\", \"description_edited\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"release\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.WebhookReleaseBody),\n\t\t\t)\n\t\t\treq.Header = fixtures.ReleaseHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"refs/tags/0.0.2\", pipeline.Ref)\n\t\t\t\tassert.Equal(t, \"ci\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"created release Awesome version 0.0.2\", pipeline.Message)\n\t\t\t\tassert.Equal(t, model.EventRelease, pipeline.Event)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request review approved\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestApproved),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t\t\tassert.Nil(t, hookRepo)\n\t\t\tassert.Nil(t, pipeline)\n\t\t})\n\n\t\tt.Run(\"merge request review requested\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestReviewRequested),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Equal(t, \"Edit README for more text to read\", pipeline.Title)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"review_requested\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request assigned\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestAssigned),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"assigned\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request unassigned\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestUnassigned),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"assigned\", \"unassigned\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request milestoned\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestMilestoned),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"milestoned\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request demilestoned\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestDemilestoned),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"demilestoned\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request labels added\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestLabelsAdded),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"label_added\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request labels cleared\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestLabelsCleared),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"label_cleared\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request labels updated\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestLabelsUpdated),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.NoError(t, err)\n\t\t\tif assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) {\n\t\t\t\tassert.Equal(t, \"main\", hookRepo.Branch)\n\t\t\t\tassert.Equal(t, \"demoaccount2-commits-group\", hookRepo.Owner)\n\t\t\t\tassert.Equal(t, \"test_ci_tmp\", hookRepo.Name)\n\t\t\t\tassert.Len(t, pipeline.ChangedFiles, 0)\n\t\t\t\tassert.Equal(t, model.EventPullMetadata, pipeline.Event)\n\t\t\t\tassert.Equal(t, []string{\"label_updated\"}, pipeline.EventReason)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"merge request unapproved\", func(t *testing.T) {\n\t\t\treq, _ := http.NewRequest(\n\t\t\t\tfixtures.ServiceHookMethod,\n\t\t\t\tfixtures.ServiceHookURL.String(),\n\t\t\t\tbytes.NewReader(fixtures.HookPullRequestUnapproved),\n\t\t\t)\n\t\t\treq.Header = fixtures.MergeRequestHookHeaders\n\n\t\t\thookRepo, pipeline, err := client.Hook(ctx, req)\n\t\t\tassert.ErrorIs(t, err, &types.ErrIgnoreEvent{})\n\t\t\tassert.Nil(t, hookRepo)\n\t\t\tassert.Nil(t, pipeline)\n\t\t})\n\t})\n}\n\nfunc TestExtractFromPath(t *testing.T) {\n\ttype testCase struct {\n\t\tname        string\n\t\tinput       string\n\t\twantOwner   string\n\t\twantName    string\n\t\terrContains string\n\t}\n\n\ttests := []testCase{\n\t\t{\n\t\t\tname:      \"basic two components\",\n\t\t\tinput:     \"owner/repo\",\n\t\t\twantOwner: \"owner\",\n\t\t\twantName:  \"repo\",\n\t\t},\n\t\t{\n\t\t\tname:      \"three components\",\n\t\t\tinput:     \"owner/group/repo\",\n\t\t\twantOwner: \"owner/group\",\n\t\t\twantName:  \"repo\",\n\t\t},\n\t\t{\n\t\t\tname:      \"many components\",\n\t\t\tinput:     \"owner/group/subgroup/deep/repo\",\n\t\t\twantOwner: \"owner/group/subgroup/deep\",\n\t\t\twantName:  \"repo\",\n\t\t},\n\t\t{\n\t\t\tname:        \"empty string\",\n\t\t\tinput:       \"\",\n\t\t\terrContains: \"minimum match not found\",\n\t\t},\n\t\t{\n\t\t\tname:        \"single component\",\n\t\t\tinput:       \"onlyrepo\",\n\t\t\terrContains: \"minimum match not found\",\n\t\t},\n\t\t{\n\t\t\tname:      \"trailing slash\",\n\t\t\tinput:     \"owner/repo/\",\n\t\t\twantOwner: \"owner/repo\",\n\t\t\twantName:  \"\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\towner, name, err := extractFromPath(tc.input)\n\n\t\t\t// Check error expectations\n\t\t\tif tc.errContains != \"\" {\n\t\t\t\tif assert.Error(t, err) {\n\t\t\t\t\tassert.Contains(t, err.Error(), tc.errContains)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.EqualValues(t, tc.wantOwner, owner)\n\t\t\tassert.EqualValues(t, tc.wantName, name)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/forge/gitlab/helper.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitlab\n\nimport (\n\t\"crypto/tls\"\n\t\"net/http\"\n\n\tgitlab \"gitlab.com/gitlab-org/api/client-go/v2\"\n\t\"golang.org/x/oauth2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n)\n\nconst (\n\tgravatarBase = \"https://www.gravatar.com/avatar\"\n)\n\n// newClient is a helper function that returns a new GitHub\n// client using the provided OAuth token.\nfunc newClient(url, accessToken string, skipVerify bool) (*gitlab.Client, error) {\n\treturn gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{\n\t\tTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),\n\t}, gitlab.WithBaseURL(url), gitlab.WithHTTPClient(&http.Client{\n\t\tTransport: httputil.NewUserAgentRoundTripper(\n\t\t\t&http.Transport{\n\t\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify},\n\t\t\t\tProxy:           http.ProxyFromEnvironment,\n\t\t\t},\n\t\t\t\"forge-gitlab\"),\n\t}))\n}\n\n// isRead is a helper function that returns true if the\n// user has Read-only access to the repository.\nfunc isRead(proj *gitlab.Project, projectMember *gitlab.ProjectMember) bool {\n\treturn proj.Visibility == gitlab.InternalVisibility || proj.Visibility == gitlab.PrivateVisibility || projectMember != nil && projectMember.AccessLevel >= gitlab.ReporterPermissions\n}\n\n// isWrite is a helper function that returns true if the\n// user has Read-Write access to the repository.\nfunc isWrite(projectMember *gitlab.ProjectMember) bool {\n\treturn projectMember != nil && projectMember.AccessLevel >= gitlab.DeveloperPermissions\n}\n\n// isAdmin is a helper function that returns true if the\n// user has Admin access to the repository.\nfunc isAdmin(projectMember *gitlab.ProjectMember) bool {\n\treturn projectMember != nil && projectMember.AccessLevel >= gitlab.MaintainerPermissions\n}\n"
  },
  {
    "path": "server/forge/gitlab/status.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gitlab\n\nimport (\n\tgitlab \"gitlab.com/gitlab-org/api/client-go/v2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// getStatus is a helper that converts a Woodpecker status to a Gitlab status.\nfunc getStatus(status model.StatusValue) gitlab.BuildStateValue {\n\tswitch status {\n\tcase model.StatusPending, model.StatusBlocked:\n\t\treturn gitlab.Pending\n\tcase model.StatusRunning:\n\t\treturn gitlab.Running\n\tcase model.StatusSuccess:\n\t\treturn gitlab.Success\n\tcase model.StatusFailure, model.StatusError:\n\t\treturn gitlab.Failed\n\tcase model.StatusKilled:\n\t\treturn gitlab.Canceled\n\tdefault:\n\t\treturn gitlab.Failed\n\t}\n}\n"
  },
  {
    "path": "server/forge/mocks/mock_Forge.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockForge creates a new instance of MockForge. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockForge(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockForge {\n\tmock := &MockForge{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockForge is an autogenerated mock type for the Forge type\ntype MockForge struct {\n\tmock.Mock\n}\n\ntype MockForge_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockForge) EXPECT() *MockForge_Expecter {\n\treturn &MockForge_Expecter{mock: &_m.Mock}\n}\n\n// Activate provides a mock function for the type MockForge\nfunc (_mock *MockForge) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tret := _mock.Called(ctx, u, r, link)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Activate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) error); ok {\n\t\tr0 = returnFunc(ctx, u, r, link)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockForge_Activate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Activate'\ntype MockForge_Activate_Call struct {\n\t*mock.Call\n}\n\n// Activate is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - link string\nfunc (_e *MockForge_Expecter) Activate(ctx interface{}, u interface{}, r interface{}, link interface{}) *MockForge_Activate_Call {\n\treturn &MockForge_Activate_Call{Call: _e.mock.On(\"Activate\", ctx, u, r, link)}\n}\n\nfunc (_c *MockForge_Activate_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, link string)) *MockForge_Activate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 string\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Activate_Call) Return(err error) *MockForge_Activate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Activate_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, link string) error) *MockForge_Activate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// BranchHead provides a mock function for the type MockForge\nfunc (_mock *MockForge) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {\n\tret := _mock.Called(ctx, u, r, branch)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for BranchHead\")\n\t}\n\n\tvar r0 *model.Commit\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) (*model.Commit, error)); ok {\n\t\treturn returnFunc(ctx, u, r, branch)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) *model.Commit); ok {\n\t\tr0 = returnFunc(ctx, u, r, branch)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Commit)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, r, branch)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_BranchHead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BranchHead'\ntype MockForge_BranchHead_Call struct {\n\t*mock.Call\n}\n\n// BranchHead is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - branch string\nfunc (_e *MockForge_Expecter) BranchHead(ctx interface{}, u interface{}, r interface{}, branch interface{}) *MockForge_BranchHead_Call {\n\treturn &MockForge_BranchHead_Call{Call: _e.mock.On(\"BranchHead\", ctx, u, r, branch)}\n}\n\nfunc (_c *MockForge_BranchHead_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, branch string)) *MockForge_BranchHead_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 string\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_BranchHead_Call) Return(commit *model.Commit, err error) *MockForge_BranchHead_Call {\n\t_c.Call.Return(commit, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_BranchHead_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error)) *MockForge_BranchHead_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Branches provides a mock function for the type MockForge\nfunc (_mock *MockForge) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {\n\tret := _mock.Called(ctx, u, r, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Branches\")\n\t}\n\n\tvar r0 []string\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) ([]string, error)); ok {\n\t\treturn returnFunc(ctx, u, r, p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) []string); ok {\n\t\tr0 = returnFunc(ctx, u, r, p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]string)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(ctx, u, r, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Branches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Branches'\ntype MockForge_Branches_Call struct {\n\t*mock.Call\n}\n\n// Branches is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - p *model.ListOptions\nfunc (_e *MockForge_Expecter) Branches(ctx interface{}, u interface{}, r interface{}, p interface{}) *MockForge_Branches_Call {\n\treturn &MockForge_Branches_Call{Call: _e.mock.On(\"Branches\", ctx, u, r, p)}\n}\n\nfunc (_c *MockForge_Branches_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions)) *MockForge_Branches_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 *model.ListOptions\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Branches_Call) Return(strings []string, err error) *MockForge_Branches_Call {\n\t_c.Call.Return(strings, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Branches_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error)) *MockForge_Branches_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Deactivate provides a mock function for the type MockForge\nfunc (_mock *MockForge) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {\n\tret := _mock.Called(ctx, u, r, link)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Deactivate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) error); ok {\n\t\tr0 = returnFunc(ctx, u, r, link)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockForge_Deactivate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deactivate'\ntype MockForge_Deactivate_Call struct {\n\t*mock.Call\n}\n\n// Deactivate is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - link string\nfunc (_e *MockForge_Expecter) Deactivate(ctx interface{}, u interface{}, r interface{}, link interface{}) *MockForge_Deactivate_Call {\n\treturn &MockForge_Deactivate_Call{Call: _e.mock.On(\"Deactivate\", ctx, u, r, link)}\n}\n\nfunc (_c *MockForge_Deactivate_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, link string)) *MockForge_Deactivate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 string\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Deactivate_Call) Return(err error) *MockForge_Deactivate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Deactivate_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, link string) error) *MockForge_Deactivate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Dir provides a mock function for the type MockForge\nfunc (_mock *MockForge) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error) {\n\tret := _mock.Called(ctx, u, r, b, dirName)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Dir\")\n\t}\n\n\tvar r0 []*types.FileMeta\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) ([]*types.FileMeta, error)); ok {\n\t\treturn returnFunc(ctx, u, r, b, dirName)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) []*types.FileMeta); ok {\n\t\tr0 = returnFunc(ctx, u, r, b, dirName)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*types.FileMeta)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, r, b, dirName)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Dir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dir'\ntype MockForge_Dir_Call struct {\n\t*mock.Call\n}\n\n// Dir is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - b *model.Pipeline\n//   - dirName string\nfunc (_e *MockForge_Expecter) Dir(ctx interface{}, u interface{}, r interface{}, b interface{}, dirName interface{}) *MockForge_Dir_Call {\n\treturn &MockForge_Dir_Call{Call: _e.mock.On(\"Dir\", ctx, u, r, b, dirName)}\n}\n\nfunc (_c *MockForge_Dir_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string)) *MockForge_Dir_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 *model.Pipeline\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Pipeline)\n\t\t}\n\t\tvar arg4 string\n\t\tif args[4] != nil {\n\t\t\targ4 = args[4].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t\targ4,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Dir_Call) Return(fileMetas []*types.FileMeta, err error) *MockForge_Dir_Call {\n\t_c.Call.Return(fileMetas, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Dir_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error)) *MockForge_Dir_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// File provides a mock function for the type MockForge\nfunc (_mock *MockForge) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error) {\n\tret := _mock.Called(ctx, u, r, b, fileName)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for File\")\n\t}\n\n\tvar r0 []byte\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) ([]byte, error)); ok {\n\t\treturn returnFunc(ctx, u, r, b, fileName)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) []byte); ok {\n\t\tr0 = returnFunc(ctx, u, r, b, fileName)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, r, b, fileName)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_File_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'File'\ntype MockForge_File_Call struct {\n\t*mock.Call\n}\n\n// File is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - b *model.Pipeline\n//   - fileName string\nfunc (_e *MockForge_Expecter) File(ctx interface{}, u interface{}, r interface{}, b interface{}, fileName interface{}) *MockForge_File_Call {\n\treturn &MockForge_File_Call{Call: _e.mock.On(\"File\", ctx, u, r, b, fileName)}\n}\n\nfunc (_c *MockForge_File_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string)) *MockForge_File_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 *model.Pipeline\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Pipeline)\n\t\t}\n\t\tvar arg4 string\n\t\tif args[4] != nil {\n\t\t\targ4 = args[4].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t\targ4,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_File_Call) Return(bytes []byte, err error) *MockForge_File_Call {\n\t_c.Call.Return(bytes, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_File_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error)) *MockForge_File_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Hook provides a mock function for the type MockForge\nfunc (_mock *MockForge) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {\n\tret := _mock.Called(ctx, r)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Hook\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 *model.Pipeline\n\tvar r2 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *http.Request) (*model.Repo, *model.Pipeline, error)); ok {\n\t\treturn returnFunc(ctx, r)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *http.Request) *model.Repo); ok {\n\t\tr0 = returnFunc(ctx, r)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *http.Request) *model.Pipeline); ok {\n\t\tr1 = returnFunc(ctx, r)\n\t} else {\n\t\tif ret.Get(1) != nil {\n\t\t\tr1 = ret.Get(1).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(2).(func(context.Context, *http.Request) error); ok {\n\t\tr2 = returnFunc(ctx, r)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\treturn r0, r1, r2\n}\n\n// MockForge_Hook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hook'\ntype MockForge_Hook_Call struct {\n\t*mock.Call\n}\n\n// Hook is a helper method to define mock.On call\n//   - ctx context.Context\n//   - r *http.Request\nfunc (_e *MockForge_Expecter) Hook(ctx interface{}, r interface{}) *MockForge_Hook_Call {\n\treturn &MockForge_Hook_Call{Call: _e.mock.On(\"Hook\", ctx, r)}\n}\n\nfunc (_c *MockForge_Hook_Call) Run(run func(ctx context.Context, r *http.Request)) *MockForge_Hook_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *http.Request\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*http.Request)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Hook_Call) Return(repo *model.Repo, pipeline *model.Pipeline, err error) *MockForge_Hook_Call {\n\t_c.Call.Return(repo, pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Hook_Call) RunAndReturn(run func(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error)) *MockForge_Hook_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Login provides a mock function for the type MockForge\nfunc (_mock *MockForge) Login(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error) {\n\tret := _mock.Called(ctx, r)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Login\")\n\t}\n\n\tvar r0 *model.User\n\tvar r1 string\n\tvar r2 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.OAuthRequest) (*model.User, string, error)); ok {\n\t\treturn returnFunc(ctx, r)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *types.OAuthRequest) *model.User); ok {\n\t\tr0 = returnFunc(ctx, r)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *types.OAuthRequest) string); ok {\n\t\tr1 = returnFunc(ctx, r)\n\t} else {\n\t\tr1 = ret.Get(1).(string)\n\t}\n\tif returnFunc, ok := ret.Get(2).(func(context.Context, *types.OAuthRequest) error); ok {\n\t\tr2 = returnFunc(ctx, r)\n\t} else {\n\t\tr2 = ret.Error(2)\n\t}\n\treturn r0, r1, r2\n}\n\n// MockForge_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login'\ntype MockForge_Login_Call struct {\n\t*mock.Call\n}\n\n// Login is a helper method to define mock.On call\n//   - ctx context.Context\n//   - r *types.OAuthRequest\nfunc (_e *MockForge_Expecter) Login(ctx interface{}, r interface{}) *MockForge_Login_Call {\n\treturn &MockForge_Login_Call{Call: _e.mock.On(\"Login\", ctx, r)}\n}\n\nfunc (_c *MockForge_Login_Call) Run(run func(ctx context.Context, r *types.OAuthRequest)) *MockForge_Login_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *types.OAuthRequest\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*types.OAuthRequest)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Login_Call) Return(user *model.User, s string, err error) *MockForge_Login_Call {\n\t_c.Call.Return(user, s, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Login_Call) RunAndReturn(run func(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error)) *MockForge_Login_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Name provides a mock function for the type MockForge\nfunc (_mock *MockForge) Name() string {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Name\")\n\t}\n\n\tvar r0 string\n\tif returnFunc, ok := ret.Get(0).(func() string); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(string)\n\t}\n\treturn r0\n}\n\n// MockForge_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'\ntype MockForge_Name_Call struct {\n\t*mock.Call\n}\n\n// Name is a helper method to define mock.On call\nfunc (_e *MockForge_Expecter) Name() *MockForge_Name_Call {\n\treturn &MockForge_Name_Call{Call: _e.mock.On(\"Name\")}\n}\n\nfunc (_c *MockForge_Name_Call) Run(run func()) *MockForge_Name_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Name_Call) Return(s string) *MockForge_Name_Call {\n\t_c.Call.Return(s)\n\treturn _c\n}\n\nfunc (_c *MockForge_Name_Call) RunAndReturn(run func() string) *MockForge_Name_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Netrc provides a mock function for the type MockForge\nfunc (_mock *MockForge) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {\n\tret := _mock.Called(u, r)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Netrc\")\n\t}\n\n\tvar r0 *model.Netrc\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) (*model.Netrc, error)); ok {\n\t\treturn returnFunc(u, r)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) *model.Netrc); ok {\n\t\tr0 = returnFunc(u, r)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Netrc)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User, *model.Repo) error); ok {\n\t\tr1 = returnFunc(u, r)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Netrc_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Netrc'\ntype MockForge_Netrc_Call struct {\n\t*mock.Call\n}\n\n// Netrc is a helper method to define mock.On call\n//   - u *model.User\n//   - r *model.Repo\nfunc (_e *MockForge_Expecter) Netrc(u interface{}, r interface{}) *MockForge_Netrc_Call {\n\treturn &MockForge_Netrc_Call{Call: _e.mock.On(\"Netrc\", u, r)}\n}\n\nfunc (_c *MockForge_Netrc_Call) Run(run func(u *model.User, r *model.Repo)) *MockForge_Netrc_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\tvar arg1 *model.Repo\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Netrc_Call) Return(netrc *model.Netrc, err error) *MockForge_Netrc_Call {\n\t_c.Call.Return(netrc, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Netrc_Call) RunAndReturn(run func(u *model.User, r *model.Repo) (*model.Netrc, error)) *MockForge_Netrc_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Org provides a mock function for the type MockForge\nfunc (_mock *MockForge) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) {\n\tret := _mock.Called(ctx, u, org)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Org\")\n\t}\n\n\tvar r0 *model.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.Org, error)); ok {\n\t\treturn returnFunc(ctx, u, org)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.Org); ok {\n\t\tr0 = returnFunc(ctx, u, org)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, org)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Org_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Org'\ntype MockForge_Org_Call struct {\n\t*mock.Call\n}\n\n// Org is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - org string\nfunc (_e *MockForge_Expecter) Org(ctx interface{}, u interface{}, org interface{}) *MockForge_Org_Call {\n\treturn &MockForge_Org_Call{Call: _e.mock.On(\"Org\", ctx, u, org)}\n}\n\nfunc (_c *MockForge_Org_Call) Run(run func(ctx context.Context, u *model.User, org string)) *MockForge_Org_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Org_Call) Return(org1 *model.Org, err error) *MockForge_Org_Call {\n\t_c.Call.Return(org1, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Org_Call) RunAndReturn(run func(ctx context.Context, u *model.User, org string) (*model.Org, error)) *MockForge_Org_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgMembership provides a mock function for the type MockForge\nfunc (_mock *MockForge) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {\n\tret := _mock.Called(ctx, u, org)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgMembership\")\n\t}\n\n\tvar r0 *model.OrgPerm\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.OrgPerm, error)); ok {\n\t\treturn returnFunc(ctx, u, org)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.OrgPerm); ok {\n\t\tr0 = returnFunc(ctx, u, org)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.OrgPerm)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, org)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_OrgMembership_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgMembership'\ntype MockForge_OrgMembership_Call struct {\n\t*mock.Call\n}\n\n// OrgMembership is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - org string\nfunc (_e *MockForge_Expecter) OrgMembership(ctx interface{}, u interface{}, org interface{}) *MockForge_OrgMembership_Call {\n\treturn &MockForge_OrgMembership_Call{Call: _e.mock.On(\"OrgMembership\", ctx, u, org)}\n}\n\nfunc (_c *MockForge_OrgMembership_Call) Run(run func(ctx context.Context, u *model.User, org string)) *MockForge_OrgMembership_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_OrgMembership_Call) Return(orgPerm *model.OrgPerm, err error) *MockForge_OrgMembership_Call {\n\t_c.Call.Return(orgPerm, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_OrgMembership_Call) RunAndReturn(run func(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)) *MockForge_OrgMembership_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PullRequests provides a mock function for the type MockForge\nfunc (_mock *MockForge) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {\n\tret := _mock.Called(ctx, u, r, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PullRequests\")\n\t}\n\n\tvar r0 []*model.PullRequest\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) ([]*model.PullRequest, error)); ok {\n\t\treturn returnFunc(ctx, u, r, p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) []*model.PullRequest); ok {\n\t\tr0 = returnFunc(ctx, u, r, p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.PullRequest)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(ctx, u, r, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_PullRequests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PullRequests'\ntype MockForge_PullRequests_Call struct {\n\t*mock.Call\n}\n\n// PullRequests is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - p *model.ListOptions\nfunc (_e *MockForge_Expecter) PullRequests(ctx interface{}, u interface{}, r interface{}, p interface{}) *MockForge_PullRequests_Call {\n\treturn &MockForge_PullRequests_Call{Call: _e.mock.On(\"PullRequests\", ctx, u, r, p)}\n}\n\nfunc (_c *MockForge_PullRequests_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions)) *MockForge_PullRequests_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 *model.ListOptions\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_PullRequests_Call) Return(pullRequests []*model.PullRequest, err error) *MockForge_PullRequests_Call {\n\t_c.Call.Return(pullRequests, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_PullRequests_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error)) *MockForge_PullRequests_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Repo provides a mock function for the type MockForge\nfunc (_mock *MockForge) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string) (*model.Repo, error) {\n\tret := _mock.Called(ctx, u, remoteID, owner, name)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Repo\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) (*model.Repo, error)); ok {\n\t\treturn returnFunc(ctx, u, remoteID, owner, name)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) *model.Repo); ok {\n\t\tr0 = returnFunc(ctx, u, remoteID, owner, name)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) error); ok {\n\t\tr1 = returnFunc(ctx, u, remoteID, owner, name)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Repo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repo'\ntype MockForge_Repo_Call struct {\n\t*mock.Call\n}\n\n// Repo is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - remoteID model.ForgeRemoteID\n//   - owner string\n//   - name string\nfunc (_e *MockForge_Expecter) Repo(ctx interface{}, u interface{}, remoteID interface{}, owner interface{}, name interface{}) *MockForge_Repo_Call {\n\treturn &MockForge_Repo_Call{Call: _e.mock.On(\"Repo\", ctx, u, remoteID, owner, name)}\n}\n\nfunc (_c *MockForge_Repo_Call) Run(run func(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string)) *MockForge_Repo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 model.ForgeRemoteID\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(model.ForgeRemoteID)\n\t\t}\n\t\tvar arg3 string\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(string)\n\t\t}\n\t\tvar arg4 string\n\t\tif args[4] != nil {\n\t\t\targ4 = args[4].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t\targ4,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Repo_Call) Return(repo *model.Repo, err error) *MockForge_Repo_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Repo_Call) RunAndReturn(run func(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string) (*model.Repo, error)) *MockForge_Repo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Repos provides a mock function for the type MockForge\nfunc (_mock *MockForge) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {\n\tret := _mock.Called(ctx, u, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Repos\")\n\t}\n\n\tvar r0 []*model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) ([]*model.Repo, error)); ok {\n\t\treturn returnFunc(ctx, u, p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) []*model.Repo); ok {\n\t\tr0 = returnFunc(ctx, u, p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(ctx, u, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Repos_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repos'\ntype MockForge_Repos_Call struct {\n\t*mock.Call\n}\n\n// Repos is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - p *model.ListOptions\nfunc (_e *MockForge_Expecter) Repos(ctx interface{}, u interface{}, p interface{}) *MockForge_Repos_Call {\n\treturn &MockForge_Repos_Call{Call: _e.mock.On(\"Repos\", ctx, u, p)}\n}\n\nfunc (_c *MockForge_Repos_Call) Run(run func(ctx context.Context, u *model.User, p *model.ListOptions)) *MockForge_Repos_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.ListOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Repos_Call) Return(repos []*model.Repo, err error) *MockForge_Repos_Call {\n\t_c.Call.Return(repos, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Repos_Call) RunAndReturn(run func(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error)) *MockForge_Repos_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Status provides a mock function for the type MockForge\nfunc (_mock *MockForge) Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error {\n\tret := _mock.Called(ctx, u, r, b, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Status\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, *model.Workflow) error); ok {\n\t\tr0 = returnFunc(ctx, u, r, b, p)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockForge_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'\ntype MockForge_Status_Call struct {\n\t*mock.Call\n}\n\n// Status is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - r *model.Repo\n//   - b *model.Pipeline\n//   - p *model.Workflow\nfunc (_e *MockForge_Expecter) Status(ctx interface{}, u interface{}, r interface{}, b interface{}, p interface{}) *MockForge_Status_Call {\n\treturn &MockForge_Status_Call{Call: _e.mock.On(\"Status\", ctx, u, r, b, p)}\n}\n\nfunc (_c *MockForge_Status_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow)) *MockForge_Status_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.Repo\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Repo)\n\t\t}\n\t\tvar arg3 *model.Pipeline\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Pipeline)\n\t\t}\n\t\tvar arg4 *model.Workflow\n\t\tif args[4] != nil {\n\t\t\targ4 = args[4].(*model.Workflow)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t\targ4,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Status_Call) Return(err error) *MockForge_Status_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Status_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error) *MockForge_Status_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Teams provides a mock function for the type MockForge\nfunc (_mock *MockForge) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {\n\tret := _mock.Called(ctx, u, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Teams\")\n\t}\n\n\tvar r0 []*model.Team\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) ([]*model.Team, error)); ok {\n\t\treturn returnFunc(ctx, u, p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) []*model.Team); ok {\n\t\tr0 = returnFunc(ctx, u, p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Team)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(ctx, u, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockForge_Teams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Teams'\ntype MockForge_Teams_Call struct {\n\t*mock.Call\n}\n\n// Teams is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\n//   - p *model.ListOptions\nfunc (_e *MockForge_Expecter) Teams(ctx interface{}, u interface{}, p interface{}) *MockForge_Teams_Call {\n\treturn &MockForge_Teams_Call{Call: _e.mock.On(\"Teams\", ctx, u, p)}\n}\n\nfunc (_c *MockForge_Teams_Call) Run(run func(ctx context.Context, u *model.User, p *model.ListOptions)) *MockForge_Teams_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\tvar arg2 *model.ListOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_Teams_Call) Return(teams []*model.Team, err error) *MockForge_Teams_Call {\n\t_c.Call.Return(teams, err)\n\treturn _c\n}\n\nfunc (_c *MockForge_Teams_Call) RunAndReturn(run func(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error)) *MockForge_Teams_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// URL provides a mock function for the type MockForge\nfunc (_mock *MockForge) URL() string {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for URL\")\n\t}\n\n\tvar r0 string\n\tif returnFunc, ok := ret.Get(0).(func() string); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(string)\n\t}\n\treturn r0\n}\n\n// MockForge_URL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'URL'\ntype MockForge_URL_Call struct {\n\t*mock.Call\n}\n\n// URL is a helper method to define mock.On call\nfunc (_e *MockForge_Expecter) URL() *MockForge_URL_Call {\n\treturn &MockForge_URL_Call{Call: _e.mock.On(\"URL\")}\n}\n\nfunc (_c *MockForge_URL_Call) Run(run func()) *MockForge_URL_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockForge_URL_Call) Return(s string) *MockForge_URL_Call {\n\t_c.Call.Return(s)\n\treturn _c\n}\n\nfunc (_c *MockForge_URL_Call) RunAndReturn(run func() string) *MockForge_URL_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/forge/mocks/mock_Refresher.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockRefresher creates a new instance of MockRefresher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockRefresher(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockRefresher {\n\tmock := &MockRefresher{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockRefresher is an autogenerated mock type for the Refresher type\ntype MockRefresher struct {\n\tmock.Mock\n}\n\ntype MockRefresher_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockRefresher) EXPECT() *MockRefresher_Expecter {\n\treturn &MockRefresher_Expecter{mock: &_m.Mock}\n}\n\n// Refresh provides a mock function for the type MockRefresher\nfunc (_mock *MockRefresher) Refresh(ctx context.Context, u *model.User) (bool, error) {\n\tret := _mock.Called(ctx, u)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Refresh\")\n\t}\n\n\tvar r0 bool\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User) (bool, error)); ok {\n\t\treturn returnFunc(ctx, u)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.User) bool); ok {\n\t\tr0 = returnFunc(ctx, u)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.User) error); ok {\n\t\tr1 = returnFunc(ctx, u)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockRefresher_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'\ntype MockRefresher_Refresh_Call struct {\n\t*mock.Call\n}\n\n// Refresh is a helper method to define mock.On call\n//   - ctx context.Context\n//   - u *model.User\nfunc (_e *MockRefresher_Expecter) Refresh(ctx interface{}, u interface{}) *MockRefresher_Refresh_Call {\n\treturn &MockRefresher_Refresh_Call{Call: _e.mock.On(\"Refresh\", ctx, u)}\n}\n\nfunc (_c *MockRefresher_Refresh_Call) Run(run func(ctx context.Context, u *model.User)) *MockRefresher_Refresh_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.User\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockRefresher_Refresh_Call) Return(b bool, err error) *MockRefresher_Refresh_Call {\n\t_c.Call.Return(b, err)\n\treturn _c\n}\n\nfunc (_c *MockRefresher_Refresh_Call) RunAndReturn(run func(ctx context.Context, u *model.User) (bool, error)) *MockRefresher_Refresh_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/forge/refresh.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forge\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"golang.org/x/sync/singleflight\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// Refresher is an optional interface for OAuth token refresh support.\n//\n// Tokens are checked before each operation. If expiring within 30 minutes,\n// Refresh() is called automatically.\n//\n// Implementations: GitLab, Bitbucket (GitHub/Gitea tokens don't expire).\ntype Refresher interface {\n\t// Refresh attempts to refresh the user's OAuth access token.\n\t// Should update u.AccessToken, u.RefreshToken, and u.Expiry.\n\t// Returns true if any fields were updated.\n\t// Caller must persist updated user to database.\n\tRefresh(ctx context.Context, u *model.User) (bool, error)\n}\n\n// refreshGroup deduplicates concurrent token refresh calls per user.\n// When multiple goroutines try to refresh the same user's token simultaneously\n// (e.g., from concurrent API requests), only one refresh executes and the\n// others wait for its result. This prevents race conditions with single-use\n// refresh tokens (e.g., Forgejo with InvalidateRefreshTokens=true).\nvar refreshGroup singleflight.Group\n\n// refreshResult carries token data through singleflight so waiting goroutines\n// can update their own *model.User copies.\ntype refreshResult struct {\n\tAccessToken  string\n\tRefreshToken string\n\tExpiry       int64\n}\n\nfunc Refresh(ctx context.Context, forge Forge, _store store.Store, user *model.User) {\n\t// Remaining ttl of 30 minutes (1800 seconds) until a token is refreshed.\n\tconst tokenMinTTL = 1800\n\n\tif refresher, ok := forge.(Refresher); ok {\n\t\t// Check to see if the user token is expired or\n\t\t// will expire within the next 30 minutes (1800 seconds).\n\t\t// If not, there is nothing we really need to do here.\n\t\tif time.Now().UTC().Unix() < (user.Expiry - tokenMinTTL) {\n\t\t\treturn\n\t\t}\n\n\t\tkey := fmt.Sprintf(\"refresh-%d\", user.ID)\n\t\tresult, err, _ := refreshGroup.Do(key, func() (any, error) {\n\t\t\tuserUpdated, err := refresher.Refresh(ctx, user)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif userUpdated {\n\t\t\t\tif err := _store.UpdateUser(user); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msg(\"fail to save user to store after refresh oauth token\")\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn &refreshResult{\n\t\t\t\tAccessToken:  user.AccessToken,\n\t\t\t\tRefreshToken: user.RefreshToken,\n\t\t\t\tExpiry:       user.Expiry,\n\t\t\t}, nil\n\t\t})\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"refresh oauth token of user '%s' failed\", user.Login)\n\t\t\treturn\n\t\t}\n\n\t\t// Copy fresh tokens into the caller's user object. This is necessary\n\t\t// because waiting goroutines have their own *model.User copies that\n\t\t// weren't passed to refresher.Refresh().\n\t\tif r, ok := result.(*refreshResult); ok {\n\t\t\tuser.AccessToken = r.AccessToken\n\t\t\tuser.RefreshToken = r.RefreshToken\n\t\t\tuser.Expiry = r.Expiry\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/forge/refresh_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage forge_test\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\n// refresherForge combines MockForge (satisfies forge.Forge) and MockRefresher\n// (satisfies forge.Refresher) so the Refresh function's type assertion succeeds.\ntype refresherForge struct {\n\t*forge_mocks.MockForge\n\t*forge_mocks.MockRefresher\n}\n\nfunc expiredUser(id int64) *model.User {\n\treturn &model.User{\n\t\tID:           id,\n\t\tLogin:        fmt.Sprintf(\"user%d\", id),\n\t\tAccessToken:  \"old-access-token\",\n\t\tRefreshToken: \"old-refresh-token\",\n\t\tExpiry:       time.Now().UTC().Unix() - 100, // expired\n\t}\n}\n\nfunc freshUser(id int64) *model.User {\n\treturn &model.User{\n\t\tID:           id,\n\t\tLogin:        fmt.Sprintf(\"user%d\", id),\n\t\tAccessToken:  \"valid-access-token\",\n\t\tRefreshToken: \"valid-refresh-token\",\n\t\tExpiry:       time.Now().UTC().Unix() + 7200, // 2 hours from now\n\t}\n}\n\nfunc TestRefresh_NonExpiredToken(t *testing.T) {\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockRefresher := forge_mocks.NewMockRefresher(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tf := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher}\n\tuser := freshUser(1)\n\n\tforge.Refresh(context.Background(), f, mockStore, user)\n\n\t// Refresher.Refresh should NOT be called since token is still valid\n\tmockRefresher.AssertNotCalled(t, \"Refresh\", mock.Anything, mock.Anything)\n}\n\nfunc TestRefresh_ExpiredToken(t *testing.T) {\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockRefresher := forge_mocks.NewMockRefresher(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tf := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher}\n\tuser := expiredUser(1)\n\n\tmockRefresher.On(\"Refresh\", mock.Anything, user).Return(true, nil).Run(func(args mock.Arguments) {\n\t\tu, ok := args.Get(1).(*model.User)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tu.AccessToken = \"new-access-token\"\n\t\tu.RefreshToken = \"new-refresh-token\"\n\t\tu.Expiry = time.Now().UTC().Unix() + 3600\n\t})\n\tmockStore.On(\"UpdateUser\", user).Return(nil)\n\n\tforge.Refresh(context.Background(), f, mockStore, user)\n\n\tassert.Equal(t, \"new-access-token\", user.AccessToken)\n\tassert.Equal(t, \"new-refresh-token\", user.RefreshToken)\n\tmockRefresher.AssertCalled(t, \"Refresh\", mock.Anything, user)\n\tmockStore.AssertCalled(t, \"UpdateUser\", user)\n}\n\nfunc TestRefresh_ExpiredTokenNoUpdate(t *testing.T) {\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockRefresher := forge_mocks.NewMockRefresher(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tf := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher}\n\tuser := expiredUser(2)\n\n\t// Refresh returns false (no update needed), e.g. token was already refreshed\n\tmockRefresher.On(\"Refresh\", mock.Anything, user).Return(false, nil)\n\n\tforge.Refresh(context.Background(), f, mockStore, user)\n\n\tmockRefresher.AssertCalled(t, \"Refresh\", mock.Anything, user)\n\t// UpdateUser should NOT be called when Refresh returns false\n\tmockStore.AssertNotCalled(t, \"UpdateUser\", mock.Anything)\n}\n\nfunc TestRefresh_ConcurrentRefreshSerialized(t *testing.T) {\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockRefresher := forge_mocks.NewMockRefresher(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tf := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher}\n\n\tvar refreshCount atomic.Int32\n\n\tmockRefresher.On(\"Refresh\", mock.Anything, mock.Anything).Return(true, nil).Run(func(args mock.Arguments) {\n\t\trefreshCount.Add(1)\n\t\t// Simulate network latency so concurrent callers overlap\n\t\ttime.Sleep(50 * time.Millisecond)\n\t\tu, ok := args.Get(1).(*model.User)\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\t\tu.AccessToken = \"new-access-token\"\n\t\tu.RefreshToken = \"new-refresh-token\"\n\t\tu.Expiry = time.Now().UTC().Unix() + 3600\n\t})\n\tmockStore.On(\"UpdateUser\", mock.Anything).Return(nil)\n\n\tconst numGoroutines = 10\n\tvar wg sync.WaitGroup\n\tusers := make([]*model.User, numGoroutines)\n\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tusers[i] = expiredUser(42) // same user ID\n\t}\n\n\twg.Add(numGoroutines)\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tgo func(u *model.User) {\n\t\t\tdefer wg.Done()\n\t\t\tforge.Refresh(context.Background(), f, mockStore, u)\n\t\t}(users[i])\n\t}\n\twg.Wait()\n\n\t// Only one actual refresh call should have been made\n\tassert.Equal(t, int32(1), refreshCount.Load(), \"expected exactly 1 refresh call, got %d\", refreshCount.Load())\n\n\t// All goroutines should have the fresh tokens\n\tfor i := 0; i < len(users); i++ {\n\t\tassert.Equal(t, \"new-access-token\", users[i].AccessToken, \"user[%d] missing new access token\", i)\n\t\tassert.Equal(t, \"new-refresh-token\", users[i].RefreshToken, \"user[%d] missing new refresh token\", i)\n\t}\n}\n\nfunc TestRefresh_ConcurrentRefreshError(t *testing.T) {\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockRefresher := forge_mocks.NewMockRefresher(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tf := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher}\n\n\tmockRefresher.On(\"Refresh\", mock.Anything, mock.Anything).Return(false, fmt.Errorf(\"token was already used\")).Run(func(_ mock.Arguments) {\n\t\ttime.Sleep(50 * time.Millisecond)\n\t})\n\n\tconst numGoroutines = 5\n\tvar wg sync.WaitGroup\n\tusers := make([]*model.User, numGoroutines)\n\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tusers[i] = expiredUser(99) // same user ID\n\t}\n\n\twg.Add(numGoroutines)\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tgo func(u *model.User) {\n\t\t\tdefer wg.Done()\n\t\t\tforge.Refresh(context.Background(), f, mockStore, u)\n\t\t}(users[i])\n\t}\n\twg.Wait()\n\n\t// Tokens should remain unchanged (error path)\n\tfor i := 0; i < len(users); i++ {\n\t\tassert.Equal(t, \"old-access-token\", users[i].AccessToken, \"user[%d] token should be unchanged after error\", i)\n\t}\n\n\t// Store.UpdateUser should NOT be called on error\n\tmockStore.AssertNotCalled(t, \"UpdateUser\", mock.Anything)\n}\n\nfunc TestRefresh_NonRefresherForge(t *testing.T) {\n\t// MockForge does NOT implement Refresher, so the type assertion should fail\n\t// and Refresh should be a no-op\n\tmockForge := forge_mocks.NewMockForge(t)\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tuser := expiredUser(1)\n\n\tforge.Refresh(context.Background(), mockForge, mockStore, user)\n\n\t// Token should be unchanged\n\tassert.Equal(t, \"old-access-token\", user.AccessToken)\n\tmockStore.AssertNotCalled(t, \"UpdateUser\", mock.Anything)\n}\n"
  },
  {
    "path": "server/forge/setup/setup.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage setup\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/addon\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/github\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc Forge(forge *model.Forge) (forge.Forge, error) {\n\tswitch forge.Type {\n\tcase model.ForgeTypeAddon:\n\t\treturn setupAddon(forge)\n\tcase model.ForgeTypeGithub:\n\t\treturn setupGitHub(forge)\n\tcase model.ForgeTypeGitlab:\n\t\treturn setupGitLab(forge)\n\tcase model.ForgeTypeBitbucket:\n\t\treturn setupBitbucket(forge)\n\tcase model.ForgeTypeGitea:\n\t\treturn setupGitea(forge)\n\tcase model.ForgeTypeForgejo:\n\t\treturn setupForgejo(forge)\n\tcase model.ForgeTypeBitbucketDatacenter:\n\t\treturn setupBitbucketDatacenter(forge)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"forge not configured\")\n\t}\n}\n\nfunc setupBitbucket(forge *model.Forge) (forge.Forge, error) {\n\topts := &bitbucket.Opts{\n\t\tOAuthClientID:     forge.OAuthClientID,\n\t\tOAuthClientSecret: forge.OAuthClientSecret,\n\t}\n\n\tlog.Debug().\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-client-secret-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tMsg(\"setting up forge\")\n\treturn bitbucket.New(forge.ID, opts)\n}\n\nfunc setupGitea(forge *model.Forge) (forge.Forge, error) {\n\tserverURL, err := url.Parse(forge.URL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\topts := gitea.Opts{\n\t\tURL:               strings.TrimRight(serverURL.String(), \"/\"),\n\t\tOAuthClientID:     forge.OAuthClientID,\n\t\tOAuthClientSecret: forge.OAuthClientSecret,\n\t\tSkipVerify:        forge.SkipVerify,\n\t\tOAuthHost:         forge.OAuthHost,\n\t}\n\tif len(opts.URL) == 0 {\n\t\treturn nil, fmt.Errorf(\"WOODPECKER_GITEA_URL must be set\")\n\t}\n\tlog.Debug().\n\t\tStr(\"url\", opts.URL).\n\t\tStr(\"oauth-host\", opts.OAuthHost).\n\t\tBool(\"skip-verify\", opts.SkipVerify).\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-secret-id-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tMsg(\"setting up forge\")\n\treturn gitea.New(forge.ID, opts)\n}\n\nfunc setupForgejo(forge *model.Forge) (forge.Forge, error) {\n\tserver, err := url.Parse(forge.URL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\topts := forgejo.Opts{\n\t\tURL:               strings.TrimRight(server.String(), \"/\"),\n\t\tOAuthClientID:     forge.OAuthClientID,\n\t\tOAuthClientSecret: forge.OAuthClientSecret,\n\t\tSkipVerify:        forge.SkipVerify,\n\t\tOAuth2URL:         forge.OAuthHost,\n\t}\n\tif len(opts.URL) == 0 {\n\t\treturn nil, fmt.Errorf(\"WOODPECKER_FORGEJO_URL must be set\")\n\t}\n\tlog.Debug().\n\t\tStr(\"url\", opts.URL).\n\t\tStr(\"oauth2-url\", opts.OAuth2URL).\n\t\tBool(\"skip-verify\", opts.SkipVerify).\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-client-secret-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tMsg(\"setting up forge\")\n\treturn forgejo.New(forge.ID, opts)\n}\n\nfunc setupGitLab(forge *model.Forge) (forge.Forge, error) {\n\topts := gitlab.Opts{\n\t\tURL:               forge.URL,\n\t\tOAuthClientID:     forge.OAuthClientID,\n\t\tOAuthClientSecret: forge.OAuthClientSecret,\n\t\tSkipVerify:        forge.SkipVerify,\n\t\tOAuthHost:         forge.OAuthHost,\n\t}\n\tlog.Debug().\n\t\tStr(\"url\", opts.URL).\n\t\tStr(\"oauth-host\", opts.OAuthHost).\n\t\tBool(\"skip-verify\", opts.SkipVerify).\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-client-secret-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tMsg(\"setting up forge\")\n\treturn gitlab.New(forge.ID, opts)\n}\n\nfunc setupGitHub(forge *model.Forge) (forge.Forge, error) {\n\t// get additional config and be false by default\n\tmergeRef, _ := forge.AdditionalOptions[\"merge-ref\"].(bool)\n\tpublicOnly, _ := forge.AdditionalOptions[\"public-only\"].(bool)\n\n\topts := github.Opts{\n\t\tURL:               forge.URL,\n\t\tOAuthClientID:     forge.OAuthClientID,\n\t\tOAuthClientSecret: forge.OAuthClientSecret,\n\t\tSkipVerify:        forge.SkipVerify,\n\t\tMergeRef:          mergeRef,\n\t\tOnlyPublic:        publicOnly,\n\t\tOAuthHost:         forge.OAuthHost,\n\t}\n\tlog.Debug().\n\t\tStr(\"url\", opts.URL).\n\t\tStr(\"oauth-host\", opts.OAuthHost).\n\t\tBool(\"merge-ref\", opts.MergeRef).\n\t\tBool(\"only-public\", opts.OnlyPublic).\n\t\tBool(\"skip-verify\", opts.SkipVerify).\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-client-secret-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tMsg(\"setting up forge\")\n\treturn github.New(forge.ID, opts)\n}\n\nfunc setupBitbucketDatacenter(forge *model.Forge) (forge.Forge, error) {\n\tgitUsername, ok := forge.AdditionalOptions[\"git-username\"].(string)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"missing git-username\")\n\t}\n\tgitPassword, ok := forge.AdditionalOptions[\"git-password\"].(string)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"missing git-password\")\n\t}\n\n\tenableProjectAdminScope, ok := forge.AdditionalOptions[\"oauth-enable-project-admin-scope\"].(bool)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"incorrect type for oauth-enable-project-admin-scope value\")\n\t}\n\n\topts := bitbucketdatacenter.Opts{\n\t\tURL:                          forge.URL,\n\t\tOAuthClientID:                forge.OAuthClientID,\n\t\tOAuthClientSecret:            forge.OAuthClientSecret,\n\t\tUsername:                     gitUsername,\n\t\tPassword:                     gitPassword,\n\t\tOAuthHost:                    forge.OAuthHost,\n\t\tOAuthEnableProjectAdminScope: enableProjectAdminScope,\n\t}\n\tlog.Debug().\n\t\tStr(\"url\", opts.URL).\n\t\tStr(\"oauth-host\", opts.OAuthHost).\n\t\tBool(\"oauth-client-id-set\", opts.OAuthClientID != \"\").\n\t\tBool(\"oauth-client-secret-set\", opts.OAuthClientSecret != \"\").\n\t\tStr(\"type\", string(forge.Type)).\n\t\tBool(\"oauth-enable-project-admin-scope\", opts.OAuthEnableProjectAdminScope).\n\t\tMsg(\"setting up forge\")\n\treturn bitbucketdatacenter.New(forge.ID, opts)\n}\n\nfunc setupAddon(forge *model.Forge) (forge.Forge, error) {\n\texecutable, ok := forge.AdditionalOptions[\"executable\"].(string)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"missing addon executable\")\n\t}\n\n\tlog.Debug().Str(\"executable\", executable).Msg(\"setting up forge\")\n\treturn addon.Load(executable)\n}\n"
  },
  {
    "path": "server/forge/types/errors.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n)\n\nvar (\n\tErrNotImplemented = errors.New(\"not implemented\")\n\tErrRepoNotFound   = errors.New(\"repo not found\")\n)\n\ntype ErrIgnoreEvent struct {\n\tEvent  string\n\tReason string\n}\n\nfunc (err *ErrIgnoreEvent) Error() string {\n\tif err.Reason != \"\" {\n\t\treturn fmt.Sprintf(\"explicit ignored event '%s', reason: %s\", err.Event, err.Reason)\n\t}\n\treturn fmt.Sprintf(\"explicit ignored event '%s'\", err.Event)\n}\n\nfunc (*ErrIgnoreEvent) Is(target error) bool {\n\t_, ok := target.(*ErrIgnoreEvent)\n\treturn ok\n}\n\ntype ErrConfigNotFound struct {\n\tConfigs []string\n}\n\nfunc (m *ErrConfigNotFound) Error() string {\n\treturn fmt.Sprintf(\"configs not found: %s\", strings.Join(m.Configs, \", \"))\n}\n\nfunc (*ErrConfigNotFound) Is(target error) bool {\n\t_, ok := target.(*ErrConfigNotFound)\n\treturn ok\n}\n"
  },
  {
    "path": "server/forge/types/meta.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport \"sort\"\n\n// FileMeta represents a file in version control.\ntype FileMeta struct {\n\tName string\n\tData []byte\n}\n\ntype fileMetaList []*FileMeta\n\nfunc (a fileMetaList) Len() int           { return len(a) }\nfunc (a fileMetaList) Less(i, j int) bool { return a[i].Name < a[j].Name }\nfunc (a fileMetaList) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }\n\nfunc SortByName(fm []*FileMeta) []*FileMeta {\n\tl := fileMetaList(fm)\n\tsort.Sort(l)\n\treturn l\n}\n"
  },
  {
    "path": "server/forge/types/meta_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSortByName(t *testing.T) {\n\tfm := []*FileMeta{\n\t\t{\n\t\t\tName: \"a\",\n\t\t},\n\t\t{\n\t\t\tName: \"c\",\n\t\t},\n\t\t{\n\t\t\tName: \"b\",\n\t\t},\n\t}\n\n\tassert.Equal(t, []*FileMeta{\n\t\t{\n\t\t\tName: \"a\",\n\t\t},\n\t\t{\n\t\t\tName: \"b\",\n\t\t},\n\t\t{\n\t\t\tName: \"c\",\n\t\t},\n\t}, SortByName(fm))\n}\n"
  },
  {
    "path": "server/forge/types/oauth.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\ntype OAuthRequest struct {\n\tCode  string\n\tState string\n}\n"
  },
  {
    "path": "server/logging/LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2017, Brad Rydzewski\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\n* Neither the name of the copyright holder nor the names of its\n  contributors may be used to endorse or promote products derived from\n  this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "server/logging/log.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logging\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// TODO: (bradrydzewski) writing to subscribers is currently a blocking\n// operation and does not protect against slow clients from locking\n// the stream. This should be resolved.\n\n//nolint:godot\n// TODO: (bradrydzewski) implement a mux.Info to fetch information and\n// statistics for the multiplexer. Streams, subscribers, etc\n// mux.Info()\n\n//nolint:godot\n// TODO: (bradrydzewski) refactor code to place publisher and subscriber\n// operations in separate files with more encapsulated logic.\n// sub.push()\n// sub.join()\n// sub.start()... event loop\n\ntype subscriber struct {\n\treceiver LogChan\n}\n\ntype stream struct {\n\tsync.Mutex\n\n\tstepID int64\n\tlist   []*model.LogEntry\n\tsubs   map[*subscriber]struct{}\n\tdone   chan struct{}\n}\n\ntype logger struct {\n\tsync.Mutex\n\n\tstreams map[int64]*stream\n}\n\n// New returns a new logger.\nfunc New() Log {\n\treturn &logger{\n\t\tstreams: map[int64]*stream{},\n\t}\n}\n\nfunc (l *logger) Open(_ context.Context, stepID int64) error {\n\tl.Lock()\n\tl.open(stepID)\n\tl.Unlock()\n\treturn nil\n}\n\nfunc (l *logger) open(stepID int64) {\n\t_, ok := l.streams[stepID]\n\tif !ok {\n\t\tl.streams[stepID] = &stream{\n\t\t\tstepID: stepID,\n\t\t\tsubs:   make(map[*subscriber]struct{}),\n\t\t\tdone:   make(chan struct{}),\n\t\t}\n\t}\n}\n\nfunc (l *logger) Write(ctx context.Context, stepID int64, entries []*model.LogEntry) error {\n\tl.Lock()\n\ts, ok := l.streams[stepID]\n\tif !ok {\n\t\t// Auto-open the stream while still holding the logger lock so that a\n\t\t// concurrent Write for the same step cannot race on l.streams.\n\t\tl.open(stepID)\n\t\ts = l.streams[stepID]\n\t}\n\tl.Unlock()\n\n\ts.Lock()\n\ts.list = append(s.list, entries...)\n\tfor sub := range s.subs {\n\t\tselect {\n\t\tcase sub.receiver <- entries:\n\t\tdefault:\n\t\t\tlog.Info().Msgf(\"subscriber channel is full -- dropping logs for step %d\", stepID)\n\t\t}\n\t}\n\ts.Unlock()\n\n\treturn nil\n}\n\nfunc (l *logger) Tail(c context.Context, stepID int64, receiver LogChan) error {\n\tl.Lock()\n\ts, ok := l.streams[stepID]\n\tl.Unlock()\n\tif !ok {\n\t\treturn ErrNotFound\n\t}\n\n\tsub := &subscriber{\n\t\treceiver: receiver,\n\t}\n\ts.Lock()\n\tif len(s.list) != 0 {\n\t\tsub.receiver <- s.list\n\t}\n\ts.subs[sub] = struct{}{}\n\ts.Unlock()\n\n\tselect {\n\tcase <-c.Done():\n\tcase <-s.done:\n\t}\n\n\ts.Lock()\n\tdelete(s.subs, sub)\n\ts.Unlock()\n\treturn nil\n}\n\nfunc (l *logger) Close(_ context.Context, stepID int64) error {\n\tl.Lock()\n\ts, ok := l.streams[stepID]\n\tl.Unlock()\n\tif !ok {\n\t\treturn ErrNotFound\n\t}\n\n\ts.Lock()\n\tclose(s.done)\n\ts.Unlock()\n\n\tl.Lock()\n\tdelete(l.streams, stepID)\n\tl.Unlock()\n\treturn nil\n}\n"
  },
  {
    "path": "server/logging/log_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logging\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestLogging(t *testing.T) {\n\tvar (\n\t\twg sync.WaitGroup\n\n\t\ttestStepID = int64(123)\n\t\ttestEntry  = &model.LogEntry{\n\t\t\tData: []byte(\"test\"),\n\t\t}\n\t)\n\n\tctx, cancel := context.WithCancelCause(\n\t\tt.Context(),\n\t)\n\n\treceiver := make(LogChan, 10)\n\tdefer close(receiver)\n\n\tgo func() {\n\t\tfor range receiver {\n\t\t\twg.Done()\n\t\t}\n\t}()\n\n\tlogger := New()\n\tassert.NoError(t, logger.Open(ctx, testStepID))\n\tgo func() {\n\t\tassert.NoError(t, logger.Tail(ctx, testStepID, receiver))\n\t}()\n\tgo func() {\n\t\tassert.NoError(t, logger.Tail(ctx, testStepID, receiver))\n\t}()\n\n\t<-time.After(500 * time.Millisecond)\n\n\twg.Add(4)\n\tgo func() {\n\t\tassert.NoError(t, logger.Write(ctx, testStepID, []*model.LogEntry{testEntry}))\n\t\tassert.NoError(t, logger.Write(ctx, testStepID, []*model.LogEntry{testEntry}))\n\t}()\n\n\twg.Wait()\n\n\twg.Add(1)\n\tgo func() {\n\t\tassert.NoError(t, logger.Tail(ctx, testStepID, receiver))\n\t}()\n\n\t<-time.After(500 * time.Millisecond)\n\n\twg.Wait()\n\tcancel(nil)\n}\n"
  },
  {
    "path": "server/logging/logging.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logging\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// ErrNotFound is returned when the log does not exist.\nvar ErrNotFound = errors.New(\"stream: not found\")\n\n// LogChan defines a channel type for receiving ordered batches of log entries.\ntype LogChan chan []*model.LogEntry\n\n// Log defines a log multiplexer.\ntype Log interface {\n\t// Open opens the log.\n\tOpen(c context.Context, stepID int64) error\n\n\t// Write writes the entry to the log.\n\tWrite(c context.Context, stepID int64, entries []*model.LogEntry) error\n\n\t// Tail tails the log.\n\tTail(c context.Context, stepID int64, handler LogChan) error\n\n\t// Close closes the log.\n\tClose(c context.Context, stepID int64) error\n}\n"
  },
  {
    "path": "server/model/agent.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"encoding/base32\"\n\t\"fmt\"\n\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n)\n\ntype Agent struct {\n\tID           int64             `json:\"id\"            xorm:\"pk autoincr 'id'\"`\n\tCreated      int64             `json:\"created\"       xorm:\"created\"`\n\tUpdated      int64             `json:\"updated\"       xorm:\"updated\"`\n\tName         string            `json:\"name\"          xorm:\"name\"`\n\tOwnerID      int64             `json:\"owner_id\"      xorm:\"'owner_id'\"`\n\tToken        string            `json:\"token\"         xorm:\"token\"`\n\tLastContact  int64             `json:\"last_contact\"  xorm:\"last_contact\"`\n\tLastWork     int64             `json:\"last_work\"     xorm:\"last_work\"` // last time the agent did something, this value is used to determine if the agent is still doing work used by the autoscaler\n\tPlatform     string            `json:\"platform\"      xorm:\"VARCHAR(100) 'platform'\"`\n\tBackend      string            `json:\"backend\"       xorm:\"VARCHAR(100) 'backend'\"`\n\tCapacity     int32             `json:\"capacity\"      xorm:\"capacity\"`\n\tVersion      string            `json:\"version\"       xorm:\"'version'\"`\n\tNoSchedule   bool              `json:\"no_schedule\"   xorm:\"no_schedule\"`\n\tCustomLabels map[string]string `json:\"custom_labels\" xorm:\"JSON 'custom_labels'\"`\n\t// OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default\n\tOrgID int64 `json:\"org_id\"        xorm:\"INDEX 'org_id'\"`\n} //\t@name\tAgent\n\nconst (\n\tIDNotSet = -1\n)\n\n// TableName return database table name for xorm.\nfunc (Agent) TableName() string {\n\treturn \"agents\"\n}\n\nfunc (a *Agent) IsSystemAgent() bool {\n\treturn a.OwnerID == IDNotSet &&\n\t\ta.OrgID == IDNotSet\n}\n\nfunc GenerateNewAgentToken() string {\n\treturn base32.StdEncoding.EncodeToString(random.GetRandomBytes(32))\n}\n\nfunc (a *Agent) GetServerLabels() (map[string]string, error) {\n\tfilters := make(map[string]string)\n\n\t// enforce filters for user and organization agents\n\tif a.OrgID != IDNotSet {\n\t\tfilters[pipeline.LabelFilterOrg] = fmt.Sprintf(\"%d\", a.OrgID)\n\t} else {\n\t\tfilters[pipeline.LabelFilterOrg] = \"*\"\n\t}\n\n\treturn filters, nil\n}\n\nfunc (a *Agent) CanAccessRepo(repo *Repo) bool {\n\t// global agent\n\tif a.OrgID == IDNotSet {\n\t\treturn true\n\t}\n\n\t// agent has access to the organization\n\tif a.OrgID == repo.OrgID {\n\t\treturn true\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "server/model/agent_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n)\n\nfunc TestGenerateNewAgentToken(t *testing.T) {\n\ttoken1 := GenerateNewAgentToken()\n\ttoken2 := GenerateNewAgentToken()\n\n\tassert.NotEmpty(t, token1)\n\tassert.NotEmpty(t, token2)\n\tassert.NotEqual(t, token1, token2)\n\tassert.Len(t, token1, 56)\n}\n\nfunc TestAgent_GetServerLabels(t *testing.T) {\n\tt.Run(\"EmptyAgent\", func(t *testing.T) {\n\t\tagent := &Agent{}\n\t\tfilters, err := agent.GetServerLabels()\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, map[string]string{\n\t\t\tpipeline.LabelFilterOrg: \"0\",\n\t\t}, filters)\n\t})\n\n\tt.Run(\"GlobalAgent\", func(t *testing.T) {\n\t\tagent := &Agent{\n\t\t\tOrgID: IDNotSet,\n\t\t}\n\t\tfilters, err := agent.GetServerLabels()\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, map[string]string{\n\t\t\tpipeline.LabelFilterOrg: \"*\",\n\t\t}, filters)\n\t})\n\n\tt.Run(\"OrgAgent\", func(t *testing.T) {\n\t\tagent := &Agent{\n\t\t\tOrgID: 123,\n\t\t}\n\t\tfilters, err := agent.GetServerLabels()\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, map[string]string{\n\t\t\tpipeline.LabelFilterOrg: \"123\",\n\t\t}, filters)\n\t})\n}\n\nfunc TestAgent_CanAccessRepo(t *testing.T) {\n\trepo := &Repo{ID: 123, OrgID: 12}\n\totherRepo := &Repo{ID: 456, OrgID: 45}\n\n\tt.Run(\"EmptyAgent\", func(t *testing.T) {\n\t\tagent := &Agent{}\n\t\tassert.False(t, agent.CanAccessRepo(repo))\n\t})\n\n\tt.Run(\"GlobalAgent\", func(t *testing.T) {\n\t\tagent := &Agent{\n\t\t\tOrgID: IDNotSet,\n\t\t}\n\n\t\tassert.True(t, agent.CanAccessRepo(repo))\n\t})\n\n\tt.Run(\"OrgAgent\", func(t *testing.T) {\n\t\tagent := &Agent{\n\t\t\tOrgID: 12,\n\t\t}\n\t\tassert.True(t, agent.CanAccessRepo(repo))\n\t\tassert.False(t, agent.CanAccessRepo(otherRepo))\n\t})\n}\n"
  },
  {
    "path": "server/model/commit.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype Commit struct {\n\tSHA      string\n\tForgeURL string\n}\n"
  },
  {
    "path": "server/model/config.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Config represents a pipeline configuration.\ntype Config struct {\n\tID     int64  `json:\"-\"    xorm:\"pk autoincr 'id'\"`\n\tRepoID int64  `json:\"-\"    xorm:\"UNIQUE(s) 'repo_id'\"`\n\tHash   string `json:\"hash\" xorm:\"UNIQUE(s) 'hash'\"`\n\tName   string `json:\"name\" xorm:\"UNIQUE(s) 'name'\"`\n\tData   []byte `json:\"data\" xorm:\"LONGBLOB 'data'\"`\n} //\t@name\tConfig\n\nfunc (Config) TableName() string {\n\treturn \"configs\"\n}\n\n// PipelineConfig is the n:n relation between Pipeline and Config.\ntype PipelineConfig struct {\n\tConfigID   int64 `json:\"-\"   xorm:\"UNIQUE(s) NOT NULL 'config_id'\"`\n\tPipelineID int64 `json:\"-\"   xorm:\"UNIQUE(s) NOT NULL 'pipeline_id'\"`\n}\n\nfunc (PipelineConfig) TableName() string {\n\treturn \"pipeline_configs\"\n}\n"
  },
  {
    "path": "server/model/const.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n)\n\ntype WebhookEvent string //\t@name\tWebhookEvent\n\nconst (\n\tEventPush         WebhookEvent = \"push\"\n\tEventPull         WebhookEvent = \"pull_request\"\n\tEventPullClosed   WebhookEvent = \"pull_request_closed\"\n\tEventPullMetadata WebhookEvent = \"pull_request_metadata\"\n\tEventTag          WebhookEvent = \"tag\"\n\tEventRelease      WebhookEvent = \"release\"\n\tEventDeploy       WebhookEvent = \"deployment\"\n\tEventCron         WebhookEvent = \"cron\"\n\tEventManual       WebhookEvent = \"manual\"\n)\n\ntype WebhookEventList []WebhookEvent\n\nfunc (wel WebhookEventList) Len() int           { return len(wel) }\nfunc (wel WebhookEventList) Swap(i, j int)      { wel[i], wel[j] = wel[j], wel[i] }\nfunc (wel WebhookEventList) Less(i, j int) bool { return wel[i] < wel[j] }\n\nvar ErrInvalidWebhookEvent = errors.New(\"invalid webhook event\")\n\nfunc (s WebhookEvent) Validate() error {\n\tswitch s {\n\tcase EventPush, EventPull, EventPullClosed, EventPullMetadata, EventTag, EventRelease, EventDeploy, EventCron, EventManual:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"%w: %s\", ErrInvalidWebhookEvent, s)\n\t}\n}\n\n// StatusValue represent pipeline states woodpecker know.\ntype StatusValue string //\t@name\tStatusValue\n\nconst (\n\tStatusSkipped  StatusValue = \"skipped\"  // skipped as per condition of current workflow failed/success state\n\tStatusPending  StatusValue = \"pending\"  // pending to be executed\n\tStatusRunning  StatusValue = \"running\"  // currently running\n\tStatusSuccess  StatusValue = \"success\"  // successfully finished\n\tStatusFailure  StatusValue = \"failure\"  // failed to finish (exit code != 0)\n\tStatusKilled   StatusValue = \"killed\"   // killed by user\n\tStatusCanceled StatusValue = \"canceled\" // canceled but hasn't been started\n\tStatusError    StatusValue = \"error\"    // error with the config / while parsing / some other system problem\n\tStatusBlocked  StatusValue = \"blocked\"  // waiting for approval\n\tStatusDeclined StatusValue = \"declined\" // blocked and declined\n\tStatusCreated  StatusValue = \"created\"  // created / internal use only\n)\n\nvar ErrInvalidStatusValue = errors.New(\"invalid status value\")\n\nfunc (s StatusValue) Validate() error {\n\tswitch s {\n\tcase StatusSkipped, StatusPending, StatusRunning, StatusSuccess, StatusFailure, StatusKilled, StatusCanceled, StatusError, StatusBlocked, StatusDeclined, StatusCreated:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"%w: %s\", ErrInvalidStatusValue, s)\n\t}\n}\n\n// RepoVisibility represent to what state a repo in woodpecker is visible to others.\ntype RepoVisibility string //\t@name\tRepoVisibility\n\nconst (\n\tVisibilityPublic   RepoVisibility = \"public\"\n\tVisibilityPrivate  RepoVisibility = \"private\"\n\tVisibilityInternal RepoVisibility = \"internal\"\n)\n"
  },
  {
    "path": "server/model/cron.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gdgvda/cron\"\n)\n\ntype Cron struct {\n\tID        int64             `json:\"id\"         xorm:\"pk autoincr 'id'\"`\n\tName      string            `json:\"name\"       xorm:\"name UNIQUE(s) INDEX\"`\n\tRepoID    int64             `json:\"repo_id\"    xorm:\"repo_id UNIQUE(s) INDEX\"`\n\tCreatorID int64             `json:\"creator_id\" xorm:\"creator_id INDEX\"` // TODO: drop with next major version\n\tNextExec  int64             `json:\"next_exec\"  xorm:\"next_exec\"`\n\tSchedule  string            `json:\"schedule\"   xorm:\"schedule NOT NULL\"` //\t@weekly,\t3min, ...\n\tCreated   int64             `json:\"created\"    xorm:\"created NOT NULL DEFAULT 0\"`\n\tBranch    string            `json:\"branch\"     xorm:\"branch\"`\n\tEnabled   bool              `json:\"enabled\"    xorm:\"enabled NOT NULL DEFAULT TRUE\"`\n\tVariables map[string]string `json:\"variables\" xorm:\"json 'variables'\"`\n} //\t@name\tCron\n\n// TableName returns the database table name for xorm.\nfunc (Cron) TableName() string {\n\treturn \"crons\"\n}\n\n// Validate ensures cron has a valid name and schedule.\nfunc (c *Cron) Validate() error {\n\tif c.Name == \"\" {\n\t\treturn fmt.Errorf(\"name is required\")\n\t}\n\n\tif c.Schedule == \"\" {\n\t\treturn fmt.Errorf(\"schedule is required\")\n\t}\n\n\tparser, err := cron.NewDefaultParser(cron.StandardOptions)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't create parser: %w\", err)\n\t}\n\n\t_, err = parser.Parse(c.Schedule)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't parse schedule: %w\", err)\n\t}\n\n\treturn nil\n}\n\ntype CronPatch struct {\n\tName      *string           `json:\"name\"`\n\tSchedule  *string           `json:\"schedule\"`\n\tBranch    *string           `json:\"branch\"`\n\tEnabled   *bool             `json:\"enabled\"`\n\tVariables map[string]string `json:\"variables\"`\n} //\t@name\tCronPatch\n"
  },
  {
    "path": "server/model/environ.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"errors\"\n)\n\nvar (\n\terrEnvironNameInvalid  = errors.New(\"invalid Environment Variable Name\")\n\terrEnvironValueInvalid = errors.New(\"invalid Environment Variable Value\")\n)\n\n// Environ represents an environment variable.\ntype Environ struct {\n\tName  string `json:\"name\"`\n\tValue string `json:\"value,omitempty\"`\n}\n\n// Validate validates the required fields and formats.\nfunc (e *Environ) Validate() error {\n\tswitch {\n\tcase len(e.Name) == 0:\n\t\treturn errEnvironNameInvalid\n\tcase len(e.Value) == 0:\n\t\treturn errEnvironValueInvalid\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// Copy makes a copy of the environment variable without the value.\nfunc (e *Environ) Copy() *Environ {\n\treturn &Environ{\n\t\tName: e.Name,\n\t}\n}\n"
  },
  {
    "path": "server/model/event.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// EventType defines the possible types of pipeline events.\ntype EventType string\n\n// Event represents a pipeline event.\ntype Event struct {\n\tRepo     Repo     `json:\"repo\"`\n\tPipeline Pipeline `json:\"pipeline\"`\n}\n"
  },
  {
    "path": "server/model/feed.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Feed represents an item in the user's feed or timeline.\ntype Feed struct {\n\tRepoID   int64  `json:\"repo_id\"                 xorm:\"repo_id\"`\n\tID       int64  `json:\"id,omitempty\"            xorm:\"pipeline_id\"`\n\tNumber   int64  `json:\"number,omitempty\"        xorm:\"pipeline_number\"`\n\tEvent    string `json:\"event,omitempty\"         xorm:\"pipeline_event\"`\n\tStatus   string `json:\"status,omitempty\"        xorm:\"pipeline_status\"`\n\tCreated  int64  `json:\"created,omitempty\"       xorm:\"pipeline_created\"`\n\tStarted  int64  `json:\"started,omitempty\"       xorm:\"pipeline_started\"`\n\tFinished int64  `json:\"finished,omitempty\"      xorm:\"pipeline_finished\"`\n\tCommit   string `json:\"commit,omitempty\"        xorm:\"pipeline_commit\"`\n\tBranch   string `json:\"branch,omitempty\"        xorm:\"pipeline_branch\"`\n\tRef      string `json:\"ref,omitempty\"           xorm:\"pipeline_ref\"`\n\tRefspec  string `json:\"refspec,omitempty\"       xorm:\"pipeline_refspec\"`\n\tTitle    string `json:\"title,omitempty\"         xorm:\"pipeline_title\"`\n\tMessage  string `json:\"message,omitempty\"       xorm:\"pipeline_message\"`\n\tAuthor   string `json:\"author,omitempty\"        xorm:\"pipeline_author\"`\n\tAvatar   string `json:\"author_avatar,omitempty\" xorm:\"pipeline_avatar\"`\n\tEmail    string `json:\"author_email,omitempty\"  xorm:\"pipeline_email\"`\n} //\t@name\tFeed\n"
  },
  {
    "path": "server/model/forge.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype ForgeType string\n\nconst (\n\tForgeTypeGithub              ForgeType = \"github\"\n\tForgeTypeGitlab              ForgeType = \"gitlab\"\n\tForgeTypeGitea               ForgeType = \"gitea\"\n\tForgeTypeForgejo             ForgeType = \"forgejo\"\n\tForgeTypeBitbucket           ForgeType = \"bitbucket\"\n\tForgeTypeBitbucketDatacenter ForgeType = \"bitbucket-dc\"\n\tForgeTypeAddon               ForgeType = \"addon\"\n)\n\ntype Forge struct {\n\tID                int64          `json:\"id\"                           xorm:\"pk autoincr 'id'\"`\n\tType              ForgeType      `json:\"type\"                         xorm:\"VARCHAR(250)\"`\n\tURL               string         `json:\"url\"                          xorm:\"VARCHAR(500) 'url'\"`\n\tOAuthClientID     string         `json:\"client,omitempty\"             xorm:\"VARCHAR(250) 'oauth_client_id'\"`\n\tOAuthClientSecret string         `json:\"-\"                            xorm:\"VARCHAR(250) 'oauth_client_secret'\"` // do not expose client secret\n\tSkipVerify        bool           `json:\"skip_verify,omitempty\"        xorm:\"bool\"`\n\tOAuthHost         string         `json:\"oauth_host,omitempty\"         xorm:\"VARCHAR(250) 'oauth_host'\"` // public url for oauth if different from url\n\tAdditionalOptions map[string]any `json:\"additional_options,omitempty\" xorm:\"json\"`\n} //\t@name\tForge\n\n// TableName returns the database table name for xorm.\nfunc (Forge) TableName() string {\n\treturn \"forges\"\n}\n\n// PublicCopy returns a copy of the forge without sensitive information and technical details.\nfunc (f *Forge) PublicCopy() *Forge {\n\tforge := &Forge{\n\t\tID:   f.ID,\n\t\tType: f.Type,\n\t\tURL:  f.URL,\n\t}\n\n\treturn forge\n}\n\n// ForgeWithOAuthClientSecret allows to update the client secret.\ntype ForgeWithOAuthClientSecret struct {\n\tForge\n\tOAuthClientSecret string `json:\"oauth_client_secret\"`\n} //\t@name\tForgeWithOAuthClientSecret\n"
  },
  {
    "path": "server/model/log.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// LogEntryType identifies the type of line in the logs.\ntype LogEntryType int //\t@name\tLogEntryType\n\nconst (\n\tLogEntryStdout LogEntryType = iota\n\tLogEntryStderr\n\tLogEntryExitCode\n\tLogEntryMetadata\n\tLogEntryProgress\n)\n\ntype LogEntry struct {\n\tID      int64        `json:\"id\"       xorm:\"pk autoincr 'id'\"`\n\tStepID  int64        `json:\"step_id\"  xorm:\"INDEX 'step_id'\"`\n\tTime    int64        `json:\"time\"     xorm:\"'time'\"`\n\tLine    int          `json:\"line\"     xorm:\"'line'\"`\n\tData    []byte       `json:\"data\"     xorm:\"LONGBLOB\"`\n\tCreated int64        `json:\"-\"        xorm:\"created\"`\n\tType    LogEntryType `json:\"type\"     xorm:\"'type'\"`\n} //\t@name\tLogEntry\n\n// TODO: store info what specific command the line belongs to (must be optional and impl. by backend)\n\nfunc (LogEntry) TableName() string {\n\treturn \"log_entries\"\n}\n"
  },
  {
    "path": "server/model/netrc.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype Netrc struct {\n\tMachine  string    `json:\"machine\"`\n\tLogin    string    `json:\"login\"`\n\tPassword string    `json:\"password\"`\n\tType     ForgeType `json:\"type\"`\n}\n"
  },
  {
    "path": "server/model/org.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Org represents an organization.\ntype Org struct {\n\tID      int64  `json:\"id,omitempty\"       xorm:\"pk autoincr 'id'\"`\n\tForgeID int64  `json:\"forge_id,omitempty\" xorm:\"forge_id UNIQUE(s)\"`\n\tName    string `json:\"name\"               xorm:\"'name' UNIQUE(s)\"`\n\tIsUser  bool   `json:\"is_user\"            xorm:\"is_user\"`\n\t// if name lookup has to check for membership or not\n\tPrivate bool `json:\"-\"                    xorm:\"private\"`\n} //\t@name\tOrg\n\n// TableName return database table name for xorm.\nfunc (Org) TableName() string {\n\treturn \"orgs\"\n}\n"
  },
  {
    "path": "server/model/pagination.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype ListOptions struct {\n\tAll     bool\n\tPage    int\n\tPerPage int\n}\n\nfunc ApplyPagination[T any](d *ListOptions, slice []T) []T {\n\tif d.All {\n\t\treturn slice\n\t}\n\tif d.PerPage*(d.Page-1) > len(slice) {\n\t\treturn []T{}\n\t}\n\tif d.PerPage*(d.Page) > len(slice) {\n\t\treturn slice[d.PerPage*(d.Page-1):]\n\t}\n\treturn slice[d.PerPage*(d.Page-1) : d.PerPage*(d.Page)]\n}\n\nfunc (d *ListOptions) Encode() string {\n\tvar query []string\n\n\tif d.Page != 0 {\n\t\tquery = append(query, fmt.Sprintf(\"page=%d\", d.Page))\n\t}\n\n\tif d.PerPage != 0 {\n\t\tquery = append(query, fmt.Sprintf(\"per_page=%d\", d.PerPage))\n\t}\n\n\tif d.All {\n\t\tquery = append(query, \"all=true\")\n\t}\n\n\treturn strings.Join(query, \"&\")\n}\n"
  },
  {
    "path": "server/model/pagination_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestApplyPagination(t *testing.T) {\n\texample := []int{\n\t\t0, 1, 2,\n\t}\n\n\tassert.Equal(t, ApplyPagination(&ListOptions{All: true}, example), example)\n\tassert.Equal(t, ApplyPagination(&ListOptions{Page: 1, PerPage: 1}, example), []int{0})\n\tassert.Equal(t, ApplyPagination(&ListOptions{Page: 2, PerPage: 2}, example), []int{2})\n\tassert.Equal(t, ApplyPagination(&ListOptions{Page: 3, PerPage: 1}, example), []int{2})\n\tassert.Equal(t, ApplyPagination(&ListOptions{Page: 4, PerPage: 1}, example), []int{})\n\tassert.Equal(t, ApplyPagination(&ListOptions{Page: 5, PerPage: 1}, example), []int{})\n}\n"
  },
  {
    "path": "server/model/perm.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Perm defines a repository permission for an individual user.\ntype Perm struct {\n\tUserID  int64 `json:\"-\"       xorm:\"UNIQUE(s) INDEX NOT NULL 'user_id'\"`\n\tRepoID  int64 `json:\"-\"       xorm:\"UNIQUE(s) INDEX NOT NULL 'repo_id'\"`\n\tPull    bool  `json:\"pull\"    xorm:\"pull\"`\n\tPush    bool  `json:\"push\"    xorm:\"push\"`\n\tAdmin   bool  `json:\"admin\"   xorm:\"admin\"`\n\tSynced  int64 `json:\"synced\"  xorm:\"synced\"`\n\tCreated int64 `json:\"created\" xorm:\"created\"`\n\tUpdated int64 `json:\"updated\" xorm:\"updated\"`\n} //\t@name\tPerm\n\n// TableName return database table name for xorm.\nfunc (Perm) TableName() string {\n\treturn \"perms\"\n}\n\n// OrgPerm defines an organization permission for an individual user.\ntype OrgPerm struct {\n\tMember bool `json:\"member\"`\n\tAdmin  bool `json:\"admin\"`\n} //\t@name\tOrgPerm\n"
  },
  {
    "path": "server/model/pipeline.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n)\n\ntype Pipeline struct {\n\tID                   int64                   `json:\"id\"                      xorm:\"pk autoincr 'id'\"`\n\tRepoID               int64                   `json:\"-\"                       xorm:\"UNIQUE(s) INDEX 'repo_id'\"`\n\tNumber               int64                   `json:\"number\"                  xorm:\"UNIQUE(s) 'number'\"`\n\tAuthor               string                  `json:\"author\"                  xorm:\"INDEX 'author'\"`\n\tParent               int64                   `json:\"parent\"                  xorm:\"parent\"`\n\tEvent                WebhookEvent            `json:\"event\"                   xorm:\"event\"`\n\tEventReason          []string                `json:\"event_reason\"            xorm:\"json 'event_reason'\"`\n\tStatus               StatusValue             `json:\"status\"                  xorm:\"INDEX 'status'\"`\n\tErrors               []*errors.PipelineError `json:\"errors\"                  xorm:\"json 'errors'\"`\n\tCreated              int64                   `json:\"created\"                 xorm:\"'created' NOT NULL DEFAULT 0 created\"`\n\tUpdated              int64                   `json:\"updated\"                 xorm:\"'updated' NOT NULL DEFAULT 0 updated\"`\n\tStarted              int64                   `json:\"started\"                 xorm:\"started\"`\n\tFinished             int64                   `json:\"finished\"                xorm:\"finished\"`\n\tDeployTo             string                  `json:\"deploy_to\"               xorm:\"deploy\"`\n\tDeployTask           string                  `json:\"deploy_task\"             xorm:\"deploy_task\"`\n\tCommit               string                  `json:\"commit\"                  xorm:\"commit\"`\n\tBranch               string                  `json:\"branch\"                  xorm:\"branch\"`\n\tRef                  string                  `json:\"ref\"                     xorm:\"ref\"`\n\tRefspec              string                  `json:\"refspec\"                 xorm:\"refspec\"`\n\tTitle                string                  `json:\"title\"                   xorm:\"title\"`\n\tMessage              string                  `json:\"message\"                 xorm:\"TEXT 'message'\"`\n\tTimestamp            int64                   `json:\"timestamp\"               xorm:\"'timestamp'\"`\n\tSender               string                  `json:\"sender\"                  xorm:\"sender\"` // uses reported user for webhooks and name of cron for cron pipelines\n\tAvatar               string                  `json:\"author_avatar\"           xorm:\"varchar(500) avatar\"`\n\tEmail                string                  `json:\"author_email\"            xorm:\"varchar(500) email\"`\n\tForgeURL             string                  `json:\"forge_url\"               xorm:\"forge_url\"`\n\tReviewer             string                  `json:\"reviewed_by\"             xorm:\"reviewer\"`\n\tReviewed             int64                   `json:\"reviewed\"                xorm:\"reviewed\"`\n\tCancelInfo           *CancelInfo             `json:\"cancel_info,omitempty\"   xorm:\"json 'cancel_info'\"`\n\tWorkflows            []*Workflow             `json:\"workflows,omitempty\"     xorm:\"-\"`\n\tChangedFiles         []string                `json:\"changed_files,omitempty\" xorm:\"LONGTEXT 'changed_files'\"`\n\tAdditionalVariables  map[string]string       `json:\"variables,omitempty\"     xorm:\"json 'additional_variables'\"`\n\tPullRequestLabels    []string                `json:\"pr_labels,omitempty\"     xorm:\"json 'pr_labels'\"`\n\tPullRequestMilestone string                  `json:\"pr_milestone,omitempty\"  xorm:\"pr_milestone\"`\n\tCron                 string                  `json:\"cron,omitempty\"          xorm:\"cron\"` // name of the cron job\n\tIsPrerelease         bool                    `json:\"is_prerelease,omitempty\" xorm:\"is_prerelease\"`\n\tFromFork             bool                    `json:\"from_fork,omitempty\"     xorm:\"from_fork\"`\n\tVersion              string                  `json:\"version\"                 xorm:\"'version'\"`\n}\n\n// APIPipeline TODO remove deprecated properties in next major.\ntype APIPipeline struct {\n\t*Pipeline\n} //\t@name\tPipeline\n\n// TableName return database table name for xorm.\nfunc (Pipeline) TableName() string {\n\treturn \"pipelines\"\n}\n\nfunc (p *Pipeline) ToAPIModel() *APIPipeline {\n\tap := &APIPipeline{\n\t\tPipeline: p,\n\t}\n\n\tswitch p.Event { //nolint:gocritic\n\tcase EventCron:\n\t\tap.Message = p.Cron\n\t\tap.Sender = p.Cron\n\t}\n\n\treturn ap\n}\n\ntype PipelineFilter struct {\n\tBefore      int64\n\tAfter       int64\n\tBranch      string\n\tEvents      []WebhookEvent\n\tRefContains string\n\tStatus      StatusValue\n}\n\n// IsMultiPipeline checks if step list contain more than one parent step.\nfunc (p Pipeline) IsMultiPipeline() bool {\n\treturn len(p.Workflows) > 1\n}\n\n// IsPullRequest checks if it's a PR event.\nfunc (p Pipeline) IsPullRequest() bool {\n\treturn metadata.Event(p.Event).IsPull()\n}\n\ntype PipelineOptions struct {\n\tBranch    string            `json:\"branch\"`\n\tVariables map[string]string `json:\"variables\"`\n} //\t@name\tPipelineOptions\n\ntype CancelInfo struct {\n\tCanceledByUser string `json:\"canceled_by_user,omitempty\"`\n\tSupersededBy   int64  `json:\"superseded_by,omitempty\"`\n\tCanceledByStep string `json:\"canceled_by_step,omitempty\"`\n} //\t@name\tCancelInfo\n"
  },
  {
    "path": "server/model/pull_request.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype PullRequest struct {\n\tIndex ForgeRemoteID `json:\"index\"`\n\tTitle string        `json:\"title\"`\n} //\t@name\tPullRequest\n"
  },
  {
    "path": "server/model/queue.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// QueueTask represents a task in the queue with additional API-specific fields.\ntype QueueTask struct {\n\tTask\n\tPipelineNumber int64  `json:\"pipeline_number\"`\n\tAgentName      string `json:\"agent_name\"`\n}\n\n// QueueInfo represents the response structure for queue information API.\ntype QueueInfo struct {\n\tPending       []QueueTask `json:\"pending\"`\n\tWaitingOnDeps []QueueTask `json:\"waiting_on_deps\"`\n\tRunning       []QueueTask `json:\"running\"`\n\tStats         struct {\n\t\tWorkerCount        int `json:\"worker_count\"`\n\t\tPendingCount       int `json:\"pending_count\"`\n\t\tWaitingOnDepsCount int `json:\"waiting_on_deps_count\"`\n\t\tRunningCount       int `json:\"running_count\"`\n\t} `json:\"stats\"`\n\tPaused bool `json:\"paused\"`\n} //\t@name\tQueueInfo\n"
  },
  {
    "path": "server/model/redirection.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype Redirection struct {\n\tID       int64  `xorm:\"pk autoincr 'id'\"`\n\tRepoID   int64  `xorm:\"'repo_id'\"`\n\tFullName string `xorm:\"UNIQUE INDEX 'repo_full_name'\"`\n}\n\nfunc (r Redirection) TableName() string {\n\treturn \"redirections\"\n}\n"
  },
  {
    "path": "server/model/registry.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"errors\"\n\t\"net/url\"\n)\n\nvar (\n\terrRegistryAddressInvalid  = errors.New(\"invalid registry address\")\n\terrRegistryUsernameInvalid = errors.New(\"invalid registry username\")\n\terrRegistryPasswordInvalid = errors.New(\"invalid registry password\")\n)\n\n// Registry represents a docker registry with credentials.\ntype Registry struct {\n\tID       int64  `json:\"id\"       xorm:\"pk autoincr 'id'\"`\n\tOrgID    int64  `json:\"org_id\"   xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'org_id'\"`\n\tRepoID   int64  `json:\"repo_id\"  xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'repo_id'\"`\n\tAddress  string `json:\"address\"  xorm:\"NOT NULL UNIQUE(s) INDEX 'address'\"`\n\tUsername string `json:\"username\" xorm:\"varchar(2000) 'username'\"`\n\tPassword string `json:\"password\" xorm:\"TEXT 'password'\"`\n\tReadOnly bool   `json:\"readonly\" xorm:\"-\"`\n} //\t@name\tRegistry\n\nfunc (r Registry) TableName() string {\n\treturn \"registries\"\n}\n\n// Global registry.\nfunc (r Registry) IsGlobal() bool {\n\treturn r.RepoID == 0 && r.OrgID == 0\n}\n\n// Organization registry.\nfunc (r Registry) IsOrganization() bool {\n\treturn r.RepoID == 0 && r.OrgID != 0\n}\n\n// Repository registry.\nfunc (r Registry) IsRepository() bool {\n\treturn r.RepoID != 0 && r.OrgID == 0\n}\n\n// Validate validates the registry information.\nfunc (r *Registry) Validate() error {\n\tswitch {\n\tcase len(r.Address) == 0:\n\t\treturn errRegistryAddressInvalid\n\tcase len(r.Username) == 0:\n\t\treturn errRegistryUsernameInvalid\n\tcase len(r.Password) == 0:\n\t\treturn errRegistryPasswordInvalid\n\t}\n\n\t_, err := url.Parse(r.Address)\n\treturn err\n}\n\n// Copy makes a copy of the registry without the password.\nfunc (r *Registry) Copy() *Registry {\n\treturn &Registry{\n\t\tID:       r.ID,\n\t\tOrgID:    r.OrgID,\n\t\tRepoID:   r.RepoID,\n\t\tAddress:  r.Address,\n\t\tUsername: r.Username,\n\t\tReadOnly: r.ReadOnly,\n\t}\n}\n"
  },
  {
    "path": "server/model/repo.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype ApprovalMode string\n\nconst (\n\tRequireApprovalNone         ApprovalMode = \"none\"          // require approval for no events\n\tRequireApprovalForks        ApprovalMode = \"forks\"         // require approval for PRs from forks (default)\n\tRequireApprovalPullRequests ApprovalMode = \"pull_requests\" // require approval for all PRs\n\tRequireApprovalAllEvents    ApprovalMode = \"all_events\"    // require approval for all external events\n)\n\nfunc (mode ApprovalMode) Valid() bool {\n\tswitch mode {\n\tcase RequireApprovalNone,\n\t\tRequireApprovalForks,\n\t\tRequireApprovalPullRequests,\n\t\tRequireApprovalAllEvents:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// Repo represents a repository.\ntype Repo struct {\n\tID      int64 `json:\"id,omitempty\"                    xorm:\"pk autoincr 'id'\"`\n\tUserID  int64 `json:\"-\"                               xorm:\"INDEX 'user_id'\"`\n\tForgeID int64 `json:\"forge_id,omitempty\"              xorm:\"UNIQUE(forge) UNIQUE(name) UNIQUE(full_name) forge_id\"`\n\t// ForgeRemoteID is the unique identifier for the repository on the forge.\n\tForgeRemoteID                ForgeRemoteID        `json:\"forge_remote_id\"                 xorm:\"UNIQUE(forge) forge_remote_id\"`\n\tOrgID                        int64                `json:\"org_id\"                          xorm:\"INDEX 'org_id'\"`\n\tOwner                        string               `json:\"owner\"                           xorm:\"UNIQUE(name) 'owner'\"`\n\tName                         string               `json:\"name\"                            xorm:\"UNIQUE(name) 'name'\"`\n\tFullName                     string               `json:\"full_name\"                       xorm:\"UNIQUE(full_name) 'full_name'\"`\n\tAvatar                       string               `json:\"avatar_url,omitempty\"            xorm:\"varchar(500) 'avatar'\"`\n\tForgeURL                     string               `json:\"forge_url,omitempty\"             xorm:\"varchar(1000) 'forge_url'\"`\n\tClone                        string               `json:\"clone_url,omitempty\"             xorm:\"varchar(1000) 'clone'\"`\n\tCloneSSH                     string               `json:\"clone_url_ssh\"                   xorm:\"varchar(1000) 'clone_ssh'\"`\n\tBranch                       string               `json:\"default_branch,omitempty\"        xorm:\"varchar(500) 'branch'\"`\n\tPREnabled                    bool                 `json:\"pr_enabled\"                      xorm:\"DEFAULT TRUE 'pr_enabled'\"`\n\tTimeout                      int64                `json:\"timeout,omitempty\"               xorm:\"timeout\"`\n\tVisibility                   RepoVisibility       `json:\"visibility\"                      xorm:\"varchar(10) 'visibility'\"`\n\tIsSCMPrivate                 bool                 `json:\"private\"                         xorm:\"private\"`\n\tTrusted                      TrustedConfiguration `json:\"trusted\"                         xorm:\"json 'trusted'\"`\n\tRequireApproval              ApprovalMode         `json:\"require_approval\"                xorm:\"varchar(50) require_approval\"`\n\tApprovalAllowedUsers         []string             `json:\"approval_allowed_users\"          xorm:\"json approval_allowed_users\"`\n\tIsActive                     bool                 `json:\"active\"                          xorm:\"active\"`\n\tAllowPull                    bool                 `json:\"allow_pr\"                        xorm:\"allow_pr\"`\n\tAllowDeploy                  bool                 `json:\"allow_deploy\"                    xorm:\"allow_deploy\"`\n\tConfig                       string               `json:\"config_file\"                     xorm:\"varchar(500) 'config_path'\"`\n\tHash                         string               `json:\"-\"                               xorm:\"varchar(500) 'hash'\"`\n\tCancelPreviousPipelineEvents []WebhookEvent       `json:\"cancel_previous_pipeline_events\" xorm:\"json 'cancel_previous_pipeline_events'\"`\n\tNetrcTrustedPlugins          []string             `json:\"netrc_trusted\"                   xorm:\"json 'netrc_trusted'\"`\n\tConfigExtensionEndpoint      string               `json:\"config_extension_endpoint\"       xorm:\"varchar(500) 'config_extension_endpoint'\"`\n\tConfigExtensionExclusive     bool                 `json:\"config_extension_exclusive\"      xorm:\"DEFAULT FALSE 'config_extension_exclusive'\"`\n\tConfigExtensionNetrc         bool                 `json:\"config_extension_netrc\"          xorm:\"DEFAULT FALSE 'config_extension_netrc'\"`\n\tRegistryExtensionEndpoint    string               `json:\"registry_extension_endpoint\"     xorm:\"varchar(500) 'registry_extension_endpoint'\"`\n\tRegistryExtensionNetrc       bool                 `json:\"registry_extension_netrc\"          xorm:\"DEFAULT FALSE 'registry_extension_netrc'\"`\n\tSecretExtensionEndpoint      string               `json:\"secret_extension_endpoint\"       xorm:\"varchar(500) 'secret_extension_endpoint'\"`\n\tSecretExtensionNetrc         bool                 `json:\"secret_extension_netrc\"          xorm:\"DEFAULT FALSE 'secret_extension_netrc'\"`\n\n\t// Rest API Only\n\n\t// HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id\n\tHasForgeNameConflict bool `json:\"has_forge_name_conflict,omitempty\"    xorm:\"-\"`\n\n\t// HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore\n\tHasNoForgeRepo bool `      json:\"has_no_forge_repo,omitempty\"          xorm:\"-\"`\n\n\t// internal usage\n\n\tPerm *Perm `json:\"-\"    xorm:\"-\"`\n} //\t@name\tRepo\n\n// TableName return database table name for xorm.\nfunc (Repo) TableName() string {\n\treturn \"repos\"\n}\n\ntype RepoFilter struct {\n\tName string\n}\n\nfunc (r *Repo) ResetVisibility() {\n\tr.Visibility = VisibilityPublic\n\tif r.IsSCMPrivate {\n\t\tr.Visibility = VisibilityPrivate\n\t}\n}\n\n// ParseRepo parses the repository owner and name from a string.\nfunc ParseRepo(str string) (user, repo string, err error) {\n\tbefore, after, _ := strings.Cut(str, \"/\")\n\tif before == \"\" || after == \"\" {\n\t\terr = fmt.Errorf(\"invalid or missing repository (e.g. octocat/hello-world)\")\n\t\treturn user, repo, err\n\t}\n\tuser = before\n\trepo = after\n\treturn user, repo, err\n}\n\n// Update updates the repository with values from the given Repo.\nfunc (r *Repo) Update(from *Repo) {\n\tif from.ForgeRemoteID.IsValid() {\n\t\tr.ForgeRemoteID = from.ForgeRemoteID\n\t}\n\tr.Owner = from.Owner\n\tr.Name = from.Name\n\tr.FullName = from.FullName\n\tr.Avatar = from.Avatar\n\tr.ForgeURL = from.ForgeURL\n\tr.PREnabled = from.PREnabled\n\tif len(from.Clone) > 0 {\n\t\tr.Clone = from.Clone\n\t}\n\tif len(from.CloneSSH) > 0 {\n\t\tr.CloneSSH = from.CloneSSH\n\t}\n\tr.Branch = from.Branch\n\t// Only propagate visibility when the source supplies it. Some webhook\n\t// payloads (notably GitLab push/tag/merge events) do not include project\n\t// visibility, leaving from.Visibility empty and from.IsSCMPrivate at the\n\t// zero value. Updating the stored fields from those payloads would\n\t// overwrite the authoritative value previously synced from the forge API\n\t// during activation or repair, breaking netrc-protected clones.\n\tif from.Visibility != \"\" {\n\t\tr.Visibility = from.Visibility\n\t\tr.IsSCMPrivate = from.IsSCMPrivate\n\t}\n}\n\n// RepoPatch represents a repository patch object.\ntype RepoPatch struct {\n\tConfig                       *string                    `json:\"config_file,omitempty\"`\n\tRequireApproval              *string                    `json:\"require_approval,omitempty\"`\n\tApprovalAllowedUsers         *[]string                  `json:\"approval_allowed_users,omitempty\"`\n\tTimeout                      *int64                     `json:\"timeout,omitempty\"`\n\tVisibility                   *string                    `json:\"visibility,omitempty\"`\n\tAllowPull                    *bool                      `json:\"allow_pr,omitempty\"`\n\tAllowDeploy                  *bool                      `json:\"allow_deploy,omitempty\"`\n\tCancelPreviousPipelineEvents *[]WebhookEvent            `json:\"cancel_previous_pipeline_events\"`\n\tNetrcTrusted                 *[]string                  `json:\"netrc_trusted\"`\n\tTrusted                      *TrustedConfigurationPatch `json:\"trusted\"`\n\tConfigExtensionEndpoint      *string                    `json:\"config_extension_endpoint,omitempty\"`\n\tConfigExtensionExclusive     *bool                      `json:\"config_extension_exclusive\"`\n\tConfigExtensionNetrc         *bool                      `json:\"config_extension_netrc\"`\n\tRegistryExtensionEndpoint    *string                    `json:\"registry_extension_endpoint,omitempty\"`\n\tRegistryExtensionNetrc       *bool                      `json:\"registry_extension_netrc\"`\n\tSecretExtensionEndpoint      *string                    `json:\"secret_extension_endpoint,omitempty\"`\n\tSecretExtensionNetrc         *bool                      `json:\"secret_extension_netrc,omitempty\"`\n} //\t@name\tRepoPatch\n\ntype ForgeRemoteID string\n\nfunc (r ForgeRemoteID) IsValid() bool {\n\treturn r != \"\" && r != \"0\"\n}\n\ntype TrustedConfiguration struct {\n\tNetwork  bool `json:\"network\"`\n\tVolumes  bool `json:\"volumes\"`\n\tSecurity bool `json:\"security\"`\n}\n\ntype TrustedConfigurationPatch struct {\n\tNetwork  *bool `json:\"network\"`\n\tVolumes  *bool `json:\"volumes\"`\n\tSecurity *bool `json:\"security\"`\n}\n\n// RepoLastPipeline represents a repository with last pipeline execution information.\ntype RepoLastPipeline struct {\n\t*Repo\n\tLastPipeline *APIPipeline `json:\"last_pipeline,omitempty\"`\n} //\t@name\tRepoLastPipeline\n"
  },
  {
    "path": "server/model/repo_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestRepoUpdate_Visibility(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tstored         Repo\n\t\tfrom           Repo\n\t\twantVisibility RepoVisibility\n\t\twantPrivate    bool\n\t}{\n\t\t{\n\t\t\tname:           \"empty source visibility preserves stored value\",\n\t\t\tstored:         Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true},\n\t\t\tfrom:           Repo{Visibility: \"\", IsSCMPrivate: false},\n\t\t\twantVisibility: VisibilityPrivate,\n\t\t\twantPrivate:    true,\n\t\t},\n\t\t{\n\t\t\tname:           \"empty source visibility preserves stored public value\",\n\t\t\tstored:         Repo{Visibility: VisibilityPublic, IsSCMPrivate: false},\n\t\t\tfrom:           Repo{Visibility: \"\", IsSCMPrivate: false},\n\t\t\twantVisibility: VisibilityPublic,\n\t\t\twantPrivate:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"source can change public to private\",\n\t\t\tstored:         Repo{Visibility: VisibilityPublic, IsSCMPrivate: false},\n\t\t\tfrom:           Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true},\n\t\t\twantVisibility: VisibilityPrivate,\n\t\t\twantPrivate:    true,\n\t\t},\n\t\t{\n\t\t\tname:           \"source can change private to public\",\n\t\t\tstored:         Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true},\n\t\t\tfrom:           Repo{Visibility: VisibilityPublic, IsSCMPrivate: false},\n\t\t\twantVisibility: VisibilityPublic,\n\t\t\twantPrivate:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"internal visibility is preserved (not collapsed to private)\",\n\t\t\tstored:         Repo{Visibility: VisibilityPublic, IsSCMPrivate: false},\n\t\t\tfrom:           Repo{Visibility: VisibilityInternal, IsSCMPrivate: true},\n\t\t\twantVisibility: VisibilityInternal,\n\t\t\twantPrivate:    true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := tt.stored\n\t\t\tr.Update(&tt.from)\n\t\t\tassert.Equal(t, tt.wantVisibility, r.Visibility)\n\t\t\tassert.Equal(t, tt.wantPrivate, r.IsSCMPrivate)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/model/secret.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"sort\"\n)\n\nvar (\n\tErrSecretNameInvalid  = errors.New(\"invalid secret name\")\n\tErrSecretImageInvalid = errors.New(\"invalid secret image\")\n\tErrSecretValueInvalid = errors.New(\"invalid secret value\")\n\tErrSecretEventInvalid = errors.New(\"invalid secret event\")\n)\n\n// SecretStore persists secret information to storage.\ntype SecretStore interface {\n\tSecretFind(*Repo, string) (*Secret, error)\n\tSecretList(*Repo, bool, *ListOptions) ([]*Secret, error)\n\tSecretCreate(*Secret) error\n\tSecretUpdate(*Secret) error\n\tSecretDelete(*Secret) error\n\tOrgSecretFind(int64, string) (*Secret, error)\n\tOrgSecretList(int64, *ListOptions) ([]*Secret, error)\n\tGlobalSecretFind(string) (*Secret, error)\n\tGlobalSecretList(*ListOptions) ([]*Secret, error)\n\tSecretListAll() ([]*Secret, error)\n}\n\n// Secret represents a secret variable, such as a password or token.\ntype Secret struct {\n\tID     int64          `json:\"id\"              xorm:\"pk autoincr 'id'\"`\n\tOrgID  int64          `json:\"org_id\"          xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'org_id'\"`\n\tRepoID int64          `json:\"repo_id\"         xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'repo_id'\"`\n\tName   string         `json:\"name\"            xorm:\"NOT NULL UNIQUE(s) INDEX 'name'\"`\n\tValue  string         `json:\"value,omitempty\" xorm:\"TEXT 'value'\"`\n\tImages []string       `json:\"images\"          xorm:\"json 'images'\"`\n\tEvents []WebhookEvent `json:\"events\"          xorm:\"json 'events'\"`\n\tNote   string         `json:\"note\" xorm:\"note\"`\n} //\t@name\tSecret\n\n// TableName return database table name for xorm.\nfunc (Secret) TableName() string {\n\treturn \"secrets\"\n}\n\n// BeforeInsert will sort events before inserted into database.\nfunc (s *Secret) BeforeInsert() {\n\ts.Events = sortEvents(s.Events)\n}\n\n// Global secret.\nfunc (s Secret) IsGlobal() bool {\n\treturn s.RepoID == 0 && s.OrgID == 0\n}\n\n// Organization secret.\nfunc (s Secret) IsOrganization() bool {\n\treturn s.RepoID == 0 && s.OrgID != 0\n}\n\n// Repository secret.\nfunc (s Secret) IsRepository() bool {\n\treturn s.RepoID != 0 && s.OrgID == 0\n}\n\nvar validDockerImageString = regexp.MustCompile(\n\t`^(` +\n\t\t`[\\w\\d\\-_\\.]+` + // hostname\n\t\t`(:\\d+)?` + // optional port\n\t\t`/)?` + // optional hostname + port\n\t\t`([\\w\\d\\-_\\.][\\w\\d\\-_\\.\\/]*/)?` + // optional url prefix\n\t\t`([\\w\\d\\-_\\.]+)` + // image name\n\t\t`(:[\\w\\d\\-_]+)?` + // optional image tag\n\t\t`$`,\n)\n\n// Validate validates the required fields and formats.\nfunc (s *Secret) Validate() error {\n\tfor _, event := range s.Events {\n\t\tif err := event.Validate(); err != nil {\n\t\t\treturn errors.Join(err, ErrSecretEventInvalid)\n\t\t}\n\t}\n\tif len(s.Events) == 0 {\n\t\treturn fmt.Errorf(\"%w: no event specified\", ErrSecretEventInvalid)\n\t}\n\n\tfor _, image := range s.Images {\n\t\tif len(image) == 0 {\n\t\t\treturn fmt.Errorf(\"%w: empty image in images\", ErrSecretImageInvalid)\n\t\t}\n\t\tif !validDockerImageString.MatchString(image) {\n\t\t\treturn fmt.Errorf(\"%w: image '%s' do not match regexp '%s'\", ErrSecretImageInvalid, image, validDockerImageString.String())\n\t\t}\n\t}\n\n\tswitch {\n\tcase len(s.Name) == 0:\n\t\treturn fmt.Errorf(\"%w: empty name\", ErrSecretNameInvalid)\n\tcase len(s.Value) == 0:\n\t\treturn fmt.Errorf(\"%w: empty value\", ErrSecretValueInvalid)\n\tdefault:\n\t\treturn nil\n\t}\n}\n\n// Copy makes a copy of the secret without the value.\nfunc (s *Secret) Copy() *Secret {\n\treturn &Secret{\n\t\tID:     s.ID,\n\t\tOrgID:  s.OrgID,\n\t\tRepoID: s.RepoID,\n\t\tName:   s.Name,\n\t\tImages: s.Images,\n\t\tEvents: sortEvents(s.Events),\n\t\tNote:   s.Note,\n\t}\n}\n\nfunc sortEvents(wel WebhookEventList) WebhookEventList {\n\tsort.Sort(wel)\n\treturn wel\n}\n\ntype SecretPatch struct {\n\tName   *string        `json:\"name\"            `\n\tValue  *string        `json:\"value,omitempty\" `\n\tImages []string       `json:\"images\"          `\n\tEvents []WebhookEvent `json:\"events\"          `\n\tNote   *string        `json:\"note\" `\n} //\t@name\tSecretPatch\n"
  },
  {
    "path": "server/model/secret_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSecretValidate(t *testing.T) {\n\ttests := []struct {\n\t\ts   Secret\n\t\terr bool\n\t}{\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"docker.io/library/mysql:latest\", \"alpine:latest\", \"localregistry.test:8443/mysql:latest\", \"localregistry.test:8443/library/mysql:latest\", \"docker.io/library/mysql\", \"alpine\", \"localregistry.test:8443/mysql\", \"localregistry.test:8443/library/mysql\", \"code.thinkaboutit.tech/pandora/woodpecker-config-server.goapp\"},\n\t\t\t},\n\t\t\terr: false,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"docker.io/library/mysql:latest\", \"alpine:latest\", \"localregistry.test:8443/mysql:latest\", \"localregistry.test:8443/library/mysql:latest\", \"docker.io/library/mysql\", \"alpine\", \"localregistry.test:8443/mysql\", \"localregistry.test:8443/library/mysql\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"docker.io/library/mysql:latest\", \"alpine:latest\", \"localregistry.test:8443/mysql:latest\", \"localregistry.test:8443/library/mysql:latest\", \"docker.io/library/mysql\", \"alpine\", \"localregistry.test:8443/mysql\", \"localregistry.test:8443/library/mysql\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tImages: []string{\"docker.io/library/mysql:latest\", \"alpine:latest\", \"localregistry.test:8443/mysql:latest\", \"localregistry.test:8443/library/mysql:latest\", \"docker.io/library/mysql\", \"alpine\", \"localregistry.test:8443/mysql\", \"localregistry.test:8443/library/mysql\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"wrong image:no\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"/library/mysql:latest\", \":8443/mysql:latest\", \":8443/library/mysql:latest\", \"/library/mysql\", \":8443/mysql\", \":8443/library/mysql\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"localregistry.test:/mysql:latest\", \"localregistry.test:/mysql\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t\t{\n\t\t\ts: Secret{\n\t\t\t\tName:   \"secretname\",\n\t\t\t\tValue:  \"secretvalue\",\n\t\t\t\tEvents: []WebhookEvent{EventPush},\n\t\t\t\tImages: []string{\"docker.io/library/mysql:\", \"alpine:\", \"localregistry.test:8443/mysql:\", \"localregistry.test:8443/library/mysql:\"},\n\t\t\t},\n\t\t\terr: true,\n\t\t},\n\t}\n\tfor i, tt := range tests {\n\t\terr := tt.s.Validate()\n\t\tif tt.err {\n\t\t\tassert.Errorf(t, err, \"expected secret validation error on index %d\", i)\n\t\t} else {\n\t\t\tassert.NoErrorf(t, err, \"unexpected secret validation error on index %d\", i)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/model/server_config.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// ServerConfig represents a key-value pair for storing server configurations.\ntype ServerConfig struct {\n\tKey   string `json:\"key\"   xorm:\"pk 'key'\"`\n\tValue string `json:\"value\" xorm:\"value\"`\n}\n\n// TableName return database table name for xorm.\nfunc (ServerConfig) TableName() string {\n\treturn \"server_configs\"\n}\n"
  },
  {
    "path": "server/model/step.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Different ways to handle failure states.\nconst (\n\tFailureIgnore = \"ignore\"\n\tFailureFail   = \"fail\"\n\tFailureCancel = \"cancel\"\n)\n\n// Step represents a process in the pipeline.\ntype Step struct {\n\tID         int64       `json:\"id\"                   xorm:\"pk autoincr 'id'\"`\n\tUUID       string      `json:\"uuid\"                 xorm:\"INDEX 'uuid'\"`\n\tPipelineID int64       `json:\"pipeline_id\"          xorm:\"UNIQUE(s) INDEX 'pipeline_id'\"`\n\tPID        int         `json:\"pid\"                  xorm:\"UNIQUE(s) 'pid'\"`\n\tPPID       int         `json:\"ppid\"                 xorm:\"ppid\"`\n\tName       string      `json:\"name\"                 xorm:\"name\"`\n\tState      StatusValue `json:\"state\"                xorm:\"state\"`\n\tError      string      `json:\"error,omitempty\"      xorm:\"TEXT 'error'\"`\n\tFailure    string      `json:\"-\"                    xorm:\"failure\"`\n\tExitCode   int         `json:\"exit_code\"            xorm:\"exit_code\"`\n\tStarted    int64       `json:\"started,omitempty\"    xorm:\"started\"`\n\tFinished   int64       `json:\"finished,omitempty\"   xorm:\"finished\"`\n\tType       StepType    `json:\"type,omitempty\"       xorm:\"type\"`\n} //\t@name\tStep\n\n// TableName return database table name for xorm.\nfunc (Step) TableName() string {\n\treturn \"steps\"\n}\n\n// Running returns true if the process state is pending or running.\nfunc (p *Step) Running() bool {\n\treturn p.State == StatusPending || p.State == StatusRunning\n}\n\n// Failing returns true if the process state is failed, killed or error.\nfunc (p *Step) Failing() bool {\n\treturn p.State == StatusError || p.State == StatusKilled || p.State == StatusFailure\n}\n\n// StepType identifies the type of step.\ntype StepType string //\t@name\tStepType\n\nconst (\n\tStepTypeClone    StepType = \"clone\"\n\tStepTypeService  StepType = \"service\"\n\tStepTypePlugin   StepType = \"plugin\"\n\tStepTypeCommands StepType = \"commands\"\n\tStepTypeCache    StepType = \"cache\"\n)\n"
  },
  {
    "path": "server/model/step_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestStepStatus(t *testing.T) {\n\tstep := &Step{\n\t\tState: StatusPending,\n\t}\n\n\tassert.Equal(t, step.Running(), true)\n\tstep.State = StatusRunning\n\tassert.Equal(t, step.Running(), true)\n\n\tstep.Failure = FailureIgnore\n\tstep.State = StatusError\n\tassert.Equal(t, step.Failing(), true)\n\tstep.State = StatusFailure\n\tassert.Equal(t, step.Failing(), true)\n\tstep.Failure = FailureFail\n\tstep.State = StatusError\n\tassert.Equal(t, step.Failing(), true)\n\tstep.State = StatusFailure\n\tassert.Equal(t, step.Failing(), true)\n\tstep.State = StatusPending\n\tassert.Equal(t, step.Failing(), false)\n\tstep.State = StatusSuccess\n\tassert.Equal(t, step.Failing(), false)\n}\n"
  },
  {
    "path": "server/model/task.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n)\n\n// Task defines scheduled pipeline Task.\ntype Task struct {\n\tID           string                 `json:\"id\"           xorm:\"PK UNIQUE 'id'\"`\n\tPID          int                    `json:\"pid\"          xorm:\"'pid'\"`\n\tName         string                 `json:\"name\"         xorm:\"'name'\"`\n\tData         []byte                 `json:\"-\"            xorm:\"LONGBLOB 'data'\"`\n\tLabels       map[string]string      `json:\"labels\"       xorm:\"json 'labels'\"`\n\tDependencies []string               `json:\"dependencies\" xorm:\"json 'dependencies'\"`\n\tRunOn        []string               `json:\"run_on\"       xorm:\"json 'run_on'\"`\n\tDepStatus    map[string]StatusValue `json:\"dep_status\"   xorm:\"json 'dependencies_status'\"`\n\tAgentID      int64                  `json:\"agent_id\"     xorm:\"'agent_id'\"`\n\tPipelineID   int64                  `json:\"pipeline_id\"  xorm:\"'pipeline_id'\"`\n\tRepoID       int64                  `json:\"repo_id\"      xorm:\"'repo_id'\"`\n} //\t@name\tTask\n\n// TableName return database table name for xorm.\nfunc (Task) TableName() string {\n\treturn \"tasks\"\n}\n\nfunc (t *Task) String() string {\n\treturn fmt.Sprintf(\"%s (%s) - %s\", t.ID, t.Dependencies, t.DepStatus)\n}\n\nfunc (t *Task) ApplyLabelsFromRepo(r *Repo) error {\n\tif r == nil {\n\t\treturn fmt.Errorf(\"repo is nil but needed to get task labels\")\n\t}\n\tif t.Labels == nil {\n\t\tt.Labels = make(map[string]string)\n\t}\n\tt.Labels[pipeline.LabelFilterRepo] = r.FullName\n\tt.Labels[pipeline.LabelFilterOrg] = fmt.Sprintf(\"%d\", r.OrgID)\n\treturn nil\n}\n\n// ShouldRun tells if a task should be run or skipped, based on dependencies.\nfunc (t *Task) ShouldRun() bool {\n\tif t.runsOnFailure() && t.runsOnSuccess() {\n\t\treturn true\n\t}\n\n\tif !t.runsOnFailure() && t.runsOnSuccess() {\n\t\tfor _, status := range t.DepStatus {\n\t\t\tif status != StatusSuccess {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\tif t.runsOnFailure() && !t.runsOnSuccess() {\n\t\tfor _, status := range t.DepStatus {\n\t\t\tif status == StatusSuccess {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc (t *Task) runsOnFailure() bool {\n\treturn slices.Contains(t.RunOn, string(StatusFailure))\n}\n\nfunc (t *Task) runsOnSuccess() bool {\n\tif len(t.RunOn) == 0 {\n\t\treturn true\n\t}\n\n\treturn slices.Contains(t.RunOn, string(StatusSuccess))\n}\n"
  },
  {
    "path": "server/model/task_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n)\n\nfunc TestTask_GetLabels(t *testing.T) {\n\tt.Run(\"Nil Repo\", func(t *testing.T) {\n\t\ttask := &Task{}\n\t\terr := task.ApplyLabelsFromRepo(nil)\n\n\t\tassert.Error(t, err)\n\t\tassert.Nil(t, task.Labels)\n\t\tassert.EqualError(t, err, \"repo is nil but needed to get task labels\")\n\t})\n\n\tt.Run(\"Empty Repo\", func(t *testing.T) {\n\t\ttask := &Task{}\n\t\trepo := &Repo{}\n\n\t\terr := task.ApplyLabelsFromRepo(repo)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, task.Labels)\n\t\tassert.Equal(t, map[string]string{\n\t\t\tpipeline.LabelFilterRepo: \"\",\n\t\t\tpipeline.LabelFilterOrg:  \"0\",\n\t\t}, task.Labels)\n\t})\n\n\tt.Run(\"Empty Labels\", func(t *testing.T) {\n\t\ttask := &Task{}\n\t\trepo := &Repo{\n\t\t\tFullName: \"test/repo\",\n\t\t\tID:       123,\n\t\t\tOrgID:    456,\n\t\t}\n\n\t\terr := task.ApplyLabelsFromRepo(repo)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, task.Labels)\n\t\tassert.Equal(t, map[string]string{\n\t\t\tpipeline.LabelFilterRepo: \"test/repo\",\n\t\t\tpipeline.LabelFilterOrg:  \"456\",\n\t\t}, task.Labels)\n\t})\n\n\tt.Run(\"Existing Labels\", func(t *testing.T) {\n\t\ttask := &Task{\n\t\t\tLabels: map[string]string{\n\t\t\t\t\"existing\": \"label\",\n\t\t\t},\n\t\t}\n\t\trepo := &Repo{\n\t\t\tFullName: \"test/repo\",\n\t\t\tID:       123,\n\t\t\tOrgID:    456,\n\t\t}\n\n\t\terr := task.ApplyLabelsFromRepo(repo)\n\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, task.Labels)\n\t\tassert.Equal(t, map[string]string{\n\t\t\t\"existing\":               \"label\",\n\t\t\tpipeline.LabelFilterRepo: \"test/repo\",\n\t\t\tpipeline.LabelFilterOrg:  \"456\",\n\t\t}, task.Labels)\n\t})\n}\n"
  },
  {
    "path": "server/model/team.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Team represents a team or organization in the forge.\ntype Team struct {\n\t// Login is the username for this team.\n\tLogin string `json:\"login\"`\n\n\t// the avatar url for this team.\n\tAvatar string `json:\"avatar_url\"`\n}\n"
  },
  {
    "path": "server/model/user.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"errors\"\n\t\"regexp\"\n)\n\n// Validate a username (e.g. from github).\nvar reUsername = regexp.MustCompile(\"^[a-zA-Z0-9-_.]+$\")\n\nvar errUserLoginInvalid = errors.New(\"invalid user login\")\n\nconst maxLoginLen = 250\n\n// User represents a registered user.\ntype User struct {\n\t// the id for this user.\n\t//\n\t// required: true\n\tID int64 `json:\"id\" xorm:\"pk autoincr 'id'\"`\n\n\tForgeID int64 `json:\"forge_id,omitempty\" xorm:\"forge_id UNIQUE(forge_id) UNIQUE(forge_login)\"`\n\n\tForgeRemoteID ForgeRemoteID `json:\"forge_remote_id\" xorm:\"forge_remote_id UNIQUE(forge_id)\"`\n\n\t// Login is the username for this user.\n\t//\n\t// required: true\n\tLogin string `json:\"login\"  xorm:\"'login' UNIQUE(forge_login)\"`\n\n\t// AccessToken is the oauth2 access token.\n\tAccessToken string `json:\"-\"  xorm:\"TEXT 'access_token'\"`\n\n\t// RefreshToken is the oauth2 refresh token.\n\tRefreshToken string `json:\"-\" xorm:\"TEXT 'refresh_token'\"`\n\n\t// Expiry is the AccessToken expiration timestamp (unix seconds).\n\tExpiry int64 `json:\"-\" xorm:\"expiry\"`\n\n\t// Email is the email address for this user.\n\t//\n\t// required: true\n\tEmail string `json:\"email\" xorm:\" varchar(500) 'email'\"`\n\n\t// the avatar url for this user.\n\tAvatar string `json:\"avatar_url\" xorm:\" varchar(500) 'avatar'\"`\n\n\t// Admin indicates the user is a system administrator.\n\t//\n\t// NOTE: If the username is part of the WOODPECKER_ADMIN\n\t// environment variable, this value will be set to true on login.\n\tAdmin bool `json:\"admin,omitempty\" xorm:\"admin\"`\n\n\t// Hash is a unique token used to sign tokens.\n\tHash string `json:\"-\" xorm:\"UNIQUE varchar(500) 'hash'\"`\n\n\t// OrgID is the of the user as model.Org.\n\tOrgID int64 `json:\"org_id\" xorm:\"org_id\"`\n} //\t@name\tUser\n\n// TableName return database table name for xorm.\nfunc (User) TableName() string {\n\treturn \"users\"\n}\n\n// Validate validates the required fields and formats.\nfunc (u *User) Validate() error {\n\tswitch {\n\tcase len(u.Login) == 0:\n\t\treturn errUserLoginInvalid\n\tcase len(u.Login) > maxLoginLen:\n\t\treturn errUserLoginInvalid\n\tcase !reUsername.MatchString(u.Login):\n\t\treturn errUserLoginInvalid\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "server/model/user_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestUserValidate(t *testing.T) {\n\ttests := []struct {\n\t\tuser User\n\t\terr  error\n\t}{\n\t\t{\n\t\t\tuser: User{},\n\t\t\terr:  errUserLoginInvalid,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"octocat!\"},\n\t\t\terr:  errUserLoginInvalid,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"!octocat\"},\n\t\t\terr:  errUserLoginInvalid,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"john$smith\"},\n\t\t\terr:  errUserLoginInvalid,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"octocat\"},\n\t\t\terr:  nil,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"john-smith\"},\n\t\t\terr:  nil,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"john_smith\"},\n\t\t\terr:  nil,\n\t\t},\n\t\t{\n\t\t\tuser: User{Login: \"john.smith\"},\n\t\t\terr:  nil,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\terr := test.user.Validate()\n\t\tassert.ErrorIs(t, err, test.err)\n\t}\n}\n"
  },
  {
    "path": "server/model/workflow.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\n// Workflow represents a workflow in the pipeline.\ntype Workflow struct {\n\tID         int64             `json:\"id\"                   xorm:\"pk autoincr 'id'\"`\n\tPipelineID int64             `json:\"pipeline_id\"          xorm:\"UNIQUE(s) INDEX 'pipeline_id'\"`\n\tPID        int               `json:\"pid\"                  xorm:\"UNIQUE(s) 'pid'\"`\n\tName       string            `json:\"name\"                 xorm:\"name\"`\n\tState      StatusValue       `json:\"state\"                xorm:\"state\"`\n\tError      string            `json:\"error,omitempty\"      xorm:\"TEXT 'error'\"`\n\tStarted    int64             `json:\"started,omitempty\"    xorm:\"started\"`\n\tFinished   int64             `json:\"finished,omitempty\"   xorm:\"finished\"`\n\tAgentID    int64             `json:\"agent_id,omitempty\"   xorm:\"agent_id\"`\n\tPlatform   string            `json:\"platform,omitempty\"   xorm:\"platform\"`\n\tEnviron    map[string]string `json:\"environ,omitempty\"    xorm:\"json 'environ'\"`\n\tAxisID     int               `json:\"-\"                    xorm:\"axis_id\"`\n\tChildren   []*Step           `json:\"children,omitempty\"   xorm:\"-\"`\n}\n\n// TableName return database table name for xorm.\nfunc (Workflow) TableName() string {\n\treturn \"workflows\"\n}\n\n// Running returns true if the process state is pending or running.\nfunc (p *Workflow) Running() bool {\n\treturn p.State == StatusPending || p.State == StatusRunning\n}\n\n// Failing returns true if the process state is failed, killed or error.\nfunc (p *Workflow) Failing() bool {\n\treturn p.State == StatusError || p.State == StatusKilled || p.State == StatusFailure\n}\n\n// IsThereRunningStage determine if it contains workflows running or pending to run.\n// TODO: return false based on depends_on (https://github.com/woodpecker-ci/woodpecker/pull/730#discussion_r795681697)\nfunc IsThereRunningStage(workflows []*Workflow) bool {\n\tfor _, p := range workflows {\n\t\tif p.Running() {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "server/pipeline/approve.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// Approve update the status to pending for a blocked pipeline so it can be executed.\nfunc Approve(ctx context.Context, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo) (*model.Pipeline, error) {\n\tif currentPipeline.Status != model.StatusBlocked {\n\t\treturn nil, ErrBadRequest{Msg: fmt.Sprintf(\"cannot approve a pipeline with status %s\", currentPipeline.Status)}\n\t}\n\n\tforge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to load forge for repo '%s'\", repo.FullName)\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\t// fetch the pipeline file from the database\n\tconfigs, err := store.ConfigsForPipeline(currentPipeline.ID)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to get pipeline config for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, ErrNotFound{Msg: msg}\n\t}\n\tvar yamls []*forge_types.FileMeta\n\tfor _, y := range configs {\n\t\tyamls = append(yamls, &forge_types.FileMeta{Data: y.Data, Name: y.Name})\n\t}\n\n\tif currentPipeline.Workflows, err = store.WorkflowGetTree(currentPipeline); err != nil {\n\t\treturn nil, fmt.Errorf(\"error: loading workflows. %w\", err)\n\t}\n\n\tif currentPipeline, err = UpdateToStatusPending(store, *currentPipeline, user.Login); err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating pipeline. %w\", err)\n\t}\n\n\tfor _, wf := range currentPipeline.Workflows {\n\t\tif wf.State != model.StatusBlocked {\n\t\t\tcontinue\n\t\t}\n\t\twf.State = model.StatusPending\n\t\tif err := store.WorkflowUpdate(wf); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error updating workflow. %w\", err)\n\t\t}\n\n\t\tfor _, step := range wf.Children {\n\t\t\tif step.State != model.StatusBlocked {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstep.State = model.StatusPending\n\t\t\tif err := store.StepUpdate(step); err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error updating step. %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tcurrentPipeline, pipelineItems, err := createPipelineItems(ctx, forge, store, currentPipeline, user, repo, yamls, nil)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to createPipelineItems for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\t// we have no way to link old workflows and steps in database to new engine generated steps,\n\t// so we just delete the old and insert the new ones\n\tif err := store.WorkflowsReplace(currentPipeline, currentPipeline.Workflows); err != nil {\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msgf(\"error persisting new steps for %s#%d after approval\", repo.FullName, currentPipeline.Number)\n\t\treturn nil, err\n\t}\n\n\tpublishPipeline(ctx, forge, currentPipeline, repo, user)\n\n\tcurrentPipeline, err = start(ctx, forge, store, currentPipeline, user, repo, pipelineItems)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to start pipeline for %s: %v\", repo.FullName, err)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\treturn currentPipeline, nil\n}\n"
  },
  {
    "path": "server/pipeline/cancel.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// Cancel the pipeline and returns the status.\nfunc Cancel(ctx context.Context, _forge forge.Forge, store store.Store, repo *model.Repo, user *model.User, pipeline *model.Pipeline, cancelInfo *model.CancelInfo) error {\n\tif pipeline.Status != model.StatusRunning && pipeline.Status != model.StatusPending && pipeline.Status != model.StatusBlocked {\n\t\treturn &ErrBadRequest{Msg: \"Cannot cancel a non-running or non-pending or non-blocked pipeline\"}\n\t}\n\n\tworkflows, err := store.WorkflowGetTree(pipeline)\n\tif err != nil {\n\t\treturn &ErrNotFound{Msg: err.Error()}\n\t}\n\n\t// First cancel/evict workflows in the queue in one go\n\tvar workflowsToCancel []string\n\tfor _, w := range workflows {\n\t\tif w.State == model.StatusRunning || w.State == model.StatusPending {\n\t\t\tworkflowsToCancel = append(workflowsToCancel, fmt.Sprint(w.ID))\n\t\t}\n\t}\n\n\tif len(workflowsToCancel) != 0 {\n\t\tif err := server.Config.Services.Scheduler.ErrorAtOnce(ctx, workflowsToCancel, queue.ErrCancel); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"queue: evict_at_once: %v\", workflowsToCancel)\n\t\t}\n\t}\n\n\thasPendingOnly := true\n\n\t// Then update the DB status for pending pipelines\n\t// Running ones will be set when the agents stop on the cancel signal\n\tfor _, workflow := range workflows {\n\t\tif workflow.State == model.StatusPending {\n\t\t\tif _, err = UpdateWorkflowToStatusSkipped(store, *workflow); err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"cannot update workflow with id %d state\", workflow.ID)\n\t\t\t}\n\t\t} else {\n\t\t\thasPendingOnly = false\n\t\t}\n\t\tfor _, step := range workflow.Children {\n\t\t\tif step.State == model.StatusPending {\n\t\t\t\tif _, err = UpdateStepToStatusSkipped(store, *step, 0, model.StatusCanceled); err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"cannot update workflow with id %d state\", workflow.ID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tplState := model.StatusKilled\n\tif hasPendingOnly {\n\t\tplState = model.StatusCanceled\n\t}\n\tkilledPipeline, err := UpdateToStatusKilled(store, *pipeline, cancelInfo, plState)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"UpdateToStatusKilled: %v\", pipeline)\n\t\treturn err\n\t}\n\n\tupdatePipelineStatus(ctx, _forge, killedPipeline, repo, user)\n\n\tif killedPipeline.Workflows, err = store.WorkflowGetTree(killedPipeline); err != nil {\n\t\treturn err\n\t}\n\n\tif err := publishToTopic(ctx, killedPipeline, repo); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not push pipeline status change to pubsub provider\")\n\t}\n\n\treturn nil\n}\n\nfunc cancelPreviousPipelines(\n\tctx context.Context,\n\t_forge forge.Forge,\n\t_store store.Store,\n\tpipeline *model.Pipeline,\n\trepo *model.Repo,\n\tuser *model.User,\n) error {\n\t// check this event should cancel previous pipelines\n\teventIncluded := slices.Contains(repo.CancelPreviousPipelineEvents, pipeline.Event)\n\tif !eventIncluded {\n\t\treturn nil\n\t}\n\n\t// get all active activeBuilds\n\tactiveBuilds, err := _store.GetActivePipelineList(repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tpipelineNeedsCancel := func(active *model.Pipeline) bool {\n\t\t// always filter on same event\n\t\tif active.Event != pipeline.Event {\n\t\t\treturn false\n\t\t}\n\n\t\t// find events for the same context\n\t\tswitch pipeline.Event {\n\t\tcase model.EventPush:\n\t\t\treturn pipeline.Branch == active.Branch\n\t\tdefault:\n\t\t\treturn pipeline.Refspec == active.Refspec\n\t\t}\n\t}\n\n\tfor _, active := range activeBuilds {\n\t\tif active.ID == pipeline.ID {\n\t\t\t// same pipeline. e.g. self\n\t\t\tcontinue\n\t\t}\n\n\t\tcancel := pipelineNeedsCancel(active)\n\n\t\tif !cancel {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err = Cancel(ctx, _forge, _store, repo, user, active, &model.CancelInfo{\n\t\t\tSupersededBy: pipeline.Number,\n\t\t}); err != nil {\n\t\t\tlog.Error().\n\t\t\t\tErr(err).\n\t\t\t\tStr(\"ref\", active.Ref).\n\t\t\t\tInt64(\"id\", active.ID).\n\t\t\t\tMsg(\"failed to cancel pipeline\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/pipeline/config.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc findOrPersistPipelineConfig(store store.Store, currentPipeline *model.Pipeline, forgeYamlConfig *forge_types.FileMeta) (*model.Config, error) {\n\treturn store.ConfigPersist(&model.Config{\n\t\tRepoID: currentPipeline.RepoID,\n\t\tName:   step_builder.SanitizePath(forgeYamlConfig.Name),\n\t\tData:   forgeYamlConfig.Data,\n\t})\n}\n"
  },
  {
    "path": "server/pipeline/create.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// Create a new pipeline and start it.\nfunc Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline *model.Pipeline) (*model.Pipeline, error) {\n\trepoUser, err := _store.GetUser(repo.UserID)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to find repo owner via id '%d'\", repo.UserID)\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif constraint.IsSkipCommitMessage(metadata.Event(pipeline.Event), pipeline.Message) {\n\t\tref := pipeline.Commit\n\t\tif len(ref) == 0 {\n\t\t\tref = pipeline.Ref\n\t\t}\n\t\tlog.Debug().Str(\"repo\", repo.FullName).Msgf(\"ignoring pipeline as skip-ci was found in the commit (%s) message '%s'\", ref, pipeline.Message)\n\t\treturn nil, ErrFiltered\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to load forge for repo '%s'\", repo.FullName)\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\t// If the forge has a refresh token, the current access token\n\t// may be stale. Therefore, we should refresh prior to dispatching\n\t// the pipeline.\n\tforge.Refresh(ctx, _forge, _store, repoUser)\n\n\t// update some pipeline fields\n\tpipeline.RepoID = repo.ID\n\tpipeline.Status = model.StatusCreated\n\tpipeline.Version = version.String()\n\tsetApprovalState(repo, pipeline)\n\terr = _store.CreatePipeline(pipeline)\n\tif err != nil {\n\t\tmsg := fmt.Errorf(\"failed to save pipeline for %s\", repo.FullName)\n\t\tlog.Error().Str(\"repo\", repo.FullName).Err(err).Msg(msg.Error())\n\t\treturn nil, msg\n\t}\n\n\t// fetch the pipeline file from the forge\n\tconfigService := server.Config.Services.Manager.ConfigServiceFromRepo(repo)\n\tforgeYamlConfigs, configFetchErr := configService.Fetch(ctx, _forge, repoUser, repo, pipeline, nil, false)\n\tswitch {\n\tcase errors.Is(configFetchErr, &forge_types.ErrConfigNotFound{}):\n\t\tlog.Debug().Str(\"repo\", repo.FullName).Err(configFetchErr).Msgf(\"cannot find config '%s' in '%s' with user: '%s'\", repo.Config, pipeline.Ref, repoUser.Login)\n\t\tif err := _store.DeletePipeline(pipeline); err != nil {\n\t\t\tlog.Error().Str(\"repo\", repo.FullName).Err(err).Msg(\"failed to delete pipeline without config\")\n\t\t}\n\n\t\treturn nil, ErrFiltered\n\tcase configFetchErr != nil && forgeYamlConfigs != nil:\n\t\t// unexpected status code from config endpoint - using previous config as fallback\n\t\tlog.Warn().Str(\"repo\", repo.FullName).Err(configFetchErr).Msgf(\"error while fetching config '%s' in '%s' with user: '%s', will fallback to old config\", repo.Config, pipeline.Ref, repoUser.Login)\n\tcase configFetchErr != nil:\n\t\t// error while fetching config - not using the old config\n\t\tlog.Error().Str(\"repo\", repo.FullName).Err(configFetchErr).Msgf(\"error while fetching config '%s' in '%s' with user: '%s', and did not get any config\", repo.Config, pipeline.Ref, repoUser.Login)\n\t\treturn nil, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, fmt.Errorf(\"could not load config from forge: %w\", configFetchErr))\n\t}\n\n\tpipelineItems, parseErr := parsePipeline(ctx, _forge, _store, pipeline, repoUser, repo, forgeYamlConfigs, nil)\n\tif pipeline_errors.HasBlockingErrors(parseErr) {\n\t\tlog.Debug().Str(\"repo\", repo.FullName).Err(parseErr).Msg(\"failed to parse yaml\")\n\t\treturn pipeline, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, parseErr)\n\t} else if parseErr != nil {\n\t\tpipeline.Errors = pipeline_errors.GetPipelineErrors(parseErr)\n\t}\n\n\tif len(pipelineItems) == 0 {\n\t\tlog.Debug().Str(\"repo\", repo.FullName).Msg(ErrFiltered.Error())\n\t\tif err := _store.DeletePipeline(pipeline); err != nil {\n\t\t\tlog.Error().Str(\"repo\", repo.FullName).Err(err).Msg(\"failed to delete empty pipeline\")\n\t\t}\n\n\t\treturn nil, ErrFiltered\n\t}\n\n\tpipeline = setPipelineStepsOnPipeline(pipeline, pipelineItems)\n\n\t// persist the pipeline config for historical correctness, restarts, etc\n\tvar configs []*model.Config\n\tfor _, forgeYamlConfig := range forgeYamlConfigs {\n\t\tconfig, err := findOrPersistPipelineConfig(_store, pipeline, forgeYamlConfig)\n\t\tif err != nil {\n\t\t\tmsg := fmt.Sprintf(\"failed to find or persist pipeline config for %s\", repo.FullName)\n\t\t\tlog.Error().Err(err).Msg(msg)\n\t\t\treturn nil, errors.New(msg)\n\t\t}\n\t\tconfigs = append(configs, config)\n\t}\n\t// link pipeline to persisted configs\n\tif err := linkPipelineConfigs(_store, configs, pipeline.ID); err != nil {\n\t\tmsg := fmt.Sprintf(\"failed to find or persist pipeline config for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif err := prepareStart(ctx, _forge, _store, pipeline, repoUser, repo); err != nil {\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msgf(\"error preparing pipeline for %s#%d\", repo.FullName, pipeline.Number)\n\t\treturn nil, err\n\t}\n\n\tif pipeline.Status == model.StatusBlocked {\n\t\treturn pipeline, nil\n\t}\n\n\tif err := updatePipelinePending(ctx, _forge, _store, pipeline, repo, repoUser); err != nil {\n\t\treturn nil, err\n\t}\n\n\tpipeline, err = start(ctx, _forge, _store, pipeline, repoUser, repo, pipelineItems)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failed to start pipeline for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\treturn pipeline, nil\n}\n\nfunc updatePipelineWithErr(ctx context.Context, _forge forge.Forge, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User, err error) error {\n\t_pipeline, err := UpdateToStatusError(_store, *pipeline, err)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// update value in ref\n\t*pipeline = *_pipeline\n\n\tpublishPipeline(ctx, _forge, pipeline, repo, repoUser)\n\n\treturn nil\n}\n\nfunc updatePipelinePending(ctx context.Context, _forge forge.Forge, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User) error {\n\t_pipeline, err := UpdateToStatusPending(_store, *pipeline, \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t// update value in ref\n\t*pipeline = *_pipeline\n\n\tpublishPipeline(ctx, _forge, pipeline, repo, repoUser)\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/pipeline/decline.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// Decline updates the status to declined for blocked pipelines.\nfunc Decline(ctx context.Context, store store.Store, pipeline *model.Pipeline, user *model.User, repo *model.Repo) (*model.Pipeline, error) {\n\tforge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to load forge for repo '%s'\", repo.FullName)\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif pipeline.Status != model.StatusBlocked {\n\t\treturn nil, fmt.Errorf(\"cannot decline a pipeline with status %s\", pipeline.Status)\n\t}\n\n\tpipeline, err = UpdateToStatusDeclined(store, *pipeline, user.Login)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error updating pipeline. %w\", err)\n\t}\n\n\tif pipeline.Workflows, err = store.WorkflowGetTree(pipeline); err != nil {\n\t\tlog.Error().Err(err).Msg(\"cannot build tree from step list\")\n\t}\n\n\tfor _, wf := range pipeline.Workflows {\n\t\twf.State = model.StatusDeclined\n\t\tif err := store.WorkflowUpdate(wf); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error updating workflow. %w\", err)\n\t\t}\n\n\t\tfor _, step := range wf.Children {\n\t\t\tstep.State = model.StatusDeclined\n\t\t\tif err := store.StepUpdate(step); err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"error updating step. %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\n\tupdatePipelineStatus(ctx, forge, pipeline, repo, user)\n\n\tif err := publishToTopic(ctx, pipeline, repo); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not push pipeline status change to pubsub provider\")\n\t}\n\n\treturn pipeline, nil\n}\n"
  },
  {
    "path": "server/pipeline/errors.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport \"errors\"\n\ntype ErrNotFound struct {\n\tMsg string\n}\n\nfunc (e ErrNotFound) Error() string {\n\treturn e.Msg\n}\n\nfunc (e ErrNotFound) Is(target error) bool {\n\t_, ok := target.(ErrNotFound)\n\tif !ok {\n\t\t_, ok = target.(*ErrNotFound)\n\t}\n\treturn ok\n}\n\ntype ErrBadRequest struct {\n\tMsg string\n}\n\nfunc (e ErrBadRequest) Error() string {\n\treturn e.Msg\n}\n\nfunc (e ErrBadRequest) Is(target error) bool {\n\t_, ok := target.(ErrBadRequest)\n\tif !ok {\n\t\t_, ok = target.(*ErrBadRequest)\n\t}\n\treturn ok\n}\n\nvar ErrFiltered = errors.New(\"ignoring hook: 'when' filters filtered out all steps\")\n"
  },
  {
    "path": "server/pipeline/gated.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"slices\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc setApprovalState(repo *model.Repo, pipeline *model.Pipeline) {\n\tif !needsApproval(repo, pipeline) {\n\t\treturn\n\t}\n\n\t// set pipeline status to blocked and require approval\n\tpipeline.Status = model.StatusBlocked\n}\n\nfunc needsApproval(repo *model.Repo, pipeline *model.Pipeline) bool {\n\t// skip events created by woodpecker itself\n\tif pipeline.Event == model.EventCron || pipeline.Event == model.EventManual {\n\t\treturn false\n\t}\n\n\t// skip if user is allowed\n\t// It's enough to check the username as the repo matches the forge of the pipeline already (no username clashes from different forges possible)\n\tif slices.Contains(repo.ApprovalAllowedUsers, pipeline.Author) {\n\t\treturn false\n\t}\n\n\tswitch repo.RequireApproval {\n\t// repository allows all events without approval\n\tcase model.RequireApprovalNone:\n\t\treturn false\n\n\t// repository requires approval for pull requests from forks\n\tcase model.RequireApprovalForks:\n\t\tif pipeline.IsPullRequest() && pipeline.FromFork {\n\t\t\treturn true\n\t\t}\n\n\t// repository requires approval for pull requests\n\tcase model.RequireApprovalPullRequests:\n\t\tif pipeline.IsPullRequest() {\n\t\t\treturn true\n\t\t}\n\n\t\t// repository requires approval for all events\n\tcase model.RequireApprovalAllEvents:\n\t\treturn true\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "server/pipeline/gated_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestSetGatedState(t *testing.T) {\n\tt.Parallel()\n\n\ttestCases := []struct {\n\t\tname          string\n\t\trepo          *model.Repo\n\t\tpipeline      *model.Pipeline\n\t\texpectBlocked bool\n\t}{\n\t\t{\n\t\t\tname: \"by-pass for cron\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalAllEvents,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent: model.EventCron,\n\t\t\t},\n\t\t\texpectBlocked: false,\n\t\t},\n\t\t{\n\t\t\tname: \"by-pass for manual pipeline\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalAllEvents,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent: model.EventManual,\n\t\t\t},\n\t\t\texpectBlocked: false,\n\t\t},\n\t\t{\n\t\t\tname: \"require approval for fork PRs\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalForks,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent:    model.EventPull,\n\t\t\t\tFromFork: true,\n\t\t\t},\n\t\t\texpectBlocked: true,\n\t\t},\n\t\t{\n\t\t\tname: \"require approval for PRs\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalPullRequests,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent:    model.EventPull,\n\t\t\t\tFromFork: false,\n\t\t\t},\n\t\t\texpectBlocked: true,\n\t\t},\n\t\t{\n\t\t\tname: \"require approval for edited PRs\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalPullRequests,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent:    model.EventPullMetadata,\n\t\t\t\tFromFork: false,\n\t\t\t},\n\t\t\texpectBlocked: true,\n\t\t},\n\t\t{\n\t\t\tname: \"require approval for everything\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval: model.RequireApprovalAllEvents,\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent: model.EventPush,\n\t\t\t},\n\t\t\texpectBlocked: true,\n\t\t},\n\t\t{\n\t\t\tname: \"require approval for everything with allowed user\",\n\t\t\trepo: &model.Repo{\n\t\t\t\tRequireApproval:      model.RequireApprovalAllEvents,\n\t\t\t\tApprovalAllowedUsers: []string{\"user\"},\n\t\t\t},\n\t\t\tpipeline: &model.Pipeline{\n\t\t\t\tEvent:  model.EventPush,\n\t\t\t\tAuthor: \"user\",\n\t\t\t},\n\t\t\texpectBlocked: false,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tsetApprovalState(tc.repo, tc.pipeline)\n\t\tassert.Equal(t, tc.expectBlocked, tc.pipeline.Status == model.StatusBlocked)\n\t}\n}\n"
  },
  {
    "path": "server/pipeline/helper.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc updatePipelineStatus(ctx context.Context, forge forge.Forge, pipeline *model.Pipeline, repo *model.Repo, user *model.User) {\n\tfor _, workflow := range pipeline.Workflows {\n\t\terr := forge.Status(ctx, user, repo, pipeline, workflow)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"error setting commit status for %s/%d\", repo.FullName, pipeline.Number)\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/pipeline/items.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"maps\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\tpipeline_metadata \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc parsePipeline(ctx context.Context, forge forge.Forge, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string) ([]*step_builder.Item, error) {\n\tnetrc, err := forge.Netrc(user, repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"failed to generate netrc file\")\n\t}\n\n\t// get the previous pipeline so that we can send status change notifications\n\tprev, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msgf(\"error getting last pipeline before pipeline number '%d'\", currentPipeline.Number)\n\t}\n\n\tsecretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)\n\tsecs, err := secretService.SecretListPipeline(ctx, repo, currentPipeline, netrc)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"error getting secrets for %s#%d\", repo.FullName, currentPipeline.Number)\n\t}\n\n\tvar secrets []compiler.Secret\n\tfor _, sec := range secs {\n\t\tvar events []pipeline_metadata.Event\n\t\tfor _, event := range sec.Events {\n\t\t\tevents = append(events, pipeline_metadata.Event(event))\n\t\t}\n\n\t\tsecrets = append(secrets, compiler.Secret{\n\t\t\tName:           sec.Name,\n\t\t\tValue:          sec.Value,\n\t\t\tAllowedPlugins: sec.Images,\n\t\t\tEvents:         events,\n\t\t})\n\t}\n\n\tregistryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo)\n\tregs, err := registryService.RegistryListPipeline(ctx, repo, currentPipeline, netrc)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"error getting registry credentials for %s#%d\", repo.FullName, currentPipeline.Number)\n\t}\n\n\tvar registries []compiler.Registry\n\tfor _, reg := range regs {\n\t\tregistries = append(registries, compiler.Registry{\n\t\t\tHostname: reg.Address,\n\t\t\tUsername: reg.Username,\n\t\t\tPassword: reg.Password,\n\t\t})\n\t}\n\n\tif envs == nil {\n\t\tenvs = map[string]string{}\n\t}\n\n\tenvironmentService := server.Config.Services.Manager.EnvironmentService()\n\tif environmentService != nil {\n\t\tglobals, _ := environmentService.EnvironList(repo)\n\t\tfor _, global := range globals {\n\t\t\tenvs[global.Name] = global.Value\n\t\t}\n\t}\n\n\tmaps.Copy(envs, currentPipeline.AdditionalVariables)\n\n\tb := step_builder.StepBuilder{\n\t\tRepo:                repo,\n\t\tCurr:                currentPipeline,\n\t\tPrev:                prev,\n\t\tEnvs:                envs,\n\t\tHost:                server.Config.Server.Host,\n\t\tYamls:               yamls,\n\t\tForge:               forge,\n\t\tTrustedClonePlugins: append(repo.NetrcTrustedPlugins, server.Config.Pipeline.TrustedClonePlugins...),\n\t\tPrivilegedPlugins:   server.Config.Pipeline.PrivilegedPlugins,\n\t\tRepoTrusted: &pipeline_metadata.TrustedConfiguration{\n\t\t\tNetwork:  repo.Trusted.Network,\n\t\t\tVolumes:  repo.Trusted.Volumes,\n\t\t\tSecurity: repo.Trusted.Security,\n\t\t},\n\t\tDefaultLabels: server.Config.Pipeline.DefaultWorkflowLabels,\n\t\tCompilerOptions: []compiler.Option{\n\t\t\tcompiler.WithLocal(false),\n\t\t\tcompiler.WithRegistry(registries...),\n\t\t\tcompiler.WithSecret(secrets...),\n\t\t\tcompiler.WithProxy(compiler.ProxyOptions{\n\t\t\t\tNoProxy:    server.Config.Pipeline.Proxy.No,\n\t\t\t\tHTTPProxy:  server.Config.Pipeline.Proxy.HTTP,\n\t\t\t\tHTTPSProxy: server.Config.Pipeline.Proxy.HTTPS,\n\t\t\t}),\n\t\t\tcompiler.WithVolumes(server.Config.Pipeline.Volumes...),\n\t\t\tcompiler.WithNetworks(server.Config.Pipeline.Networks...),\n\t\t\tcompiler.WithOption(\n\t\t\t\tcompiler.WithNetrc(\n\t\t\t\t\tnetrc.Login,\n\t\t\t\t\tnetrc.Password,\n\t\t\t\t\tnetrc.Machine,\n\t\t\t\t),\n\t\t\t\trepo.IsSCMPrivate || server.Config.Pipeline.AuthenticatePublicRepos,\n\t\t\t),\n\t\t\tcompiler.WithDefaultClonePlugin(server.Config.Pipeline.DefaultClonePlugin),\n\t\t\tcompiler.WithWorkspaceFromURL(compiler.DefaultWorkspaceBase, repo.ForgeURL),\n\t\t},\n\t}\n\n\t// TODO: remove with version 4.x\n\tif server.Config.Pipeline.ForceIgnoreServiceFailure {\n\t\tb.CompilerOptions = append(b.CompilerOptions, compiler.WithForceIgnoreServiceFailure())\n\t}\n\n\treturn b.Build()\n}\n\nfunc createPipelineItems(c context.Context, forge forge.Forge, store store.Store,\n\tcurrentPipeline *model.Pipeline, user *model.User, repo *model.Repo,\n\tyamls []*forge_types.FileMeta, envs map[string]string,\n) (*model.Pipeline, []*step_builder.Item, error) {\n\tpipelineItems, err := parsePipeline(c, forge, store, currentPipeline, user, repo, yamls, envs)\n\tif pipeline_errors.HasBlockingErrors(err) {\n\t\tcurrentPipeline, uErr := UpdateToStatusError(store, *currentPipeline, err)\n\t\tif uErr != nil {\n\t\t\tlog.Error().Err(uErr).Msgf(\"error setting error status of pipeline for %s#%d\", repo.FullName, currentPipeline.Number)\n\t\t} else {\n\t\t\tupdatePipelineStatus(c, forge, currentPipeline, repo, user)\n\t\t}\n\n\t\treturn currentPipeline, nil, err\n\t} else if err != nil {\n\t\tcurrentPipeline.Errors = pipeline_errors.GetPipelineErrors(err)\n\t\terr = updatePipelinePending(c, forge, store, currentPipeline, repo, user)\n\t}\n\n\tcurrentPipeline = setPipelineStepsOnPipeline(currentPipeline, pipelineItems)\n\n\treturn currentPipeline, pipelineItems, err\n}\n\n// setPipelineStepsOnPipeline is the link between pipeline representation in \"pipeline package\" and server\n// to be specific this func currently is used to convert the pipeline.Item list (crafted by StepBuilder.Build()) into\n// a pipeline that can be stored in the database by the server.\nfunc setPipelineStepsOnPipeline(pipeline *model.Pipeline, pipelineItems []*step_builder.Item) *model.Pipeline {\n\tvar pidSequence int\n\tfor _, item := range pipelineItems {\n\t\tif pidSequence < item.Workflow.PID {\n\t\t\tpidSequence = item.Workflow.PID\n\t\t}\n\t}\n\n\t// the workflows in the pipeline should be empty as only we do populate them,\n\t// but if a pipeline was already loaded form database it might contain things, so we just clean it\n\tpipeline.Workflows = nil\n\tfor _, item := range pipelineItems {\n\t\tfor _, stage := range item.Config.Stages {\n\t\t\tfor _, step := range stage.Steps {\n\t\t\t\tpidSequence++\n\t\t\t\tstep := &model.Step{\n\t\t\t\t\tName:       step.Name,\n\t\t\t\t\tUUID:       step.UUID,\n\t\t\t\t\tPipelineID: pipeline.ID,\n\t\t\t\t\tPID:        pidSequence,\n\t\t\t\t\tPPID:       item.Workflow.PID,\n\t\t\t\t\tState:      model.StatusPending,\n\t\t\t\t\tFailure:    step.Failure,\n\t\t\t\t\tType:       model.StepType(step.Type),\n\t\t\t\t}\n\t\t\t\tif pipeline.Status == model.StatusBlocked {\n\t\t\t\t\tstep.State = model.StatusBlocked\n\t\t\t\t}\n\t\t\t\titem.Workflow.Children = append(item.Workflow.Children, step)\n\t\t\t}\n\t\t}\n\t\tif pipeline.Status == model.StatusBlocked {\n\t\t\titem.Workflow.State = model.StatusBlocked\n\t\t}\n\t\titem.Workflow.PipelineID = pipeline.ID\n\t\tpipeline.Workflows = append(pipeline.Workflows, item.Workflow)\n\t}\n\n\treturn pipeline\n}\n"
  },
  {
    "path": "server/pipeline/items_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n\tmanager_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/mocks\"\n\tregistry_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks\"\n\tsecret_service_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestSetPipelineStepsOnPipeline(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline := &model.Pipeline{\n\t\tID:    1,\n\t\tEvent: model.EventPush,\n\t}\n\n\tpipelineItems := []*step_builder.Item{{\n\t\tWorkflow: &model.Workflow{\n\t\t\tPID: 1,\n\t\t},\n\t\tConfig: &backend_types.Config{\n\t\t\tStages: []*backend_types.Stage{\n\t\t\t\t{\n\t\t\t\t\tSteps: []*backend_types.Step{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName: \"clone\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tSteps: []*backend_types.Step{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName: \"step\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}}\n\tpipeline = setPipelineStepsOnPipeline(pipeline, pipelineItems)\n\tif len(pipeline.Workflows) != 1 {\n\t\tt.Fatal(\"Should generate three in total\")\n\t}\n\tif pipeline.Workflows[0].PipelineID != 1 {\n\t\tt.Fatal(\"Should set workflow's pipeline ID\")\n\t}\n\tif pipeline.Workflows[0].Children[0].PPID != 1 {\n\t\tt.Fatal(\"Should set step PPID\")\n\t}\n}\n\nfunc TestParsePipeline(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline := &model.Pipeline{\n\t\tID:    1,\n\t\tEvent: model.EventPush,\n\t\tAdditionalVariables: map[string]string{\n\t\t\t\"ADDITIONAL\": \"value\",\n\t\t},\n\t}\n\n\tuser := &model.User{\n\t\tID: 1,\n\t}\n\n\trepo := &model.Repo{\n\t\tID: 1,\n\t}\n\n\tyamls := []*forge_types.FileMeta{\n\t\t{\n\t\t\tName: \"woodpecker.yml\",\n\t\t\tData: []byte(`\nwhen:\n  - event: push\n\nsteps:\n  - name: test\n    image: alpine\n    environment:\n      HELLO:\n        from_secret: hello\n    commands:\n      - echo \"hello world\"\n`),\n\t\t},\n\t}\n\n\tenvs := map[string]string{\n\t\t\"FOO\": \"bar\",\n\t}\n\n\tforge := forge_mocks.NewMockForge(t)\n\tforge.On(\"Netrc\", mock.Anything, mock.Anything).Return(&model.Netrc{\n\t\tLogin:    \"user\",\n\t\tPassword: \"password\",\n\t}, nil)\n\tforge.On(\"Name\").Return(\"github\")\n\tforge.On(\"URL\").Return(\"https://github.com\")\n\n\tstore := store_mocks.NewMockStore(t)\n\tstore.On(\"GetPipelineLastBefore\", mock.Anything, mock.Anything, pipeline.ID).Return(&model.Pipeline{}, nil)\n\n\tmockManager := manager_mocks.NewMockManager(t)\n\tserver.Config.Services.Manager = mockManager\n\n\tsecretService := secret_service_mocks.NewMockService(t)\n\tsecretService.On(\"SecretListPipeline\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{\n\t\t{\n\t\t\tName:  \"hello\",\n\t\t\tValue: \"secret world\",\n\t\t},\n\t}, nil)\n\tmockManager.On(\"SecretServiceFromRepo\", mock.Anything).Return(secretService, nil)\n\n\tregistryService := registry_service_mocks.NewMockService(t)\n\tregistryService.On(\"RegistryListPipeline\", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Registry{\n\t\t{\n\t\t\tAddress:  \"docker.io\",\n\t\t\tUsername: \"user\",\n\t\t\tPassword: \"password\",\n\t\t},\n\t}, nil)\n\tmockManager.On(\"RegistryServiceFromRepo\", mock.Anything).Return(registryService, nil)\n\n\tmockManager.On(\"EnvironmentService\").Return(nil, nil)\n\n\tpipelineItems, err := parsePipeline(t.Context(), forge, store, pipeline, user, repo, yamls, envs)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, pipelineItems, 1)\n\tassert.Equal(t, \"test\", pipelineItems[0].Config.Stages[0].Steps[0].Name)\n\tassert.Equal(t, \"alpine\", pipelineItems[0].Config.Stages[0].Steps[0].Image)\n\tstep := pipelineItems[0].Config.Stages[0].Steps[0]\n\tassert.Equal(t, []string{`echo \"hello world\"`}, step.Commands)\n\tassert.Equal(t, \"value\", step.Environment[\"ADDITIONAL\"])\n\tassert.Equal(t, \"bar\", step.Environment[\"FOO\"])\n\tassert.Equal(t, \"secret world\", step.Environment[\"HELLO\"])\n}\n"
  },
  {
    "path": "server/pipeline/pipeline_status.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2019 mhmxs.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"time\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// PipelineStatus determine pipeline status based on corresponding workflow list.\nfunc PipelineStatus(workflows []*model.Workflow) model.StatusValue {\n\tstatus := model.StatusSuccess\n\n\tfor _, p := range workflows {\n\t\tstatus = MergeStatusValues(status, p.State)\n\t}\n\n\treturn status\n}\n\nfunc UpdateToStatusRunning(store store.Store, pipeline model.Pipeline, started int64) (*model.Pipeline, error) {\n\tpipeline.Status = model.StatusRunning\n\tpipeline.Started = started\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n\nfunc UpdateToStatusPending(store store.Store, pipeline model.Pipeline, reviewer string) (*model.Pipeline, error) {\n\tif reviewer != \"\" {\n\t\tpipeline.Reviewer = reviewer\n\t\tpipeline.Reviewed = time.Now().Unix()\n\t}\n\tpipeline.Status = model.StatusPending\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n\nfunc UpdateToStatusDeclined(store store.Store, pipeline model.Pipeline, reviewer string) (*model.Pipeline, error) {\n\tpipeline.Reviewer = reviewer\n\tpipeline.Status = model.StatusDeclined\n\tpipeline.Reviewed = time.Now().Unix()\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n\nfunc UpdateStatusToDone(store store.Store, pipeline model.Pipeline, status model.StatusValue, stopped int64) (*model.Pipeline, error) {\n\tpipeline.Status = status\n\tpipeline.Finished = stopped\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n\nfunc UpdateToStatusError(store store.Store, pipeline model.Pipeline, err error) (*model.Pipeline, error) {\n\tpipeline.Errors = errors.GetPipelineErrors(err)\n\tpipeline.Status = model.StatusError\n\tpipeline.Started = time.Now().Unix()\n\tpipeline.Finished = pipeline.Started\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n\nfunc UpdateToStatusKilled(store store.Store, pipeline model.Pipeline, cancelInfo *model.CancelInfo, state model.StatusValue) (*model.Pipeline, error) {\n\tpipeline.Status = state\n\tpipeline.Finished = time.Now().Unix()\n\tpipeline.CancelInfo = cancelInfo\n\treturn &pipeline, store.UpdatePipeline(&pipeline)\n}\n"
  },
  {
    "path": "server/pipeline/pipeline_status_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2019 mhmxs.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc mockStorePipeline(t *testing.T) store.Store {\n\ts := mocks.NewMockStore(t)\n\ts.On(\"UpdatePipeline\", mock.Anything).Return(nil)\n\treturn s\n}\n\nfunc TestUpdateToStatusRunning(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline, _ := UpdateToStatusRunning(mockStorePipeline(t), model.Pipeline{}, int64(1))\n\tassert.Equal(t, model.StatusRunning, pipeline.Status)\n\tassert.EqualValues(t, 1, pipeline.Started)\n}\n\nfunc TestUpdateToStatusPending(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now().Unix()\n\n\tpipeline, _ := UpdateToStatusPending(mockStorePipeline(t), model.Pipeline{}, \"Reviewer\")\n\n\tassert.Equal(t, model.StatusPending, pipeline.Status)\n\tassert.Equal(t, \"Reviewer\", pipeline.Reviewer)\n\tassert.LessOrEqual(t, now, pipeline.Reviewed)\n}\n\nfunc TestUpdateToStatusDeclined(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now().Unix()\n\n\tpipeline, _ := UpdateToStatusDeclined(mockStorePipeline(t), model.Pipeline{}, \"Reviewer\")\n\n\tassert.Equal(t, model.StatusDeclined, pipeline.Status)\n\tassert.Equal(t, \"Reviewer\", pipeline.Reviewer)\n\tassert.LessOrEqual(t, now, pipeline.Reviewed)\n}\n\nfunc TestUpdateToStatusToDone(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline, _ := UpdateStatusToDone(mockStorePipeline(t), model.Pipeline{}, \"status\", int64(1))\n\n\tassert.Equal(t, model.StatusValue(\"status\"), pipeline.Status)\n\tassert.EqualValues(t, 1, pipeline.Finished)\n}\n\nfunc TestUpdateToStatusError(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now().Unix()\n\n\tpipeline, _ := UpdateToStatusError(mockStorePipeline(t), model.Pipeline{}, errors.New(\"this is an error\"))\n\n\tassert.Len(t, pipeline.Errors, 1)\n\tassert.Equal(t, \"[generic] this is an error\", pipeline.Errors[0].Error())\n\tassert.Equal(t, model.StatusError, pipeline.Status)\n\tassert.Equal(t, pipeline.Started, pipeline.Finished)\n\tassert.LessOrEqual(t, now, pipeline.Started)\n\tassert.Equal(t, pipeline.Started, pipeline.Finished)\n}\n\nfunc TestUpdateToStatusKilled(t *testing.T) {\n\tt.Parallel()\n\n\tnow := time.Now().Unix()\n\tcancelInfo := &model.CancelInfo{\n\t\tSupersededBy: 2,\n\t}\n\n\tpipeline, _ := UpdateToStatusKilled(mockStorePipeline(t), model.Pipeline{}, cancelInfo, model.StatusKilled)\n\n\tassert.Equal(t, model.StatusKilled, pipeline.Status)\n\tassert.NotNil(t, pipeline.CancelInfo)\n\tassert.EqualValues(t, 2, pipeline.CancelInfo.SupersededBy)\n\tassert.LessOrEqual(t, now, pipeline.Finished)\n}\n"
  },
  {
    "path": "server/pipeline/queue.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n)\n\nfunc queuePipeline(ctx context.Context, repo *model.Repo, pipelineItems []*step_builder.Item) error {\n\tvar tasks []*model.Task\n\tfor _, item := range pipelineItems {\n\t\tif item.Workflow.State == model.StatusSkipped {\n\t\t\tcontinue\n\t\t}\n\t\ttask := &model.Task{\n\t\t\tID:         fmt.Sprint(item.Workflow.ID),\n\t\t\tPID:        item.Workflow.PID,\n\t\t\tName:       item.Workflow.Name,\n\t\t\tLabels:     make(map[string]string),\n\t\t\tPipelineID: item.Workflow.PipelineID,\n\t\t\tRepoID:     repo.ID,\n\t\t}\n\t\tmaps.Copy(task.Labels, item.Labels)\n\t\terr := task.ApplyLabelsFromRepo(repo)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttask.Dependencies = getTaskDependencies(item.DependsOn, pipelineItems)\n\t\ttask.RunOn = item.RunsOn\n\t\ttask.DepStatus = make(map[string]model.StatusValue)\n\n\t\ttask.Data, err = json.Marshal(rpc.Workflow{\n\t\t\tID:      fmt.Sprint(item.Workflow.ID),\n\t\t\tConfig:  item.Config,\n\t\t\tTimeout: repo.Timeout,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\ttasks = append(tasks, task)\n\t}\n\treturn server.Config.Services.Scheduler.PushAtOnce(ctx, tasks)\n}\n\nfunc getTaskDependencies(dependsOn []string, items []*step_builder.Item) (taskIDs []string) {\n\tfor _, dep := range dependsOn {\n\t\tfor _, pipelineItem := range items {\n\t\t\tif pipelineItem.Workflow.Name == dep {\n\t\t\t\ttaskIDs = append(taskIDs, fmt.Sprint(pipelineItem.Workflow.ID))\n\t\t\t}\n\t\t}\n\t}\n\treturn taskIDs\n}\n"
  },
  {
    "path": "server/pipeline/restart.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// Restart a pipeline by creating a new one out of the old and start it.\nfunc Restart(ctx context.Context, store store.Store, lastPipeline *model.Pipeline, user *model.User, repo *model.Repo, envs map[string]string) (*model.Pipeline, error) {\n\tforge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to load forge for repo '%s'\", repo.FullName)\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif lastPipeline.Status == model.StatusBlocked {\n\t\treturn nil, &ErrBadRequest{Msg: \"cannot restart a pipeline with status blocked\"}\n\t}\n\n\t// fetch the old pipeline config from the database\n\tconfigs, err := store.ConfigsForPipeline(lastPipeline.ID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"failure to get pipeline config for %s\", repo.FullName)\n\t\treturn nil, &ErrNotFound{Msg: fmt.Sprintf(\"failure to get pipeline config for %s. %s\", repo.FullName, err)}\n\t}\n\n\tvar pipelineFiles []*forge_types.FileMeta\n\tfor _, y := range configs {\n\t\tpipelineFiles = append(pipelineFiles, &forge_types.FileMeta{Data: y.Data, Name: y.Name})\n\t}\n\n\t// If the config service is active we should refetch the config in case something changed\n\tconfigService := server.Config.Services.Manager.ConfigServiceFromRepo(repo)\n\tpipelineFiles, err = configService.Fetch(ctx, forge, user, repo, lastPipeline, pipelineFiles, true)\n\tif err != nil {\n\t\treturn nil, &ErrBadRequest{\n\t\t\tMsg: fmt.Sprintf(\"On fetching external pipeline config: %s\", err),\n\t\t}\n\t}\n\n\tnewPipeline := createNewOutOfOld(lastPipeline)\n\tnewPipeline.Parent = lastPipeline.Number\n\tnewPipeline.Version = version.String()\n\n\terr = store.CreatePipeline(newPipeline)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to save pipeline for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif len(configs) == 0 {\n\t\tnewPipeline, uErr := UpdateToStatusError(store, *newPipeline, errors.New(\"pipeline definition not found\"))\n\t\tif uErr != nil {\n\t\t\tlog.Debug().Err(uErr).Msg(\"failure to update pipeline status\")\n\t\t} else {\n\t\t\tupdatePipelineStatus(ctx, forge, newPipeline, repo, user)\n\t\t}\n\t\treturn newPipeline, nil\n\t}\n\tif err := linkPipelineConfigs(store, configs, newPipeline.ID); err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to persist pipeline config for %s.\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tnewPipeline, pipelineItems, err := createPipelineItems(ctx, forge, store, newPipeline, user, repo, pipelineFiles, envs)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to createPipelineItems for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tif err := prepareStart(ctx, forge, store, newPipeline, user, repo); err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to prepare pipeline for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\tnewPipeline, err = start(ctx, forge, store, newPipeline, user, repo, pipelineItems)\n\tif err != nil {\n\t\tmsg := fmt.Sprintf(\"failure to start pipeline for %s\", repo.FullName)\n\t\tlog.Error().Err(err).Msg(msg)\n\t\treturn nil, errors.New(msg)\n\t}\n\n\treturn newPipeline, nil\n}\n\nfunc linkPipelineConfigs(store store.Store, configs []*model.Config, pipelineID int64) error {\n\tfor _, conf := range configs {\n\t\tpipelineConfig := &model.PipelineConfig{\n\t\t\tConfigID:   conf.ID,\n\t\t\tPipelineID: pipelineID,\n\t\t}\n\t\terr := store.PipelineConfigCreate(pipelineConfig)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc createNewOutOfOld(old *model.Pipeline) *model.Pipeline {\n\tnewPipeline := *old\n\tnewPipeline.ID = 0\n\tnewPipeline.Number = 0\n\tnewPipeline.Status = model.StatusPending\n\tnewPipeline.Started = 0\n\tnewPipeline.Finished = 0\n\tnewPipeline.Errors = nil\n\treturn &newPipeline\n}\n"
  },
  {
    "path": "server/pipeline/start.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// start a pipeline, make sure it was stored persistent in the store before.\nfunc start(ctx context.Context, forge forge.Forge, store store.Store, activePipeline *model.Pipeline, user *model.User, repo *model.Repo, pipelineItems []*step_builder.Item) (*model.Pipeline, error) {\n\t// call to cancel previous pipelines if needed\n\tif err := cancelPreviousPipelines(ctx, forge, store, activePipeline, repo, user); err != nil {\n\t\t// should be not breaking\n\t\tlog.Error().Err(err).Msg(\"failed to cancel previous pipelines\")\n\t}\n\n\tpublishPipeline(ctx, forge, activePipeline, repo, user)\n\n\tif err := queuePipeline(ctx, repo, pipelineItems); err != nil {\n\t\tlog.Error().Err(err).Msg(\"queuePipeline\")\n\t\treturn nil, err\n\t}\n\n\treturn activePipeline, nil\n}\n\nfunc prepareStart(ctx context.Context, forge forge.Forge, store store.Store, activePipeline *model.Pipeline, user *model.User, repo *model.Repo) error {\n\tif err := store.WorkflowsCreate(activePipeline.Workflows); err != nil {\n\t\tlog.Error().Err(err).Str(\"repo\", repo.FullName).Msgf(\"error persisting steps for %s#%d\", repo.FullName, activePipeline.Number)\n\t\treturn err\n\t}\n\n\tpublishPipeline(ctx, forge, activePipeline, repo, user)\n\treturn nil\n}\n\nfunc publishPipeline(ctx context.Context, forge forge.Forge, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User) {\n\tif err := publishToTopic(ctx, pipeline, repo); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not push pipeline status change to pubsub provider\")\n\t}\n\tupdatePipelineStatus(ctx, forge, pipeline, repo, repoUser)\n}\n"
  },
  {
    "path": "server/pipeline/status.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\n// list of statuses by their priority. Most important is on top.\nvar statusPriorityOrder = []model.StatusValue{\n\t// blocked, declined and created cannot appear in the\n\t// same workflow/pipeline at the same time\n\tmodel.StatusDeclined,\n\tmodel.StatusBlocked,\n\tmodel.StatusCreated,\n\n\t// errors have highest priority.\n\tmodel.StatusError,\n\n\t// skipped and killed cannot appear together with running/pending.\n\tmodel.StatusKilled,\n\tmodel.StatusCanceled,\n\n\t// running states\n\tmodel.StatusRunning,\n\tmodel.StatusPending,\n\n\t// finished states\n\tmodel.StatusFailure,\n\tmodel.StatusSuccess,\n\n\t// skipped due to status condition\n\tmodel.StatusSkipped,\n}\n\nvar priorityMap map[model.StatusValue]int = buildPriorityMap()\n\nfunc buildPriorityMap() map[model.StatusValue]int {\n\tm := map[model.StatusValue]int{}\n\tfor i, s := range statusPriorityOrder {\n\t\tm[s] = i\n\t}\n\treturn m\n}\n\nfunc MergeStatusValues(s, t model.StatusValue) model.StatusValue {\n\t// both are skipped due to cancellation -> canceled\n\tif s == model.StatusCanceled && t == model.StatusCanceled {\n\t\treturn model.StatusCanceled\n\t}\n\t// if only one was skipped -> use killed as the workflow/pipeline was running once already\n\tif s == model.StatusCanceled {\n\t\ts = model.StatusKilled\n\t}\n\tif t == model.StatusCanceled {\n\t\tt = model.StatusKilled\n\t}\n\treturn statusPriorityOrder[min(priorityMap[s], priorityMap[t])]\n}\n"
  },
  {
    "path": "server/pipeline/status_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestStatusValueMerge(t *testing.T) {\n\ttests := []struct {\n\t\ts model.StatusValue\n\t\tt model.StatusValue\n\t\te model.StatusValue\n\t}{\n\t\t{\n\t\t\ts: model.StatusSuccess,\n\t\t\tt: model.StatusSkipped,\n\t\t\te: model.StatusSuccess,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusSuccess,\n\t\t\tt: model.StatusSuccess,\n\t\t\te: model.StatusSuccess,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusFailure,\n\t\t\tt: model.StatusSuccess,\n\t\t\te: model.StatusFailure,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusRunning,\n\t\t\tt: model.StatusSuccess,\n\t\t\te: model.StatusRunning,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusRunning,\n\t\t\tt: model.StatusFailure,\n\t\t\te: model.StatusRunning,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusFailure,\n\t\t\tt: model.StatusKilled,\n\t\t\te: model.StatusKilled,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusSkipped,\n\t\t\tt: model.StatusKilled,\n\t\t\te: model.StatusKilled,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusSkipped,\n\t\t\tt: model.StatusSkipped,\n\t\t\te: model.StatusSkipped,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusSkipped,\n\t\t\tt: model.StatusCanceled,\n\t\t\te: model.StatusKilled,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusSuccess,\n\t\t\tt: model.StatusCanceled,\n\t\t\te: model.StatusKilled,\n\t\t},\n\t\t{\n\t\t\ts: model.StatusFailure,\n\t\t\tt: model.StatusCanceled,\n\t\t\te: model.StatusKilled,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tassert.Equal(t, tt.e, MergeStatusValues(tt.s, tt.t))\n\t\tassert.Equal(t, tt.e, MergeStatusValues(tt.t, tt.s))\n\t}\n}\n"
  },
  {
    "path": "server/pipeline/step_builder/metadata.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage step_builder\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// MetadataFromStruct return the metadata from a pipeline will run with.\nfunc MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, prev *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata {\n\thost := sysURL\n\turi, err := url.Parse(sysURL)\n\tif err == nil {\n\t\thost = uri.Host\n\t}\n\n\tfForge := metadata.Forge{}\n\tif forge != nil {\n\t\tfForge = metadata.Forge{\n\t\t\tType: forge.Name(),\n\t\t\tURL:  forge.URL(),\n\t\t}\n\t}\n\n\tfRepo := metadata.Repo{}\n\tif repo != nil {\n\t\tfRepo = metadata.Repo{\n\t\t\tID:          repo.ID,\n\t\t\tName:        repo.Name,\n\t\t\tOwner:       repo.Owner,\n\t\t\tRemoteID:    fmt.Sprint(repo.ForgeRemoteID),\n\t\t\tForgeURL:    repo.ForgeURL,\n\t\t\tCloneURL:    repo.Clone,\n\t\t\tCloneSSHURL: repo.CloneSSH,\n\t\t\tPrivate:     repo.IsSCMPrivate,\n\t\t\tBranch:      repo.Branch,\n\t\t\tTrusted: metadata.TrustedConfiguration{\n\t\t\t\tNetwork:  repo.Trusted.Network,\n\t\t\t\tVolumes:  repo.Trusted.Volumes,\n\t\t\t\tSecurity: repo.Trusted.Security,\n\t\t\t},\n\t\t}\n\n\t\tif idx := strings.LastIndex(repo.FullName, \"/\"); idx != -1 {\n\t\t\tif fRepo.Name == \"\" && repo.FullName != \"\" {\n\t\t\t\tfRepo.Name = repo.FullName[idx+1:]\n\t\t\t}\n\t\t\tif fRepo.Owner == \"\" && repo.FullName != \"\" {\n\t\t\t\tfRepo.Owner = repo.FullName[:idx]\n\t\t\t}\n\t\t}\n\t}\n\n\tfWorkflow := metadata.Workflow{}\n\tif workflow != nil {\n\t\tfWorkflow = metadata.Workflow{\n\t\t\tName:   workflow.Name,\n\t\t\tNumber: workflow.PID,\n\t\t\tMatrix: workflow.Environ,\n\t\t}\n\t}\n\n\treturn metadata.Metadata{\n\t\tRepo:     fRepo,\n\t\tCurr:     metadataPipelineFromModelPipeline(pipeline, true),\n\t\tPrev:     metadataPipelineFromModelPipeline(prev, false),\n\t\tWorkflow: fWorkflow,\n\t\tStep:     metadata.Step{},\n\t\tSys: metadata.System{\n\t\t\tName:     \"woodpecker\",\n\t\t\tURL:      sysURL,\n\t\t\tHost:     host,\n\t\t\tPlatform: \"\", // will be set by pipeline platform option or by agent\n\t\t\tVersion:  version.Version,\n\t\t},\n\t\tForge: fForge,\n\t}\n}\n\nfunc metadataPipelineFromModelPipeline(pipeline *model.Pipeline, includeParent bool) metadata.Pipeline {\n\tif pipeline == nil {\n\t\treturn metadata.Pipeline{}\n\t}\n\n\tparent := int64(0)\n\tif includeParent {\n\t\tparent = pipeline.Parent\n\t}\n\n\treturn metadata.Pipeline{\n\t\tNumber:      pipeline.Number,\n\t\tParent:      parent,\n\t\tCreated:     pipeline.Created,\n\t\tStarted:     pipeline.Started,\n\t\tFinished:    pipeline.Finished,\n\t\tStatus:      string(pipeline.Status),\n\t\tEvent:       metadata.Event(pipeline.Event),\n\t\tEventReason: pipeline.EventReason,\n\t\tForgeURL:    pipeline.ForgeURL,\n\t\tDeployTo:    pipeline.DeployTo,\n\t\tDeployTask:  pipeline.DeployTask,\n\t\tCommit: metadata.Commit{\n\t\t\tSha:     pipeline.Commit,\n\t\t\tRef:     pipeline.Ref,\n\t\t\tRefspec: pipeline.Refspec,\n\t\t\tBranch:  pipeline.Branch,\n\t\t\tMessage: pipeline.Message,\n\t\t\tAuthor: metadata.Author{\n\t\t\t\tName:  pipeline.Author,\n\t\t\t\tEmail: pipeline.Email,\n\t\t\t},\n\t\t\tChangedFiles:         pipeline.ChangedFiles,\n\t\t\tPullRequestLabels:    pipeline.PullRequestLabels,\n\t\t\tPullRequestMilestone: pipeline.PullRequestMilestone,\n\t\t\tIsPrerelease:         pipeline.IsPrerelease,\n\t\t},\n\t\tCron:   pipeline.Cron,\n\t\tAuthor: pipeline.Author,\n\t\tAvatar: pipeline.Avatar,\n\t}\n}\n"
  },
  {
    "path": "server/pipeline/step_builder/metadata_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage step_builder\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestMetadataFromStruct(t *testing.T) {\n\tforge := mocks.NewMockForge(t)\n\tforge.On(\"Name\").Return(\"gitea\")\n\tforge.On(\"URL\").Return(\"https://gitea.com\")\n\n\ttestCases := []struct {\n\t\tname             string\n\t\tforge            metadata.ServerForge\n\t\trepo             *model.Repo\n\t\tpipeline, prev   *model.Pipeline\n\t\tworkflow         *model.Workflow\n\t\tsysURL           string\n\t\texpectedMetadata metadata.Metadata\n\t\texpectedEnviron  map[string]string\n\t}{\n\t\t{\n\t\t\tname:             \"Test with empty info\",\n\t\t\texpectedMetadata: metadata.Metadata{Sys: metadata.System{Name: \"woodpecker\"}},\n\t\t\texpectedEnviron: map[string]string{\n\t\t\t\t\"CI\":                  \"woodpecker\",\n\t\t\t\t\"CI_PIPELINE_CREATED\": \"0\", \"CI_PIPELINE_FILES\": \"[]\", \"CI_PIPELINE_NUMBER\": \"0\",\n\t\t\t\t\"CI_PIPELINE_PARENT\": \"0\", \"CI_PIPELINE_STARTED\": \"0\", \"CI_PIPELINE_URL\": \"/repos/0/pipeline/0\",\n\t\t\t\t\"CI_PREV_PIPELINE_CREATED\":  \"0\",\n\t\t\t\t\"CI_PREV_PIPELINE_FINISHED\": \"0\", \"CI_PREV_PIPELINE_NUMBER\": \"0\", \"CI_PREV_PIPELINE_PARENT\": \"0\",\n\t\t\t\t\"CI_PREV_PIPELINE_STARTED\": \"0\", \"CI_PREV_PIPELINE_URL\": \"/repos/0/pipeline/0\",\n\t\t\t\t\"CI_REPO_PRIVATE\": \"false\", \"CI_REPO_TRUSTED\": \"false\", \"CI_REPO_TRUSTED_NETWORK\": \"false\", \"CI_REPO_TRUSTED_SECURITY\": \"false\", \"CI_REPO_TRUSTED_VOLUMES\": \"false\",\n\t\t\t\t\"CI_STEP_NUMBER\": \"0\", \"CI_STEP_URL\": \"/repos/0/pipeline/0\", \"CI_SYSTEM_NAME\": \"woodpecker\",\n\t\t\t\t\"CI_WORKFLOW_NUMBER\": \"0\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"Test with forge\",\n\t\t\tforge:    forge,\n\t\t\trepo:     &model.Repo{FullName: \"testUser/testRepo\", ForgeURL: \"https://gitea.com/testUser/testRepo\", Clone: \"https://gitea.com/testUser/testRepo.git\", CloneSSH: \"git@gitea.com:testUser/testRepo.git\", Branch: \"main\", IsSCMPrivate: true},\n\t\t\tpipeline: &model.Pipeline{Number: 3, ChangedFiles: []string{\"test.go\", \"markdown file.md\"}},\n\t\t\tprev:     &model.Pipeline{Number: 2},\n\t\t\tworkflow: &model.Workflow{Name: \"hello\"},\n\t\t\tsysURL:   \"https://example.com\",\n\t\t\texpectedMetadata: metadata.Metadata{\n\t\t\t\tForge: metadata.Forge{Type: \"gitea\", URL: \"https://gitea.com\"},\n\t\t\t\tSys:   metadata.System{Name: \"woodpecker\", Host: \"example.com\", URL: \"https://example.com\"},\n\t\t\t\tRepo:  metadata.Repo{Owner: \"testUser\", Name: \"testRepo\", ForgeURL: \"https://gitea.com/testUser/testRepo\", CloneURL: \"https://gitea.com/testUser/testRepo.git\", CloneSSHURL: \"git@gitea.com:testUser/testRepo.git\", Branch: \"main\", Private: true},\n\t\t\t\tCurr: metadata.Pipeline{\n\t\t\t\t\tNumber: 3,\n\t\t\t\t\tCommit: metadata.Commit{ChangedFiles: []string{\"test.go\", \"markdown file.md\"}},\n\t\t\t\t},\n\t\t\t\tPrev:     metadata.Pipeline{Number: 2},\n\t\t\t\tWorkflow: metadata.Workflow{Name: \"hello\"},\n\t\t\t},\n\t\t\texpectedEnviron: map[string]string{\n\t\t\t\t\"CI\":            \"woodpecker\",\n\t\t\t\t\"CI_FORGE_TYPE\": \"gitea\", \"CI_FORGE_URL\": \"https://gitea.com\",\n\t\t\t\t\"CI_PIPELINE_CREATED\": \"0\", \"CI_PIPELINE_FILES\": `[\"test.go\",\"markdown file.md\"]`,\n\t\t\t\t\"CI_PIPELINE_NUMBER\": \"3\", \"CI_PIPELINE_PARENT\": \"0\", \"CI_PIPELINE_STARTED\": \"0\", \"CI_PIPELINE_URL\": \"https://example.com/repos/0/pipeline/3\",\n\t\t\t\t\"CI_PREV_PIPELINE_CREATED\":  \"0\",\n\t\t\t\t\"CI_PREV_PIPELINE_FINISHED\": \"0\", \"CI_PREV_PIPELINE_NUMBER\": \"2\", \"CI_PREV_PIPELINE_PARENT\": \"0\",\n\t\t\t\t\"CI_PREV_PIPELINE_STARTED\": \"0\", \"CI_PREV_PIPELINE_URL\": \"https://example.com/repos/0/pipeline/2\", \"CI_REPO\": \"testUser/testRepo\", \"CI_REPO_CLONE_URL\": \"https://gitea.com/testUser/testRepo.git\", \"CI_REPO_CLONE_SSH_URL\": \"git@gitea.com:testUser/testRepo.git\",\n\t\t\t\t\"CI_REPO_DEFAULT_BRANCH\": \"main\", \"CI_REPO_NAME\": \"testRepo\", \"CI_REPO_OWNER\": \"testUser\", \"CI_REPO_PRIVATE\": \"true\",\n\t\t\t\t\"CI_REPO_TRUSTED\": \"false\", \"CI_REPO_TRUSTED_NETWORK\": \"false\", \"CI_REPO_TRUSTED_SECURITY\": \"false\", \"CI_REPO_TRUSTED_VOLUMES\": \"false\",\n\t\t\t\t\"CI_REPO_URL\": \"https://gitea.com/testUser/testRepo\", \"CI_STEP_NUMBER\": \"0\", \"CI_STEP_URL\": \"https://example.com/repos/0/pipeline/3\", \"CI_SYSTEM_HOST\": \"example.com\",\n\t\t\t\t\"CI_SYSTEM_NAME\": \"woodpecker\", \"CI_SYSTEM_URL\": \"https://example.com\", \"CI_WORKFLOW_NAME\": \"hello\", \"CI_WORKFLOW_NUMBER\": \"0\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, testCase := range testCases {\n\t\tt.Run(testCase.name, func(t *testing.T) {\n\t\t\tresult := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.prev, testCase.workflow, testCase.sysURL)\n\t\t\tassert.EqualValues(t, testCase.expectedMetadata, result)\n\t\t\tassert.EqualValues(t, testCase.expectedEnviron, result.Environ())\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/pipeline/step_builder/step_builder.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage step_builder\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/oklog/ulid/v2\"\n\t\"github.com/rs/zerolog/log\"\n\t\"go.uber.org/multierr\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\tbackend_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types\"\n\tpipeline_errors \"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix\"\n\tyaml_types \"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// StepBuilder Takes the hook data and the yaml and returns the internal data model.\ntype StepBuilder struct {\n\tRepo                *model.Repo     // TODO: get rid of server dependency\n\tCurr                *model.Pipeline // TODO: get rid of server dependency\n\tPrev                *model.Pipeline // TODO: get rid of server dependency\n\tHost                string\n\tYamls               []*forge_types.FileMeta\n\tEnvs                map[string]string\n\tForge               metadata.ServerForge\n\tDefaultLabels       map[string]string\n\tRepoTrusted         *metadata.TrustedConfiguration\n\tTrustedClonePlugins []string\n\tPrivilegedPlugins   []string\n\tCompilerOptions     []compiler.Option\n}\n\ntype Item struct {\n\tWorkflow  *model.Workflow // TODO: get rid of server dependency\n\tLabels    map[string]string\n\tDependsOn []string\n\tRunsOn    []string\n\tConfig    *backend_types.Config\n}\n\nfunc (b *StepBuilder) Build() (items []*Item, errorsAndWarnings error) {\n\tb.Yamls = forge_types.SortByName(b.Yamls)\n\n\tpidSequence := 1\n\n\tfor _, y := range b.Yamls {\n\t\t// matrix axes\n\t\taxes, err := matrix.ParseString(string(y.Data))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif len(axes) == 0 {\n\t\t\taxes = append(axes, matrix.Axis{})\n\t\t}\n\n\t\tfor i, axis := range axes {\n\t\t\tworkflow := &model.Workflow{\n\t\t\t\tPID:     pidSequence,\n\t\t\t\tState:   model.StatusPending,\n\t\t\t\tEnviron: axis,\n\t\t\t\tName:    SanitizePath(y.Name),\n\t\t\t}\n\t\t\tif len(axes) > 1 {\n\t\t\t\tworkflow.AxisID = i + 1\n\t\t\t}\n\t\t\titem, err := b.genItemForWorkflow(workflow, axis, string(y.Data))\n\t\t\tif err != nil && pipeline_errors.HasBlockingErrors(err) {\n\t\t\t\treturn nil, err\n\t\t\t} else if err != nil {\n\t\t\t\terrorsAndWarnings = multierr.Append(errorsAndWarnings, err)\n\t\t\t}\n\n\t\t\tif item == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\titems = append(items, item)\n\t\t\tpidSequence++\n\t\t}\n\n\t\t// TODO: add summary workflow that send status back based on workflows generated by matrix function\n\t\t// depend on https://github.com/woodpecker-ci/woodpecker/issues/778\n\t}\n\n\titems = filterItemsWithMissingDependencies(items)\n\n\treturn items, errorsAndWarnings\n}\n\nfunc (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (item *Item, errorsAndWarnings error) {\n\tworkflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Prev, workflow, b.Host)\n\tenviron := b.environmentVariables(workflowMetadata, axis)\n\n\t// add global environment variables for substituting\n\tfor k, v := range b.Envs {\n\t\tif _, exists := environ[k]; exists {\n\t\t\t// don't override existing values\n\t\t\tcontinue\n\t\t}\n\t\tenviron[k] = v\n\t}\n\n\t// substitute vars\n\tsubstituted, err := metadata.EnvVarSubst(data, environ)\n\tif err != nil {\n\t\treturn nil, multierr.Append(errorsAndWarnings, err)\n\t}\n\n\t// parse yaml pipeline\n\tparsed, err := yaml.ParseString(substituted)\n\tif err != nil {\n\t\treturn nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler}\n\t}\n\n\t// lint pipeline\n\terrorsAndWarnings = multierr.Append(errorsAndWarnings, linter.New(\n\t\tlinter.WithTrusted(linter.TrustedConfiguration{\n\t\t\tNetwork:  b.Repo.Trusted.Network,\n\t\t\tVolumes:  b.Repo.Trusted.Volumes,\n\t\t\tSecurity: b.Repo.Trusted.Security,\n\t\t}),\n\t\tlinter.PrivilegedPlugins(b.PrivilegedPlugins),\n\t\tlinter.WithTrustedClonePlugins(b.TrustedClonePlugins),\n\t).Lint([]*linter.WorkflowConfig{{\n\t\tWorkflow:  parsed,\n\t\tFile:      workflow.Name,\n\t\tRawConfig: data,\n\t}}))\n\tif pipeline_errors.HasBlockingErrors(errorsAndWarnings) {\n\t\treturn nil, errorsAndWarnings\n\t}\n\n\t// checking if filtered.\n\tif match, err := parsed.When.Match(workflowMetadata, true, environ); !match && err == nil {\n\t\tlog.Debug().Str(\"pipeline\", workflow.Name).Msg(\n\t\t\t\"marked as skipped, does not match metadata\",\n\t\t)\n\t\treturn nil, nil\n\t} else if err != nil {\n\t\tlog.Debug().Str(\"pipeline\", workflow.Name).Msg(\n\t\t\t\"pipeline config could not be parsed\",\n\t\t)\n\t\treturn nil, multierr.Append(errorsAndWarnings, err)\n\t}\n\n\tir, err := b.toInternalRepresentation(parsed, environ, workflowMetadata, workflow.ID)\n\tif err != nil {\n\t\treturn nil, multierr.Append(errorsAndWarnings, err)\n\t}\n\n\tif len(ir.Stages) == 0 {\n\t\treturn nil, nil\n\t}\n\n\titem = &Item{\n\t\tWorkflow:  workflow,\n\t\tConfig:    ir,\n\t\tLabels:    parsed.Labels,\n\t\tDependsOn: parsed.DependsOn,\n\t\tRunsOn:    parsed.RunsOn, //nolint:staticcheck // TODO: remove in next major.\n\t}\n\tif len(item.Labels) == 0 {\n\t\titem.Labels = make(map[string]string, len(b.DefaultLabels))\n\t\t// Set default labels if no labels are defined in the pipeline\n\t\tmaps.Copy(item.Labels, b.DefaultLabels)\n\t}\n\n\tif !slices.Contains(item.RunsOn, \"failure\") && parsed.When.IncludesStatusFailure(workflowMetadata, true, environ) {\n\t\titem.RunsOn = append(item.RunsOn, \"failure\")\n\t}\n\tif !slices.Contains(item.RunsOn, \"success\") && parsed.When.IncludesStatusFailure(workflowMetadata, true, environ) {\n\t\titem.RunsOn = append(item.RunsOn, \"success\")\n\t}\n\n\t// \"woodpecker-ci.org\" namespace is reserved for internal use\n\tfor key := range item.Labels {\n\t\tif strings.HasPrefix(key, pipeline.InternalLabelPrefix) {\n\t\t\tlog.Debug().Str(\"forge\", b.Forge.Name()).Str(\"repo\", b.Repo.FullName).Str(\"label\", key).Msg(\"dropped pipeline label with reserved prefix woodpecker-ci.org\")\n\t\t\tdelete(item.Labels, key)\n\t\t}\n\t}\n\n\t// Add Woodpecker managed labels to the pipeline\n\titem.Labels[pipeline.LabelForgeRemoteID] = b.Forge.Name()\n\titem.Labels[pipeline.LabelRepoForgeID] = string(b.Repo.ForgeRemoteID)\n\titem.Labels[pipeline.LabelRepoID] = strconv.FormatInt(b.Repo.ID, 10)\n\titem.Labels[pipeline.LabelRepoName] = b.Repo.Name\n\titem.Labels[pipeline.LabelRepoFullName] = b.Repo.FullName\n\titem.Labels[pipeline.LabelBranch] = b.Repo.Branch\n\titem.Labels[pipeline.LabelOrgID] = strconv.FormatInt(b.Repo.OrgID, 10)\n\n\tfor stageI := range item.Config.Stages {\n\t\tfor stepI := range item.Config.Stages[stageI].Steps {\n\t\t\titem.Config.Stages[stageI].Steps[stepI].WorkflowLabels = item.Labels\n\t\t\titem.Config.Stages[stageI].Steps[stepI].OrgID = b.Repo.OrgID\n\t\t}\n\t}\n\n\treturn item, errorsAndWarnings\n}\n\nfunc filterItemsWithMissingDependencies(items []*Item) []*Item {\n\titemsToRemove := make([]*Item, 0)\n\n\tfor _, item := range items {\n\t\tfor _, dep := range item.DependsOn {\n\t\t\tif !containsItemWithName(dep, items) {\n\t\t\t\titemsToRemove = append(itemsToRemove, item)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(itemsToRemove) > 0 {\n\t\tfiltered := make([]*Item, 0)\n\t\tfor _, item := range items {\n\t\t\tif !containsItemWithName(item.Workflow.Name, itemsToRemove) {\n\t\t\t\tfiltered = append(filtered, item)\n\t\t\t}\n\t\t}\n\t\t// Recursive to handle transitive deps\n\t\treturn filterItemsWithMissingDependencies(filtered)\n\t}\n\n\treturn items\n}\n\nfunc containsItemWithName(name string, items []*Item) bool {\n\tfor _, item := range items {\n\t\tif name == item.Workflow.Name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (b *StepBuilder) environmentVariables(metadata metadata.Metadata, axis matrix.Axis) map[string]string {\n\tenviron := metadata.Environ()\n\tmaps.Copy(environ, axis)\n\treturn environ\n}\n\nfunc (b *StepBuilder) toInternalRepresentation(parsed *yaml_types.Workflow, environ map[string]string, metadata metadata.Metadata, workflowID int64) (*backend_types.Config, error) {\n\toptions := []compiler.Option{}\n\toptions = append(options,\n\t\tcompiler.WithEnviron(environ),\n\t\tcompiler.WithEnviron(b.Envs),\n\t\tcompiler.WithEscalated(b.PrivilegedPlugins...),\n\t\tcompiler.WithTrustedClonePlugins(b.TrustedClonePlugins),\n\t\tcompiler.WithPrefix(\n\t\t\tfmt.Sprintf(\n\t\t\t\t\"wp_%s_%d\",\n\t\t\t\tstrings.ToLower(ulid.Make().String()),\n\t\t\t\tworkflowID,\n\t\t\t),\n\t\t),\n\t\tcompiler.WithMetadata(metadata),\n\t\tcompiler.WithTrustedSecurity(b.RepoTrusted.Security),\n\t)\n\n\t// by adding the passed in options last, we allow them\n\t// to override any of the default options set above\n\toptions = append(options, b.CompilerOptions...)\n\n\treturn compiler.New(options...).Compile(parsed)\n}\n\nfunc SanitizePath(path string) string {\n\tpath = filepath.Base(path)\n\tpath = strings.TrimSuffix(path, \".yml\")\n\tpath = strings.TrimSuffix(path, \".yaml\")\n\tpath = strings.TrimPrefix(path, \".\")\n\treturn path\n}\n"
  },
  {
    "path": "server/pipeline/step_builder/step_builder_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage step_builder\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/errors\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestGlobalEnvsubst(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge: getMockForge(t),\n\t\tEnvs: map[string]string{\n\t\t\t\"KEY_K\": \"VALUE_V\",\n\t\t\t\"IMAGE\": \"scratch\",\n\t\t},\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr: &model.Pipeline{\n\t\t\tMessage: \"aaa\",\n\t\t\tEvent:   model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: ${IMAGE}\n    settings:\n      yyy: ${CI_COMMIT_MESSAGE}\n`)},\n\t\t},\n\t}\n\n\t_, err := b.Build()\n\tassert.NoError(t, err)\n}\n\nfunc TestMissingGlobalEnvsubst(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge: getMockForge(t),\n\t\tEnvs: map[string]string{\n\t\t\t\"KEY_K\":    \"VALUE_V\",\n\t\t\t\"NO_IMAGE\": \"scratch\",\n\t\t},\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr: &model.Pipeline{\n\t\t\tMessage: \"aaa\",\n\t\t\tEvent:   model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: ${IMAGE}\n    settings:\n      yyy: ${CI_COMMIT_MESSAGE}\n`)},\n\t\t},\n\t}\n\n\t_, err := b.Build()\n\tassert.Error(t, err, \"test erroneously succeeded\")\n}\n\nfunc TestMultilineEnvsubst(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr: &model.Pipeline{\n\t\t\tMessage: `aaa\nbbb`,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: xxx\n    image: scratch\n    settings:\n      yyy: ${CI_COMMIT_MESSAGE}\n`)},\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n    settings:\n      yyy: ${CI_COMMIT_MESSAGE}\n`)},\n\t\t},\n\t}\n\n\t_, err := b.Build()\n\tassert.NoError(t, err)\n}\n\nfunc TestMultiPipeline(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepo:        &model.Repo{},\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tCurr: &model.Pipeline{\n\t\t\tEvent: model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: xxx\n    image: scratch\n`)},\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 2, \"Should have generated 2 items\")\n}\n\nfunc TestDependsOn(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepo:        &model.Repo{},\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tCurr: &model.Pipeline{\n\t\t\tEvent: model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Name: \"lint\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t\t{Name: \"test\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t\t{Name: \"deploy\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: deploy\n    image: scratch\n\ndepends_on:\n  - lint\n  - test\n`)},\n\t\t\t{Name: \"missing dependencies\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: deploy\n    image: scratch\n\ndepends_on:\n  - missing\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 3, \"Should have generated 3 items\")\n\tassert.Len(t, items[0].DependsOn, 2, \"Should have 2 dependencies\")\n\tassert.Equal(t, \"test\", items[0].DependsOn[1], \"Should depend on test\")\n}\n\nfunc TestRunsOn(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr: &model.Pipeline{\n\t\t\tEvent: model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\n  status: [ success, failure ]\n\nsteps:\n  - name: deploy\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items[0].RunsOn, 2, \"Should run on success and failure\")\n\tassert.ElementsMatchf(t, []string{\"success\", \"failure\"}, items[0].RunsOn, \"Should run on failure\")\n}\n\nfunc TestPipelineName(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{Config: \".woodpecker\"},\n\t\tCurr: &model.Pipeline{\n\t\t\tEvent: model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Name: \".woodpecker/lint.yml\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t\t{Name: \".woodpecker/.test.yml\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tpipelineNames := []string{items[0].Workflow.Name, items[1].Workflow.Name}\n\tassert.True(t, containsItemWithName(\"lint\", items) && containsItemWithName(\"test\", items),\n\t\t\"Pipeline name should be 'lint' and 'test' but are '%v'\", pipelineNames)\n}\n\nfunc TestBranchFilter(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr: &model.Pipeline{\n\t\t\tBranch: \"dev\",\n\t\t\tEvent:  model.EventPush,\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tHost: \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\n  branch: main\nsteps:\n  - name: xxx\n    image: scratch\n`)},\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 1, \"Should have generated 1 pipeline\")\n\tassert.Equal(t, model.StatusPending, items[0].Workflow.State, \"Should run on dev branch\")\n}\n\nfunc TestRootWhenFilter(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        &model.Pipeline{Event: \"tag\"},\n\t\tPrev:        &model.Pipeline{},\n\t\tHost:        \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event:\n    - tag\nsteps:\n  - name: xxx\n    image: scratch\n`)},\n\t\t\t{Data: []byte(`\nwhen:\n  event:\n    - push\nsteps:\n  - name: xxx\n    image: scratch\n`)},\n\t\t\t{Data: []byte(`\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.False(t, errors.HasBlockingErrors(err))\n\tassert.Len(t, items, 2, \"Should have generated 2 items\")\n}\n\nfunc TestZeroSteps(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline := &model.Pipeline{\n\t\tBranch: \"dev\",\n\t\tEvent:  model.EventPush,\n\t}\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        pipeline,\n\t\tPrev:        &model.Pipeline{},\n\t\tHost:        \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nskip_clone: true\nsteps:\n  - name: build\n    when:\n      branch: notdev\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Empty(t, items, \"Should not generate a pipeline item if there are no steps\")\n}\n\nfunc TestZeroStepsAsMultiPipelineTransitiveDeps(t *testing.T) {\n\tt.Parallel()\n\n\tpipeline := &model.Pipeline{\n\t\tBranch: \"dev\",\n\t\tEvent:  model.EventPush,\n\t}\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        pipeline,\n\t\tPrev:        &model.Pipeline{},\n\t\tHost:        \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Name: \"zerostep\", Data: []byte(`\nwhen:\n  event: push\nskip_clone: true\nsteps:\n  - name: build\n    when:\n      branch: notdev\n    image: scratch\n`)},\n\t\t\t{Name: \"justastep\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t\t{Name: \"shouldbefiltered\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\ndepends_on: [ zerostep ]\n`)},\n\t\t\t{Name: \"shouldbefilteredtoo\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\ndepends_on: [ shouldbefiltered ]\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 1, \"Zerostep and the step that depends on it, and the one depending on it should not generate a pipeline item\")\n\tassert.Equal(t, \"justastep\", items[0].Workflow.Name, \"justastep should have been generated\")\n}\n\nfunc TestSanitizePath(t *testing.T) {\n\tt.Parallel()\n\n\ttestTable := []struct {\n\t\tpath          string\n\t\tsanitizedPath string\n\t}{\n\t\t{\n\t\t\tpath:          \".woodpecker/test.yml\",\n\t\t\tsanitizedPath: \"test\",\n\t\t},\n\t\t{\n\t\t\tpath:          \".woodpecker.yml\",\n\t\t\tsanitizedPath: \"woodpecker\",\n\t\t},\n\t\t{\n\t\t\tpath:          \"folder/sub-folder/test.yml\",\n\t\t\tsanitizedPath: \"test\",\n\t\t},\n\t\t{\n\t\t\tpath:          \".woodpecker/test.yaml\",\n\t\t\tsanitizedPath: \"test\",\n\t\t},\n\t\t{\n\t\t\tpath:          \".woodpecker.yaml\",\n\t\t\tsanitizedPath: \"woodpecker\",\n\t\t},\n\t\t{\n\t\t\tpath:          \"folder/sub-folder/test.yaml\",\n\t\t\tsanitizedPath: \"test\",\n\t\t},\n\t}\n\n\tfor _, test := range testTable {\n\t\tassert.Equal(t, test.sanitizedPath, SanitizePath(test.path), \"Path hasn't been sanitized correctly\")\n\t}\n}\n\nfunc TestMatrix(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        &model.Pipeline{Event: model.EventPush},\n\t\tPrev:        &model.Pipeline{},\n\t\tHost:        \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\n\nmatrix:\n  GO_VERSION:\n    - 1.14\n    - 1.15\n\nsteps:\n  - name: build\n    image: golang:${GO_VERSION}\n    commands:\n      - go build\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 2)\n\n\t// Check AxisID and Environ\n\tassert.Equal(t, 1, items[0].Workflow.AxisID)\n\tassert.Equal(t, \"1.14\", items[0].Workflow.Environ[\"GO_VERSION\"])\n\n\tassert.Equal(t, 2, items[1].Workflow.AxisID)\n\tassert.Equal(t, \"1.15\", items[1].Workflow.Environ[\"GO_VERSION\"])\n}\n\nfunc TestMissingWorkflowDeps(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        &model.Pipeline{Event: model.EventPush},\n\t\tPrev:        &model.Pipeline{},\n\t\tHost:        \"\",\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{\n\t\t\t\tName: \"workflow-with-missing-deps\",\n\t\t\t\tData: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\ndepends_on:\n  - non-existing\n`),\n\t\t\t},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Empty(t, items, \"Workflows with missing dependencies should be filtered out\")\n}\n\nfunc TestInvalidYAML(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       nil,\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        &model.Pipeline{Event: model.EventPush},\n\t\tPrev:        &model.Pipeline{},\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Name: \"broken-yaml\", Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n\tinvalid yaml indentation\n`)},\n\t\t},\n\t}\n\n\t_, err := b.Build()\n\tassert.ErrorContains(t, err, \"found a tab character that violates indentation\")\n}\n\nfunc TestEnvVarPrecedence(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge: getMockForge(t),\n\t\tEnvs: map[string]string{\n\t\t\t\"CUSTOM_VAR\":     \"global-value\",\n\t\t\t\"CI_REPO_NAME\":   \"should-not-override\",\n\t\t\t\"ANOTHER_CUSTOM\": \"global-value-2\",\n\t\t},\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{Name: \"actual-repo\"},\n\t\tCurr: &model.Pipeline{\n\t\t\tEvent:   model.EventPush,\n\t\t\tMessage: \"test\",\n\t\t},\n\t\tPrev: &model.Pipeline{},\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\nsteps:\n  - name: test-env\n    image: scratch\n    environment:\n      CUSTOM_VAR: ${CUSTOM_VAR}\n      REPO_NAME: ${CI_REPO_NAME}\n      ANOTHER: ${ANOTHER_CUSTOM}\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 1)\n\n\t// Verify CI_REPO_NAME wasn't overridden by global env\n\tassert.Equal(t, \"actual-repo\", items[0].Config.Stages[0].Steps[0].Environment[\"CI_REPO_NAME\"])\n}\n\nfunc TestLabelMerging(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{Name: \"test-repo\"},\n\t\tCurr:        &model.Pipeline{Event: model.EventPush},\n\t\tPrev:        &model.Pipeline{},\n\t\tDefaultLabels: map[string]string{\n\t\t\t\"default-label\": \"default-value\",\n\t\t\t\"override-me\":   \"default\",\n\t\t},\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\n\nlabels:\n  override-me: \"custom-value\"\n  workflow-label: \"workflow-value\"\n\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t\t{Data: []byte(`\nwhen:\n  event: push\n\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 2)\n\n\tassert.Equal(t, \"custom-value\", items[0].Labels[\"override-me\"], \"Workflow label should override default\")\n\tassert.Equal(t, \"workflow-value\", items[0].Labels[\"workflow-label\"], \"Workflow-specific label should be present\")\n\tassert.Equal(t, \"default-value\", items[1].Labels[\"default-label\"], \"Default label should be present\")\n}\n\nfunc TestCompilerOptions(t *testing.T) {\n\tt.Parallel()\n\n\tb := StepBuilder{\n\t\tForge:       getMockForge(t),\n\t\tRepoTrusted: &metadata.TrustedConfiguration{},\n\t\tRepo:        &model.Repo{},\n\t\tCurr:        &model.Pipeline{Event: model.EventPush},\n\t\tPrev:        &model.Pipeline{},\n\t\tCompilerOptions: []compiler.Option{\n\t\t\tcompiler.WithEnviron(map[string]string{\n\t\t\t\t\"KEY\": \"VALUE\",\n\t\t\t}),\n\t\t},\n\t\tYamls: []*forge_types.FileMeta{\n\t\t\t{Data: []byte(`\nskip_clone: true\nwhen:\n  event: push\nsteps:\n  - name: build\n    image: scratch\n`)},\n\t\t},\n\t}\n\n\titems, err := b.Build()\n\tassert.NoError(t, err)\n\tassert.Len(t, items, 1)\n\tassert.Len(t, items[0].Config.Stages, 1, \"Should have 1 stage\")\n\tassert.Len(t, items[0].Config.Stages[0].Steps, 1, \"Should have 1 step in first stage\")\n\tassert.Equal(t, \"VALUE\", items[0].Config.Stages[0].Steps[0].Environment[\"KEY\"], \"Environment variable should be set\")\n}\n\nfunc getMockForge(t *testing.T) forge.Forge {\n\tforge := mocks.NewMockForge(t)\n\tforge.On(\"Name\").Return(\"mock\")\n\tforge.On(\"URL\").Return(\"https://codeberg.org\")\n\treturn forge\n}\n"
  },
  {
    "path": "server/pipeline/step_status.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2019 mhmxs\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc CalcStepStatus(step model.Step, state rpc.StepState) (_ *model.Step, cancelPipelineFromStep bool, _ error) {\n\tlog.Debug().Str(\"StepUUID\", step.UUID).Msgf(\"Update step %#v state %#v\", step, state)\n\n\tswitch step.State {\n\tcase model.StatusPending:\n\t\t// Handle skip before anything else — skipped steps never started,\n\t\t// so we must not set Started or transition through Running.\n\t\tif state.Skipped {\n\t\t\tstep.State = model.StatusSkipped\n\t\t\tif state.Finished != 0 {\n\t\t\t\tstep.Finished = state.Finished\n\t\t\t}\n\t\t\treturn &step, false, nil\n\t\t}\n\n\t\t// Transition from pending to running when started\n\t\tif state.Finished == 0 {\n\t\t\tstep.State = model.StatusRunning\n\t\t}\n\t\tstep.Started = state.Started\n\t\tif step.Started == 0 {\n\t\t\tstep.Started = time.Now().Unix()\n\t\t}\n\n\t\t// Handle direct transition to finished if step setup error happened\n\t\tif state.Exited || state.Error != \"\" {\n\t\t\tstep.Finished = state.Finished\n\t\t\tif step.Finished == 0 {\n\t\t\t\tstep.Finished = time.Now().Unix()\n\t\t\t}\n\t\t\tstep.ExitCode = state.ExitCode\n\t\t\tstep.Error = state.Error\n\n\t\t\tif state.ExitCode == 0 && state.Error == \"\" {\n\t\t\t\tstep.State = model.StatusSuccess\n\t\t\t} else {\n\t\t\t\tstep.State = model.StatusFailure\n\n\t\t\t\tif step.Failure == model.FailureCancel {\n\t\t\t\t\tcancelPipelineFromStep = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\tcase model.StatusRunning:\n\t\t// Already running, check if it finished\n\t\tif state.Exited || state.Error != \"\" {\n\t\t\tstep.Finished = state.Finished\n\t\t\tif step.Finished == 0 {\n\t\t\t\tstep.Finished = time.Now().Unix()\n\t\t\t}\n\t\t\tstep.ExitCode = state.ExitCode\n\t\t\tstep.Error = state.Error\n\n\t\t\tif state.ExitCode == 0 && state.Error == \"\" {\n\t\t\t\tstep.State = model.StatusSuccess\n\t\t\t} else {\n\t\t\t\tstep.State = model.StatusFailure\n\n\t\t\t\tif step.Failure == model.FailureCancel {\n\t\t\t\t\tcancelPipelineFromStep = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\tdefault:\n\t\treturn nil, false, fmt.Errorf(\"step has state %s and does not expect rpc state updates\", step.State)\n\t}\n\n\t// Handle cancellation across both cases\n\tif state.Canceled && step.State != model.StatusKilled {\n\t\tstep.State = model.StatusKilled\n\t\tif step.Finished == 0 {\n\t\t\tstep.Finished = time.Now().Unix()\n\t\t}\n\t}\n\n\treturn &step, cancelPipelineFromStep, nil\n}\n\n// UpdateStepStatus updates step status based on agent reports via RPC.\nfunc UpdateStepStatus(ctx context.Context, store store.Store, step *model.Step, state rpc.StepState) error {\n\tlog.Debug().Str(\"StepUUID\", step.UUID).Msgf(\"Update step %#v state %#v\", *step, state)\n\n\tupdatedStep, shouldCancelPipelineFromStep, err := CalcStepStatus(*step, state)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*step = *updatedStep // update step for external callers\n\n\tif shouldCancelPipelineFromStep {\n\t\tif err := cancelPipelineFromStep(ctx, store, step); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn store.StepUpdate(step)\n}\n\nfunc cancelPipelineFromStep(ctx context.Context, store store.Store, step *model.Step) error {\n\tpipeline, err := store.GetPipeline(step.PipelineID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepo, err := store.GetRepo(pipeline.RepoID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trepoUser, err := store.GetUser(repo.UserID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn Cancel(ctx, _forge, store, repo, repoUser, pipeline, &model.CancelInfo{\n\t\tCanceledByStep: step.Name,\n\t})\n}\n\nfunc UpdateStepToStatusSkipped(store store.Store, step model.Step, finished int64, status model.StatusValue) (*model.Step, error) {\n\tstep.State = status\n\tif step.Started != 0 {\n\t\tstep.State = model.StatusSuccess // for daemons that are killed\n\t\tstep.Finished = finished\n\t}\n\treturn &step, store.StepUpdate(&step)\n}\n"
  },
  {
    "path": "server/pipeline/step_status_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc mockStoreStep(t *testing.T) store.Store {\n\ts := mocks.NewMockStore(t)\n\ts.On(\"StepUpdate\", mock.Anything).Return(nil)\n\treturn s\n}\n\nfunc TestUpdateStepStatus(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Pending\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tt.Run(\"TransitionToRunning\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tt.Run(\"WithStartTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t\tstate := rpc.StepState{Started: 42, Finished: 0}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusRunning, step.State)\n\t\t\t\tassert.Equal(t, int64(42), step.Started)\n\t\t\t\tassert.Equal(t, int64(0), step.Finished)\n\t\t\t})\n\n\t\t\tt.Run(\"WithoutStartTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t\tstate := rpc.StepState{Started: 0, Finished: 0}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusRunning, step.State)\n\t\t\t\tassert.Greater(t, step.Started, int64(0))\n\t\t\t})\n\t\t})\n\n\t\tt.Run(\"DirectToSuccess\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tt.Run(\"WithFinishTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t\tstate := rpc.StepState{Started: 42, Exited: true, Finished: 100, ExitCode: 0, Error: \"\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusSuccess, step.State)\n\t\t\t\tassert.Equal(t, int64(42), step.Started)\n\t\t\t\tassert.Equal(t, int64(100), step.Finished)\n\t\t\t})\n\n\t\t\tt.Run(\"WithoutFinishTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t\tstate := rpc.StepState{Started: 42, Exited: true, Finished: 0, ExitCode: 0, Error: \"\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusSuccess, step.State)\n\t\t\t\tassert.Greater(t, step.Finished, int64(0))\n\t\t\t})\n\t\t})\n\n\t\tt.Run(\"DirectToFailure\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tt.Run(\"WithExitCode\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t\tstate := rpc.StepState{Started: 42, Exited: true, Finished: 34, ExitCode: 1, Error: \"an error\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusFailure, step.State)\n\t\t\t\tassert.Equal(t, 1, step.ExitCode)\n\t\t\t\tassert.Equal(t, \"an error\", step.Error)\n\t\t\t})\n\t\t})\n\t})\n\n\tt.Run(\"Running\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tt.Run(\"ToSuccess\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tt.Run(\"WithFinishTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\t\tstate := rpc.StepState{Exited: true, Finished: 100, ExitCode: 0, Error: \"\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusSuccess, step.State)\n\t\t\t\tassert.Equal(t, int64(100), step.Finished)\n\t\t\t})\n\n\t\t\tt.Run(\"WithoutFinishTime\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\t\tstate := rpc.StepState{Exited: true, Finished: 0, ExitCode: 0, Error: \"\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusSuccess, step.State)\n\t\t\t\tassert.Greater(t, step.Finished, int64(0))\n\t\t\t})\n\t\t})\n\n\t\tt.Run(\"ToFailure\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tt.Run(\"WithExitCode137\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\t\tstate := rpc.StepState{Exited: true, Finished: 34, ExitCode: pipeline.ExitCodeKilled, Error: \"an error\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusFailure, step.State)\n\t\t\t\tassert.Equal(t, int64(34), step.Finished)\n\t\t\t\tassert.Equal(t, pipeline.ExitCodeKilled, step.ExitCode)\n\t\t\t})\n\n\t\t\tt.Run(\"WithError\", func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\t\tstate := rpc.StepState{Exited: true, Finished: 34, ExitCode: 0, Error: \"an error\"}\n\n\t\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, model.StatusFailure, step.State)\n\t\t\t\tassert.Equal(t, \"an error\", step.Error)\n\t\t\t})\n\t\t})\n\n\t\tt.Run(\"StillRunning\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\tstate := rpc.StepState{Exited: false, Finished: 0}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, model.StatusRunning, step.State)\n\t\t\tassert.Equal(t, int64(0), step.Finished)\n\t\t})\n\t})\n\n\tt.Run(\"Canceled\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tt.Run(\"WithoutFinishTime\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\tstate := rpc.StepState{Canceled: true}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, model.StatusKilled, step.State)\n\t\t\tassert.Greater(t, step.Finished, int64(0))\n\t\t})\n\n\t\tt.Run(\"WithExitedAndFinishTime\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusRunning, Started: 42}\n\t\t\tstate := rpc.StepState{Canceled: true, Exited: true, Finished: 100, ExitCode: 1, Error: \"canceled\"}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, model.StatusKilled, step.State)\n\t\t\tassert.Equal(t, int64(100), step.Finished)\n\t\t\tassert.Equal(t, 1, step.ExitCode)\n\t\t\tassert.Equal(t, \"canceled\", step.Error)\n\t\t})\n\t})\n\n\tt.Run(\"Skipped\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\t// This mirrors exactly what the agent sends when executor.go detects\n\t\t// OnSuccess=false or OnFailure=false — only Skipped is set, everything\n\t\t// else is zero/false (no Started, no Finished, not Exited).\n\t\tt.Run(\"PendingToSkipped_AgentPayload\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\t// Exact payload from: traceStep(&backend.State{Skipped: true}, nil, step)\n\t\t\t// Started=0, Finished=0, Exited=false, Skipped=true\n\t\t\tstate := rpc.StepState{\n\t\t\t\tSkipped:  true,\n\t\t\t\tExited:   false,\n\t\t\t\tFinished: 0,\n\t\t\t\tStarted:  0,\n\t\t\t}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\t// Must be Skipped, NOT Running (the bug: Finished==0 triggers StatusRunning first)\n\t\t\tassert.Equal(t, model.StatusSkipped, step.State)\n\t\t\t// Started must NOT be set — skipped steps never ran\n\t\t\tassert.Equal(t, int64(0), step.Started)\n\t\t\t// Finished must NOT be set — skipped steps never ran\n\t\t\tassert.Equal(t, int64(0), step.Finished)\n\t\t})\n\n\t\tt.Run(\"PendingToSkipped\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\tstate := rpc.StepState{Skipped: true}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, model.StatusSkipped, step.State)\n\t\t})\n\n\t\tt.Run(\"PendingToSkippedWithFinishTime\", func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tstep := &model.Step{State: model.StatusPending}\n\t\t\tstate := rpc.StepState{Skipped: true, Exited: true, Finished: 50}\n\n\t\t\terr := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, model.StatusSkipped, step.State)\n\t\t\tassert.Equal(t, int64(50), step.Finished)\n\t\t})\n\t})\n\n\tt.Run(\"TerminalState\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusKilled, Started: 42, Finished: 64}\n\t\tstate := rpc.StepState{Exited: false}\n\n\t\terr := UpdateStepStatus(t.Context(), mocks.NewMockStore(t), step, state)\n\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"does not expect rpc state updates\")\n\t\tassert.Equal(t, model.StatusKilled, step.State)\n\t})\n}\n\nfunc TestUpdateStepToStatusSkipped(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"NotStarted\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstep, err := UpdateStepToStatusSkipped(mockStoreStep(t), model.Step{}, int64(1), model.StatusSkipped)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSkipped, step.State)\n\t\tassert.Equal(t, int64(0), step.Finished)\n\t})\n\n\tt.Run(\"AlreadyStarted\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstep, err := UpdateStepToStatusSkipped(mockStoreStep(t), model.Step{Started: 42}, int64(100), model.StatusSkipped)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSuccess, step.State)\n\t\tassert.Equal(t, int64(100), step.Finished)\n\t})\n}\n"
  },
  {
    "path": "server/pipeline/topic.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/oklog/ulid/v2\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n)\n\n// publishToTopic publishes message to UI clients.\nfunc publishToTopic(c context.Context, pipeline *model.Pipeline, repo *model.Repo) (err error) {\n\tmessage := pubsub.Message{ID: ulid.Make().String()}\n\tmessage.Data, err = json.Marshal(model.Event{\n\t\tRepo:     *repo,\n\t\tPipeline: *pipeline,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't marshal JSON: %w\", err)\n\t}\n\n\tsubTopics := make(map[string]struct{})\n\t// if repo is public, push to public topic\n\tif !repo.IsSCMPrivate {\n\t\tsubTopics[pubsub.PublicTopic] = struct{}{}\n\t}\n\t// publish to repo specific topic\n\tsubTopics[pubsub.GetRepoTopic(repo)] = struct{}{}\n\n\treturn server.Config.Services.Scheduler.Publish(c, subTopics, message)\n}\n"
  },
  {
    "path": "server/pipeline/workflow_status.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// WorkflowStatus determine workflow status based on corresponding step list.\nfunc WorkflowStatus(steps []*model.Step) model.StatusValue {\n\tstatus := model.StatusSuccess\n\n\tfor _, p := range steps {\n\t\tif p.Failure == model.FailureFail || !p.Failing() {\n\t\t\tstatus = MergeStatusValues(status, p.State)\n\t\t}\n\t}\n\n\treturn status\n}\n\nfunc UpdateWorkflowStatusToRunning(store store.Store, workflow model.Workflow, state rpc.WorkflowState) (*model.Workflow, error) {\n\tworkflow.Started = state.Started\n\tworkflow.State = model.StatusRunning\n\treturn &workflow, store.WorkflowUpdate(&workflow)\n}\n\nfunc UpdateWorkflowToStatusSkipped(store store.Store, workflow model.Workflow) (*model.Workflow, error) {\n\tworkflow.State = model.StatusSkipped\n\treturn &workflow, store.WorkflowUpdate(&workflow)\n}\n\nfunc UpdateWorkflowStatusToDone(store store.Store, workflow model.Workflow, state rpc.WorkflowState) (*model.Workflow, error) {\n\tworkflow.Finished = state.Finished\n\tworkflow.Error = state.Error\n\tif state.Started == 0 {\n\t\tworkflow.State = model.StatusSkipped\n\t} else {\n\t\tworkflow.State = WorkflowStatus(workflow.Children)\n\t}\n\tif workflow.Error != \"\" {\n\t\tworkflow.State = model.StatusFailure\n\t}\n\tif state.Canceled {\n\t\tworkflow.State = model.StatusKilled\n\t}\n\treturn &workflow, store.WorkflowUpdate(&workflow)\n}\n"
  },
  {
    "path": "server/pipeline/workflow_status_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pipeline\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestWorkflowStatus(t *testing.T) {\n\ttests := []struct {\n\t\ts []*model.Step\n\t\te model.StatusValue\n\t}{\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusFailure,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusSuccess,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusSuccess,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusFailure,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusFailure,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusPending,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusPending,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusPending,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusSuccess,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusPending,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusRunning,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusRunning,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusRunning,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusRunning,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusRunning,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusRunning,\n\t\t},\n\t\t{\n\t\t\ts: []*model.Step{\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusRunning,\n\t\t\t\t\tFailure: model.FailureFail,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tState:   model.StatusPending,\n\t\t\t\t\tFailure: model.FailureIgnore,\n\t\t\t\t},\n\t\t\t},\n\t\t\te: model.StatusRunning,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tassert.Equal(t, tt.e, WorkflowStatus(tt.s))\n\t}\n}\n\nfunc TestUpdateWorkflowStatusToRunning(t *testing.T) {\n\tt.Run(\"should update workflow to running status\", func(t *testing.T) {\n\t\tworkflow := model.Workflow{\n\t\t\tID:    1,\n\t\t\tState: model.StatusPending,\n\t\t}\n\t\tstate := rpc.WorkflowState{\n\t\t\tStarted: 1234567890,\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.MatchedBy(func(w *model.Workflow) bool {\n\t\t\treturn w.ID == 1 && w.State == model.StatusRunning && w.Started == 1234567890\n\t\t})).Return(nil)\n\n\t\tresult, err := UpdateWorkflowStatusToRunning(mockStore, workflow, state)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusRunning, result.State)\n\t\tassert.Equal(t, int64(1234567890), result.Started)\n\t\tmockStore.AssertCalled(t, \"WorkflowUpdate\", mock.Anything)\n\t})\n}\n\nfunc TestUpdateWorkflowToStatusSkipped(t *testing.T) {\n\tt.Run(\"should update workflow to skipped status\", func(t *testing.T) {\n\t\tworkflow := model.Workflow{\n\t\t\tID:    2,\n\t\t\tState: model.StatusPending,\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.MatchedBy(func(w *model.Workflow) bool {\n\t\t\treturn w.ID == 2 && w.State == model.StatusSkipped\n\t\t})).Return(nil)\n\n\t\tresult, err := UpdateWorkflowToStatusSkipped(mockStore, workflow)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSkipped, result.State)\n\t\tmockStore.AssertCalled(t, \"WorkflowUpdate\", mock.Anything)\n\t})\n}\n\nfunc TestUpdateWorkflowStatusToDone(t *testing.T) {\n\tt.Run(\"should mark as skipped when not started\", func(t *testing.T) {\n\t\tworkflow := model.Workflow{\n\t\t\tID:       3,\n\t\t\tState:    model.StatusRunning,\n\t\t\tChildren: []*model.Step{},\n\t\t}\n\t\tstate := rpc.WorkflowState{\n\t\t\tStarted:  0, // Not started\n\t\t\tFinished: 1234567900,\n\t\t\tError:    \"\",\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.MatchedBy(func(w *model.Workflow) bool {\n\t\t\treturn w.State == model.StatusSkipped && w.Finished == 1234567900\n\t\t})).Return(nil)\n\n\t\tresult, err := UpdateWorkflowStatusToDone(mockStore, workflow, state)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSkipped, result.State)\n\t\tassert.Equal(t, int64(1234567900), result.Finished)\n\t})\n\n\tt.Run(\"should mark as failure when error exists\", func(t *testing.T) {\n\t\tworkflow := model.Workflow{\n\t\t\tID:       5,\n\t\t\tState:    model.StatusRunning,\n\t\t\tChildren: []*model.Step{},\n\t\t}\n\t\tstate := rpc.WorkflowState{\n\t\t\tStarted:  1234567800,\n\t\t\tFinished: 1234567900,\n\t\t\tError:    \"some error occurred\",\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.MatchedBy(func(w *model.Workflow) bool {\n\t\t\treturn w.State == model.StatusFailure\n\t\t})).Return(nil)\n\n\t\tresult, err := UpdateWorkflowStatusToDone(mockStore, workflow, state)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusFailure, result.State)\n\t\tassert.Equal(t, \"some error occurred\", result.Error)\n\t})\n\n\tt.Run(\"should mark as success when all children are successful\", func(t *testing.T) {\n\t\tsuccessStep := &model.Step{\n\t\t\tID:    1,\n\t\t\tState: model.StatusSuccess,\n\t\t}\n\t\tworkflow := model.Workflow{\n\t\t\tID:       6,\n\t\t\tState:    model.StatusRunning,\n\t\t\tChildren: []*model.Step{successStep},\n\t\t}\n\t\tstate := rpc.WorkflowState{\n\t\t\tStarted:  1234567800,\n\t\t\tFinished: 1234567900,\n\t\t\tError:    \"\",\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.Anything).Return(nil)\n\n\t\tresult, err := UpdateWorkflowStatusToDone(mockStore, workflow, state)\n\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSuccess, result.State)\n\t\tassert.Equal(t, int64(1234567900), result.Finished)\n\t})\n}\n"
  },
  {
    "path": "server/pubsub/memory/pub.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage memory\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"sync\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n)\n\ntype publisher struct {\n\tsync.RWMutex\n\n\tsubs map[*pubsub.Receiver][]string\n}\n\n// New creates an in-memory publisher.\nfunc New() pubsub.PubSub {\n\treturn &publisher{\n\t\tsubs: make(map[*pubsub.Receiver][]string),\n\t}\n}\n\nfunc (p *publisher) Publish(_ context.Context, topics pubsub.Topics, message pubsub.Message) error {\n\tif len(topics) == 0 {\n\t\treturn fmt.Errorf(\"%w: specify at least one\", pubsub.ErrNoTopic)\n\t}\n\n\tp.RLock()\n\tdefer p.RUnlock()\n\n\tfor s, tl := range p.subs {\n\t\t// callback is from outside so just make sure it still exists\n\t\tif s == nil || *s == nil {\n\t\t\tlog.Error().Msg(\"found nil callback func in subscribers!\")\n\t\t\tcontinue\n\t\t}\n\n\t\tfor t := range topics {\n\t\t\tif slices.Contains(tl, t) {\n\t\t\t\tgo (*s)(message)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *publisher) Subscribe(c context.Context, topics pubsub.Topics, receiver pubsub.Receiver) error {\n\tif len(topics) == 0 {\n\t\treturn fmt.Errorf(\"%w: subscribe to at least one\", pubsub.ErrNoTopic)\n\t}\n\n\tvar tl []string\n\tfor k := range topics {\n\t\ttl = append(tl, k)\n\t}\n\n\tdefer func() {\n\t\tp.Lock()\n\t\tdelete(p.subs, &receiver)\n\t\tp.Unlock()\n\t}()\n\n\tp.Lock()\n\tp.subs[&receiver] = tl\n\tp.Unlock()\n\n\t<-c.Done()\n\treturn nil\n}\n"
  },
  {
    "path": "server/pubsub/memory/pub_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage memory\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n)\n\nfunc TestPubsub(t *testing.T) {\n\tvar (\n\t\twg sync.WaitGroup\n\n\t\ttestTopic = map[string]struct{}{\"test\": {}}\n\n\t\ttestMessage = pubsub.Message{\n\t\t\tData: []byte(\"test\"),\n\t\t}\n\t)\n\n\tctx, cancel := context.WithCancelCause(\n\t\tt.Context(),\n\t)\n\tbroker := New()\n\n\tassert.Error(t, broker.Subscribe(ctx, nil, func(pubsub.Message) {}))\n\tgo func() {\n\t\tassert.NoError(t, broker.Subscribe(ctx, testTopic, func(message pubsub.Message) { assert.Equal(t, testMessage, message); wg.Done() }))\n\t}()\n\tgo func() {\n\t\tassert.NoError(t, broker.Subscribe(ctx, testTopic, func(pubsub.Message) { wg.Done() }))\n\t}()\n\n\t<-time.After(500 * time.Millisecond)\n\n\twg.Add(2)\n\tgo func() {\n\t\tassert.NoError(t, broker.Publish(ctx, testTopic, testMessage))\n\t}()\n\n\twg.Wait()\n\tcancel(nil)\n}\n\nfunc TestPubsubConcurrentCancel(t *testing.T) {\n\ttestTopic := map[string]struct{}{\"test\": {}}\n\tbroker := New()\n\n\tfor range 100 {\n\t\tctx, cancel := context.WithCancelCause(t.Context())\n\t\tch := make(chan []byte) // Unbuffered to force blocking sends\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\t_ = broker.Subscribe(ctx, testTopic, func(m pubsub.Message) {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\tcase ch <- m.Data:\n\t\t\t\t}\n\t\t\t})\n\t\t}()\n\n\t\t// Start publishing many messages to increase chance of blocking send\n\t\tvar pubWg sync.WaitGroup\n\t\tfor range 100 {\n\t\t\tpubWg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer pubWg.Done()\n\t\t\t\t_ = broker.Publish(ctx, testTopic, pubsub.Message{Data: []byte(\"x\")})\n\t\t\t}()\n\t\t}\n\n\t\t// Cancel while publishes are in flight to race with pending sends\n\t\tcancel(nil)\n\t\tpubWg.Wait()\n\t\twg.Wait()\n\t}\n}\n"
  },
  {
    "path": "server/pubsub/pubsub.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pubsub\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// PubSub provider interface, used to signal pipeline state changes to WebUI.\ntype PubSub interface {\n\t// Publish pushes a state change to all subscribers,\n\t// that have at least subscribed to one of the topics we publish it under.\n\tPublish(context.Context, Topics, Message) error\n\t// Subscribe gets all state changes that match the same topic.\n\t// If multiple topics are subscribed, and a message also match multiple,\n\t// the implementation takes care of deduplication.\n\tSubscribe(context.Context, Topics, Receiver) error\n}\n\n// Message defines a published message.\ntype Message struct {\n\t// ID identifies this message.\n\tID string `json:\"id,omitempty\"`\n\n\t// Data is the actual data in the entry.\n\tData []byte `json:\"data\"`\n}\n\n// Receiver receives published messages.\ntype Receiver func(Message)\n\n// Topics are key-value pairs, messages are filtered upon\n// the the key is the base-key and the value to the sub-key.\ntype Topics map[string]struct{}\n\nfunc GetRepoTopic(r *model.Repo) string {\n\treturn fmt.Sprintf(\"repo.id.%d\", r.ID)\n}\n\nconst PublicTopic = \"public\"\n\nvar ErrNoTopic = errors.New(\"no topic specified\")\n"
  },
  {
    "path": "server/pubsub/pubsub_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage pubsub_test\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n)\n\nfunc TestPubSub(t *testing.T) {\n\t// for each pubsub adapter (currently we have only one)\n\tt.Run(\"in_memory\", func(t *testing.T) {\n\t\ttestPubSub(t, memory.New())\n\t})\n}\n\nfunc testPubSub(t *testing.T, adapter pubsub.PubSub) {\n\tassert.NoError(t,\n\t\tadapter.Publish(t.Context(), pubsub.Topics{\"a\": {}}, pubsub.Message{ID: \"1\", Data: []byte(`dummy`)}),\n\t\t\"expect no issue publish to a pubsub with no subscribers\",\n\t)\n\n\tt.Run(\"test deduplication asumptions\", func(t *testing.T) {\n\t\ttreeTopicCloser, treeTopicGetMSGs := genTestSub(t, adapter, pubsub.Topics{\"tree\": {}})\n\t\tt.Cleanup(treeTopicCloser)\n\t\tcloser, getMSGs := genTestSub(t, adapter, pubsub.Topics{\"apples\": {}, \"tree\": {}, \"raspberry\": {}})\n\t\tt.Cleanup(closer)\n\t\tassert.Len(t, getMSGs(), 0)\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\t\tassert.NoError(t, adapter.Publish(t.Context(), pubsub.Topics{\"tree\": {}, \"raspberry\": {}, \"tails\": {}}, pubsub.Message{ID: \"2\"}))\n\t\tassert.NoError(t, adapter.Publish(t.Context(), pubsub.Topics{\"apples\": {}, \"raspberry\": {}, \"tails\": {}}, pubsub.Message{ID: \"3\"}))\n\t\ttime.Sleep(100 * time.Millisecond)\n\n\t\tif assert.Len(t, getMSGs(), 2) {\n\t\t\tassert.ElementsMatch(t, []string{\"2\", \"3\"}, messagesToIDs(getMSGs()))\n\t\t}\n\n\t\tassert.EqualValues(t, \"2\", treeTopicGetMSGs()[0].ID)\n\t})\n\n\tt.Run(\"test adapters calc for strange input\", func(t *testing.T) {\n\t\tt.Run(\"empty topic\", func(t *testing.T) {\n\t\t\tassert.Error(t, adapter.Subscribe(t.Context(), nil, func(pubsub.Message) {}))\n\t\t\tassert.Error(t, adapter.Subscribe(t.Context(), pubsub.Topics{}, func(pubsub.Message) {}))\n\n\t\t\tassert.Error(t, adapter.Publish(t.Context(), nil, pubsub.Message{}))\n\t\t\tassert.Error(t, adapter.Publish(t.Context(), pubsub.Topics{}, pubsub.Message{}))\n\t\t})\n\t})\n}\n\nfunc genTestSub(t *testing.T, adapter pubsub.PubSub, topics pubsub.Topics) (close func(), getMSGs func() []pubsub.Message) {\n\tctx, closer := context.WithCancelCause(t.Context())\n\tvar mu sync.Mutex\n\tvar messages []pubsub.Message\n\n\tgo func() {\n\t\terr := adapter.Subscribe(ctx, topics, func(m pubsub.Message) {\n\t\t\tmu.Lock()\n\t\t\tmessages = append(messages, m)\n\t\t\tmu.Unlock()\n\t\t})\n\t\tassert.NoError(t, err)\n\t}()\n\n\treturn func() { closer(nil) }, func() []pubsub.Message {\n\t\tmu.Lock()\n\t\tdefer mu.Unlock()\n\t\tcp := make([]pubsub.Message, len(messages))\n\t\tcopy(cp, messages)\n\t\treturn cp\n\t}\n}\n\nfunc messagesToIDs(msgs []pubsub.Message) (ids []string) {\n\tfor i := range msgs {\n\t\tids = append(ids, msgs[i].ID)\n\t}\n\treturn ids\n}\n"
  },
  {
    "path": "server/queue/fifo.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage queue\n\nimport (\n\t\"container/list\"\n\t\"context\"\n\t\"errors\"\n\t\"slices\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\ntype entry struct {\n\titem     *model.Task\n\tdone     chan bool\n\terror    error\n\tdeadline time.Time\n}\n\ntype worker struct {\n\tagentID int64\n\tfilter  FilterFn\n\tchannel chan *model.Task\n\tstop    context.CancelCauseFunc\n}\n\ntype fifo struct {\n\tsync.Mutex\n\n\tctx           context.Context\n\tworkers       map[*worker]struct{}\n\trunning       map[string]*entry\n\tpending       *list.List\n\twaitingOnDeps *list.List\n\textension     time.Duration\n\tpaused        bool\n}\n\n// processTimeInterval is the time till the queue rearranges things,\n// as the agent pull in 10 milliseconds we should also give them work asap.\nconst processTimeInterval = 100 * time.Millisecond\n\n// NewMemoryQueue returns a new fifo queue.\nfunc NewMemoryQueue(ctx context.Context) Queue {\n\tq := &fifo{\n\t\tctx:           ctx,\n\t\tworkers:       map[*worker]struct{}{},\n\t\trunning:       map[string]*entry{},\n\t\tpending:       list.New(),\n\t\twaitingOnDeps: list.New(),\n\t\textension:     constant.TaskTimeout,\n\t\tpaused:        false,\n\t}\n\tgo q.process()\n\treturn q\n}\n\n// PushAtOnce pushes multiple tasks to the tail of this queue.\nfunc (q *fifo) PushAtOnce(_ context.Context, tasks []*model.Task) error {\n\tq.Lock()\n\tfor _, task := range tasks {\n\t\tq.pending.PushBack(task)\n\t}\n\tq.Unlock()\n\treturn nil\n}\n\n// Poll retrieves and removes a task head of this queue.\nfunc (q *fifo) Poll(c context.Context, agentID int64, filter FilterFn) (*model.Task, error) {\n\tq.Lock()\n\tctx, stop := context.WithCancelCause(c)\n\n\tw := &worker{\n\t\tagentID: agentID,\n\t\tchannel: make(chan *model.Task, 1),\n\t\tfilter:  filter,\n\t\tstop:    stop,\n\t}\n\tq.workers[w] = struct{}{}\n\tq.Unlock()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tq.Lock()\n\t\t\tdelete(q.workers, w)\n\t\t\tq.Unlock()\n\t\t\treturn nil, ctx.Err()\n\t\tcase t := <-w.channel:\n\t\t\treturn t, nil\n\t\t}\n\t}\n}\n\n// Done signals the task is complete.\nfunc (q *fifo) Done(_ context.Context, id string, exitStatus model.StatusValue) error {\n\treturn q.finished([]string{id}, exitStatus, nil)\n}\n\n// Error signals the task is done with an error.\nfunc (q *fifo) Error(_ context.Context, id string, err error) error {\n\treturn q.finished([]string{id}, model.StatusFailure, err)\n}\n\n// ErrorAtOnce signals multiple tasks are done and complete with an error.\n// If still pending they will just get removed from the queue.\nfunc (q *fifo) ErrorAtOnce(_ context.Context, ids []string, err error) error {\n\tif errors.Is(err, ErrCancel) {\n\t\treturn q.finished(ids, model.StatusKilled, err)\n\t}\n\treturn q.finished(ids, model.StatusFailure, err)\n}\n\n// locks the queue itself!\nfunc (q *fifo) finished(ids []string, exitStatus model.StatusValue, err error) error {\n\tq.Lock()\n\tdefer q.Unlock()\n\n\t// it's an external error so we wrap it\n\terr = NewErrExternal(err)\n\n\tvar errs []error\n\t// we first process the tasks itself\n\tfor _, id := range ids {\n\t\tif taskEntry, ok := q.running[id]; ok {\n\t\t\ttaskEntry.error = err\n\t\t\tclose(taskEntry.done)\n\t\t\tdelete(q.running, id)\n\t\t} else {\n\t\t\terrs = append(errs, q.removeFromPendingAndWaiting(id))\n\t\t}\n\t}\n\n\t// next we aim for there dependencies\n\t// we do this because in our ids list there could be tasks and its dependencies\n\t// so not to mess things up\n\tfor _, id := range ids {\n\t\tq.updateDepStatusInQueue(id, exitStatus)\n\t}\n\n\treturn errors.Join(errs...)\n}\n\n// Wait waits until the item is done executing.\n// Also signals via error ErrCancel if workflow got canceled.\nfunc (q *fifo) Wait(ctx context.Context, taskID string) error {\n\tq.Lock()\n\tstate := q.running[taskID]\n\tq.Unlock()\n\tif state != nil {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\tcase <-state.done:\n\t\t\t// check if we have a wrapped cancel error and unwrap it\n\t\t\tif errors.Is(state.error, ErrCancel) {\n\t\t\t\treturn ErrCancel\n\t\t\t}\n\t\t\t// or return queue errors and no workflow errors\n\t\t\tif !errors.Is(state.error, new(ErrExternal)) {\n\t\t\t\treturn state.error\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// Extend extends the task execution deadline.\nfunc (q *fifo) Extend(_ context.Context, agentID int64, taskID string) error {\n\tq.Lock()\n\tdefer q.Unlock()\n\n\tstate, ok := q.running[taskID]\n\tif ok {\n\t\tif state.item.AgentID != agentID {\n\t\t\treturn ErrAgentMissMatch\n\t\t}\n\n\t\tstate.deadline = time.Now().Add(q.extension)\n\t\treturn nil\n\t}\n\treturn ErrNotFound\n}\n\n// Info returns internal queue information.\nfunc (q *fifo) Info(_ context.Context) InfoT {\n\tq.Lock()\n\tstats := InfoT{}\n\tstats.Stats.Workers = len(q.workers)\n\tstats.Stats.Pending = q.pending.Len()\n\tstats.Stats.WaitingOnDeps = q.waitingOnDeps.Len()\n\tstats.Stats.Running = len(q.running)\n\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tstats.Pending = append(stats.Pending, task)\n\t}\n\tfor element := q.waitingOnDeps.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tstats.WaitingOnDeps = append(stats.WaitingOnDeps, task)\n\t}\n\tfor _, entry := range q.running {\n\t\tstats.Running = append(stats.Running, entry.item)\n\t}\n\tstats.Paused = q.paused\n\n\tq.Unlock()\n\treturn stats\n}\n\n// Pause stops the queue from handing out new work items in Poll.\nfunc (q *fifo) Pause() {\n\tq.Lock()\n\tq.paused = true\n\tq.Unlock()\n}\n\n// Resume starts the queue again.\nfunc (q *fifo) Resume() {\n\tq.Lock()\n\tq.paused = false\n\tq.Unlock()\n}\n\n// KickAgentWorkers kicks all workers for a given agent.\nfunc (q *fifo) KickAgentWorkers(agentID int64) {\n\tq.Lock()\n\tdefer q.Unlock()\n\n\tfor worker := range q.workers {\n\t\tif worker.agentID == agentID {\n\t\t\tworker.stop(ErrWorkerKicked)\n\t\t\tdelete(q.workers, worker)\n\t\t}\n\t}\n}\n\n// helper function that loops through the queue and attempts to\n// match the item to a single subscriber until context got cancel.\nfunc (q *fifo) process() {\n\tfor {\n\t\tselect {\n\t\tcase <-time.After(processTimeInterval):\n\t\tcase <-q.ctx.Done():\n\t\t\treturn\n\t\t}\n\n\t\tq.Lock()\n\t\tif q.paused {\n\t\t\tq.Unlock()\n\t\t\tcontinue\n\t\t}\n\n\t\tq.resubmitExpiredPipelines()\n\t\tq.filterWaiting()\n\t\tfor pending, worker := q.assignToWorker(); pending != nil && worker != nil; pending, worker = q.assignToWorker() {\n\t\t\ttask, _ := pending.Value.(*model.Task)\n\t\t\ttask.AgentID = worker.agentID\n\t\t\tdelete(q.workers, worker)\n\t\t\tq.pending.Remove(pending)\n\t\t\tq.running[task.ID] = &entry{\n\t\t\t\titem:     task,\n\t\t\t\tdone:     make(chan bool),\n\t\t\t\tdeadline: time.Now().Add(q.extension),\n\t\t\t}\n\t\t\tworker.channel <- task\n\t\t}\n\t\tq.Unlock()\n\t}\n}\n\nfunc (q *fifo) filterWaiting() {\n\t// resubmits all waiting tasks to pending, deps may have cleared\n\tfor element := q.waitingOnDeps.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tq.pending.PushBack(task)\n\t}\n\n\t// rebuild waitingDeps\n\tq.waitingOnDeps = list.New()\n\tvar filtered []*list.Element\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tif q.depsInQueue(task) {\n\t\t\tlog.Debug().Msgf(\"queue: waiting due to unmet dependencies %v\", task.ID)\n\t\t\tq.waitingOnDeps.PushBack(task)\n\t\t\tfiltered = append(filtered, element)\n\t\t}\n\t}\n\n\t// filter waiting tasks\n\tfor _, f := range filtered {\n\t\tq.pending.Remove(f)\n\t}\n}\n\nfunc (q *fifo) assignToWorker() (*list.Element, *worker) {\n\tvar bestWorker *worker\n\tvar bestScore int\n\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tlog.Debug().Msgf(\"queue: trying to assign task: %v with deps %v\", task.ID, task.Dependencies)\n\n\t\tfor worker := range q.workers {\n\t\t\tmatched, score := worker.filter(task)\n\t\t\tif matched && score > bestScore {\n\t\t\t\tbestWorker = worker\n\t\t\t\tbestScore = score\n\t\t\t}\n\t\t}\n\t\tif bestWorker != nil {\n\t\t\tlog.Debug().Msgf(\"queue: assigned task: %v with deps %v to worker with score %d\", task.ID, task.Dependencies, bestScore)\n\t\t\treturn element, bestWorker\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (q *fifo) resubmitExpiredPipelines() {\n\tfor taskID, taskState := range q.running {\n\t\tif time.Now().After(taskState.deadline) {\n\t\t\tlog.Info().Msgf(\"queue: resubmitting expired task %s\", taskID)\n\t\t\ttaskState.error = ErrTaskExpired\n\t\t\tq.pending.PushFront(taskState.item)\n\t\t\tdelete(q.running, taskID)\n\t\t\tclose(taskState.done)\n\t\t}\n\t}\n}\n\nfunc (q *fifo) depsInQueue(task *model.Task) bool {\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\tpossibleDep, ok := element.Value.(*model.Task)\n\t\tlog.Debug().Msgf(\"queue: pending right now: %v\", possibleDep.ID)\n\t\tfor _, dep := range task.Dependencies {\n\t\t\tif ok && possibleDep.ID == dep {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\tfor possibleDepID := range q.running {\n\t\tlog.Debug().Msgf(\"queue: running right now: %v\", possibleDepID)\n\t\tif slices.Contains(task.Dependencies, possibleDepID) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// expects the q to be currently owned e.g. locked by caller!\nfunc (q *fifo) updateDepStatusInQueue(taskID string, status model.StatusValue) {\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\tpending, _ := element.Value.(*model.Task)\n\t\tfor _, dep := range pending.Dependencies {\n\t\t\tif taskID == dep {\n\t\t\t\tpending.DepStatus[dep] = status\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, running := range q.running {\n\t\tfor _, dep := range running.item.Dependencies {\n\t\t\tif taskID == dep {\n\t\t\t\trunning.item.DepStatus[dep] = status\n\t\t\t}\n\t\t}\n\t}\n\n\tfor element := q.waitingOnDeps.Front(); element != nil; element = element.Next() {\n\t\twaiting, _ := element.Value.(*model.Task)\n\t\tfor _, dep := range waiting.Dependencies {\n\t\t\tif taskID == dep {\n\t\t\t\twaiting.DepStatus[dep] = status\n\t\t\t}\n\t\t}\n\t}\n}\n\n// expects the q to be currently owned e.g. locked by caller!\nfunc (q *fifo) removeFromPendingAndWaiting(taskID string) error {\n\tlog.Debug().Msgf(\"queue: trying to remove %s\", taskID)\n\n\t// we assume pending first\n\tfor element := q.pending.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tif task.ID == taskID {\n\t\t\tlog.Debug().Msgf(\"queue: %s is removed from pending\", taskID)\n\t\t\t_ = q.pending.Remove(element)\n\t\t\treturn nil\n\t\t}\n\t}\n\n\t// well looks like it's waiting\n\tfor element := q.waitingOnDeps.Front(); element != nil; element = element.Next() {\n\t\ttask, _ := element.Value.(*model.Task)\n\t\tif task.ID == taskID {\n\t\t\tlog.Debug().Msgf(\"queue: %s is removed from waitingOnDeps\", taskID)\n\t\t\t_ = q.waitingOnDeps.Remove(element)\n\t\t\treturn nil\n\t\t}\n\t}\n\n\t// well it could not be found\n\treturn ErrNotFound\n}\n"
  },
  {
    "path": "server/queue/fifo_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage queue\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar (\n\tfilterFnTrue = func(*model.Task) (bool, int) { return true, 1 }\n\tgenDummyTask = func() *model.Task {\n\t\treturn &model.Task{\n\t\t\tID:   \"1\",\n\t\t\tData: []byte(\"{}\"),\n\t\t}\n\t}\n\twaitForProcess = func() { time.Sleep(processTimeInterval + 50*time.Millisecond) }\n)\n\nfunc setupTestQueue(t *testing.T) (context.Context, context.CancelCauseFunc, *fifo) {\n\tctx, cancel := context.WithCancelCause(t.Context())\n\tt.Cleanup(func() { cancel(nil) })\n\n\tq, _ := NewMemoryQueue(ctx).(*fifo)\n\tif q == nil {\n\t\tt.Fatal(\"Failed to create queue\")\n\t}\n\n\treturn ctx, cancel, q\n}\n\nfunc TestFifoBasicOperations(t *testing.T) {\n\tctx, cancel, q := setupTestQueue(t)\n\tdefer cancel(nil)\n\n\tt.Run(\"push poll done lifecycle\", func(t *testing.T) {\n\t\tdummyTask := genDummyTask()\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 1)\n\n\t\tgot, err := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, dummyTask, got)\n\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 0)\n\t\tassert.Len(t, info.Running, 1)\n\n\t\t// Edge case: verify task can't be polled again while running\n\t\tpollCtx, pollCancel := context.WithTimeout(ctx, 100*time.Millisecond)\n\t\t_, err = q.Poll(pollCtx, 2, filterFnTrue)\n\t\tpollCancel()\n\t\tassert.Error(t, err) // Should timeout/cancel, not return the same task\n\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess))\n\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Running, 0)\n\n\t\t// Edge case: Done on already completed task should handle gracefully\n\t\terr = q.Done(ctx, got.ID, model.StatusSuccess)\n\t\t// Document current behavior - should either error or be idempotent\n\t\tif err != nil {\n\t\t\tassert.Error(t, err)\n\t\t}\n\t})\n\n\tt.Run(\"error handling\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"task-error-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1}))\n\n\t\twaitForProcess()\n\t\tgot, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\tassert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf(\"test error\")))\n\t\twaitForProcess()\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Running, 0)\n\n\t\tassert.Error(t, q.Error(ctx, \"totally-fake-id\", fmt.Errorf(\"test error\")))\n\n\t\t// Edge case: Error on task that's already errored\n\t\terr := q.Error(ctx, got.ID, fmt.Errorf(\"double error\"))\n\t\t// Should either error or be idempotent\n\t\tif err != nil {\n\t\t\tassert.Error(t, err)\n\t\t}\n\t})\n\n\tt.Run(\"external error filtered by Wait\", func(t *testing.T) {\n\t\t// Test that external errors (from Error/ErrorAtOnce) are wrapped as ErrExternal\n\t\t// and filtered out by Wait(), while internal errors like context cancellation\n\t\t// are passed through\n\n\t\t// Test 1: External error is filtered by Wait\n\t\ttask1 := &model.Task{ID: \"wait-external-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1}))\n\t\twaitForProcess()\n\n\t\tgot1, err := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\n\t\t// Start waiting on the task\n\t\twaitDone := make(chan error, 1)\n\t\tgo func() {\n\t\t\twaitDone <- q.Wait(ctx, got1.ID)\n\t\t}()\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\n\t\t// Report an external error (agent reported error)\n\t\texternalErr := fmt.Errorf(\"agent reported error\")\n\t\tassert.NoError(t, q.Error(ctx, got1.ID, externalErr))\n\n\t\t// Wait should return nil (external error filtered out)\n\t\tselect {\n\t\tcase err := <-waitDone:\n\t\t\tassert.NoError(t, err, \"Wait should filter ErrExternal and return nil\")\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Wait should have returned\")\n\t\t}\n\n\t\t// Test 2: Internal error (context cancellation) passes through Wait\n\t\ttask2 := &model.Task{ID: \"wait-internal-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2}))\n\t\twaitForProcess()\n\n\t\tgot2, err := q.Poll(ctx, 2, filterFnTrue)\n\t\tassert.NoError(t, err)\n\n\t\twaitCtx, waitCancel := context.WithCancelCause(ctx)\n\t\twaitDone2 := make(chan error, 1)\n\t\tgo func() {\n\t\t\twaitDone2 <- q.Wait(waitCtx, got2.ID)\n\t\t}()\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\t\twaitCancel(nil)\n\n\t\t// Context cancellation should cause Wait to return (internal error handling)\n\t\tselect {\n\t\tcase err := <-waitDone2:\n\t\t\t// Wait returns nil when context is canceled (normal behavior)\n\t\t\tassert.NoError(t, err, \"Wait should return nil when context is canceled\")\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Wait should return when context is canceled\")\n\t\t}\n\n\t\t// Clean up\n\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\t// Test 3: Multiple waiters all get nil when external error occurs\n\t\ttask3 := &model.Task{ID: \"wait-multi-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task3}))\n\t\twaitForProcess()\n\n\t\tgot3, err := q.Poll(ctx, 3, filterFnTrue)\n\t\tassert.NoError(t, err)\n\n\t\t// Start multiple waiters\n\t\tnumWaiters := 3\n\t\twaitResults := make(chan error, numWaiters)\n\t\tfor i := 0; i < numWaiters; i++ {\n\t\t\tgo func() {\n\t\t\t\twaitResults <- q.Wait(ctx, got3.ID)\n\t\t\t}()\n\t\t}\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\n\t\t// Report an external error\n\t\tbatchErr := fmt.Errorf(\"external batch failure\")\n\t\tassert.NoError(t, q.ErrorAtOnce(ctx, []string{got3.ID}, batchErr))\n\n\t\t// All waiters should return nil (external error filtered)\n\t\tfor i := 0; i < numWaiters; i++ {\n\t\t\tselect {\n\t\t\tcase err := <-waitResults:\n\t\t\t\tassert.NoError(t, err, \"All waiters should get nil when ErrExternal is filtered\")\n\t\t\tcase <-time.After(time.Second):\n\t\t\t\tt.Fatalf(\"Waiter %d didn't return in time\", i)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"error at once\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"batch-1\"}\n\t\ttask2 := &model.Task{ID: \"batch-2\"}\n\t\ttask3 := &model.Task{ID: \"batch-3\"}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2, task3}))\n\t\twaitForProcess()\n\n\t\tgot1, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tgot2, _ := q.Poll(ctx, 2, filterFnTrue)\n\n\t\tassert.NoError(t, q.ErrorAtOnce(ctx, []string{got1.ID, got2.ID}, fmt.Errorf(\"batch error\")))\n\t\twaitForProcess()\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Running, 0)\n\t\tassert.Len(t, info.Pending, 1)\n\n\t\tgot3, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\ttask4 := &model.Task{ID: \"batch-4\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4}))\n\t\twaitForProcess()\n\t\tgot4, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\terr := q.ErrorAtOnce(ctx, []string{got4.ID, \"fake-1\", \"fake-2\"}, fmt.Errorf(\"test\"))\n\t\tassert.Error(t, err)\n\t\tassert.ErrorIs(t, err, ErrNotFound)\n\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Running, 0)\n\n\t\t// Edge case: ErrorAtOnce with empty slice\n\t\terr = q.ErrorAtOnce(ctx, []string{}, fmt.Errorf(\"no tasks\"))\n\t\tassert.NoError(t, err)\n\t\t// Should handle gracefully, potentially no-op\n\n\t\t// Edge case: ErrorAtOnce with nil error\n\t\ttask5 := &model.Task{ID: \"batch-5\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task5}))\n\t\twaitForProcess()\n\t\tgot5, _ := q.Poll(ctx, 3, filterFnTrue)\n\t\terr = q.ErrorAtOnce(ctx, []string{got5.ID}, nil)\n\t\tassert.NoError(t, err)\n\t\t// Should handle nil error gracefully\n\t\twaitForProcess()\n\t})\n\n\tt.Run(\"error at once with waiting deps\", func(t *testing.T) {\n\t\ttask5 := &model.Task{ID: \"deps-cancel-5\"}\n\t\ttask6 := &model.Task{\n\t\t\tID:           \"deps-cancel-6\",\n\t\t\tDependencies: []string{\"deps-cancel-5\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task5, task6}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 1, info.Stats.WaitingOnDeps)\n\n\t\tassert.NoError(t, q.ErrorAtOnce(ctx, []string{\"deps-cancel-5\", \"deps-cancel-6\"}, fmt.Errorf(\"canceled\")))\n\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Equal(t, 0, info.Stats.WaitingOnDeps)\n\t\tassert.Len(t, info.Pending, 0)\n\n\t\t// Edge case: verify both tasks are actually gone, not stuck somewhere\n\t\tassert.Len(t, info.Running, 0)\n\t\tassert.Len(t, info.WaitingOnDeps, 0)\n\t})\n\n\tt.Run(\"error at once cancellation\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"cancel-prop-1\"}\n\t\ttask2 := &model.Task{\n\t\t\tID:           \"cancel-prop-2\",\n\t\t\tDependencies: []string{\"cancel-prop-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t\tRunOn:        []string{\"success\", \"failure\"},\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2}))\n\t\twaitForProcess()\n\t\tgot1, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\tassert.NoError(t, q.ErrorAtOnce(ctx, []string{got1.ID}, ErrCancel))\n\n\t\twaitForProcess()\n\t\twaitForProcess()\n\n\t\tgot2, _ := q.Poll(ctx, 2, filterFnTrue)\n\t\tassert.Equal(t, model.StatusKilled, got2.DepStatus[\"cancel-prop-1\"])\n\n\t\t// Edge case: verify ErrCancel results in StatusKilled not StatusFailure\n\t\tassert.NotEqual(t, model.StatusFailure, got2.DepStatus[\"cancel-prop-1\"])\n\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t})\n\n\tt.Run(\"pause resume\", func(t *testing.T) {\n\t\tdummyTask := &model.Task{ID: \"pause-1\"}\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\t_, _ = q.Poll(ctx, 99, filterFnTrue)\n\t\t\twg.Done()\n\t\t}()\n\n\t\tq.Pause()\n\t\tt0 := time.Now()\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask}))\n\t\twaitForProcess()\n\n\t\t// Edge case: verify queue is actually paused\n\t\tinfo := q.Info(ctx)\n\t\tassert.True(t, info.Paused)\n\t\tassert.Len(t, info.Pending, 1)\n\t\tassert.Len(t, info.Running, 0)\n\n\t\tq.Resume()\n\n\t\twg.Wait()\n\t\tassert.Greater(t, time.Since(t0), 20*time.Millisecond)\n\n\t\t// Edge case: verify queue is unpaused\n\t\tinfo = q.Info(ctx)\n\t\tassert.False(t, info.Paused)\n\n\t\t// Edge case: multiple pause/resume cycles\n\t\ttask2 := &model.Task{ID: \"pause-2\"}\n\t\tq.Pause()\n\t\tq.Pause() // Double pause\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2}))\n\t\twaitForProcess()\n\t\tq.Resume()\n\t\tq.Resume() // Double resume\n\t\twaitForProcess()\n\t\tgot, _ := q.Poll(ctx, 99, filterFnTrue)\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t})\n}\n\nfunc TestFifoDependencies(t *testing.T) {\n\tctx, cancel, q := setupTestQueue(t)\n\tdefer cancel(nil)\n\n\tt.Run(\"basic dependency handling\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"dep-basic-1\"}\n\t\ttask2 := &model.Task{\n\t\t\tID:           \"dep-basic-2\",\n\t\t\tDependencies: []string{\"dep-basic-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\ttask3 := &model.Task{\n\t\t\tID:           \"dep-basic-3\",\n\t\t\tDependencies: []string{\"dep-basic-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t\tRunOn:        []string{\"success\", \"failure\"},\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 2, info.Stats.WaitingOnDeps)\n\n\t\tgot, err := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, task1, got)\n\t\tassert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf(\"exit code 1\")))\n\n\t\twaitForProcess()\n\t\tgot, err = q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, task2, got)\n\t\tassert.False(t, got.ShouldRun())\n\t\tassert.Equal(t, model.StatusFailure, got.DepStatus[\"dep-basic-1\"])\n\n\t\twaitForProcess()\n\t\tgot, err = q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, task3, got)\n\t\tassert.True(t, got.ShouldRun())\n\t\tassert.Equal(t, model.StatusFailure, got.DepStatus[\"dep-basic-1\"])\n\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Equal(t, 0, info.Stats.WaitingOnDeps)\n\n\t\t// Edge case: verify DepStatus is correctly set before polling\n\t\tassert.NotEmpty(t, task2.DepStatus)\n\t\tassert.NotEmpty(t, task3.DepStatus)\n\t})\n\n\tt.Run(\"multiple dependencies\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"multi-dep-1\"}\n\t\ttask2 := &model.Task{ID: \"multi-dep-2\"}\n\t\ttask3 := &model.Task{\n\t\t\tID:           \"multi-dep-3\",\n\t\t\tDependencies: []string{\"multi-dep-1\", \"multi-dep-2\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1}))\n\t\twaitForProcess()\n\n\t\tgot1, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tgot2, _ := q.Poll(ctx, 2, filterFnTrue)\n\n\t\tgotIDs := map[string]bool{got1.ID: true, got2.ID: true}\n\t\tassert.True(t, gotIDs[\"multi-dep-1\"] && gotIDs[\"multi-dep-2\"])\n\n\t\tif got1.ID == \"multi-dep-1\" {\n\t\t\tassert.NoError(t, q.Done(ctx, got1.ID, model.StatusSuccess))\n\t\t\tassert.NoError(t, q.Error(ctx, got2.ID, fmt.Errorf(\"failed\")))\n\t\t} else {\n\t\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\t\tassert.NoError(t, q.Error(ctx, got1.ID, fmt.Errorf(\"failed\")))\n\t\t}\n\n\t\twaitForProcess()\n\t\tgot3, err := q.Poll(ctx, 3, filterFnTrue)\n\t\tassert.NoError(t, err)\n\n\t\tassert.Contains(t, got3.DepStatus, \"multi-dep-1\")\n\t\tassert.Contains(t, got3.DepStatus, \"multi-dep-2\")\n\t\tassert.True(t,\n\t\t\t(got3.DepStatus[\"multi-dep-1\"] == model.StatusSuccess && got3.DepStatus[\"multi-dep-2\"] == model.StatusFailure) ||\n\t\t\t\t(got3.DepStatus[\"multi-dep-1\"] == model.StatusFailure && got3.DepStatus[\"multi-dep-2\"] == model.StatusSuccess))\n\t\tassert.False(t, got3.ShouldRun())\n\n\t\t// Edge case: verify both deps are tracked\n\t\tassert.Len(t, got3.DepStatus, 2)\n\t\tassert.NoError(t, q.Done(ctx, got3.ID, model.StatusSkipped))\n\t\twaitForProcess()\n\t})\n\n\tt.Run(\"transitive dependencies\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"trans-1\"}\n\t\ttask2 := &model.Task{\n\t\t\tID:           \"trans-2\",\n\t\t\tDependencies: []string{\"trans-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\ttask3 := &model.Task{\n\t\t\tID:           \"trans-3\",\n\t\t\tDependencies: []string{\"trans-2\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1}))\n\t\twaitForProcess()\n\n\t\tgot, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf(\"exit code 1\")))\n\n\t\twaitForProcess()\n\t\tgot, _ = q.Poll(ctx, 2, filterFnTrue)\n\t\tassert.False(t, got.ShouldRun())\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSkipped))\n\n\t\twaitForProcess()\n\t\tgot, _ = q.Poll(ctx, 3, filterFnTrue)\n\t\tassert.Equal(t, model.StatusSkipped, got.DepStatus[\"trans-2\"])\n\t\tassert.False(t, got.ShouldRun())\n\n\t\t// Edge case: verify transitive failure propagates correctly\n\t\t// task3 should see trans-2 as skipped, not trans-1's status\n\t\tassert.NotContains(t, got.DepStatus, \"trans-1\")\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSkipped))\n\t\twaitForProcess()\n\t})\n\n\tt.Run(\"dependency status propagation\", func(t *testing.T) {\n\t\ttask1 := &model.Task{ID: \"prop-1\"}\n\t\ttask2 := &model.Task{\n\t\t\tID:           \"prop-2\",\n\t\t\tDependencies: []string{\"prop-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\ttask3 := &model.Task{\n\t\t\tID:           \"prop-3\",\n\t\t\tDependencies: []string{\"prop-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t\tRunOn:        []string{\"success\", \"failure\"},\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2, task3}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 2, info.Stats.WaitingOnDeps)\n\n\t\tgot1, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, q.Done(ctx, got1.ID, model.StatusSuccess))\n\n\t\twaitForProcess()\n\n\t\tgot2, _ := q.Poll(ctx, 2, filterFnTrue)\n\t\tgot3, _ := q.Poll(ctx, 3, filterFnTrue)\n\n\t\tassert.Equal(t, model.StatusSuccess, got2.DepStatus[\"prop-1\"])\n\t\tassert.Equal(t, model.StatusSuccess, got3.DepStatus[\"prop-1\"])\n\n\t\t// Edge case: verify both tasks can be polled concurrently\n\t\tassert.NotEqual(t, got2.ID, got3.ID)\n\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\tassert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\ttask4 := &model.Task{ID: \"prop-4\"}\n\t\ttask5 := &model.Task{\n\t\t\tID:           \"prop-5\",\n\t\t\tDependencies: []string{\"prop-4\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\ttask6 := &model.Task{\n\t\t\tID:           \"prop-6\",\n\t\t\tDependencies: []string{\"prop-4\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t\tRunOn:        []string{\"success\", \"failure\"},\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4, task5, task6}))\n\t\twaitForProcess()\n\n\t\tgot4, _ := q.Poll(ctx, 4, filterFnTrue)\n\t\tassert.NoError(t, q.Error(ctx, got4.ID, fmt.Errorf(\"failed\")))\n\n\t\twaitForProcess()\n\n\t\tgot5, _ := q.Poll(ctx, 5, filterFnTrue)\n\t\tassert.Equal(t, model.StatusFailure, got5.DepStatus[\"prop-4\"])\n\t\tassert.False(t, got5.ShouldRun())\n\n\t\tgot6, _ := q.Poll(ctx, 6, filterFnTrue)\n\t\tassert.Equal(t, model.StatusFailure, got6.DepStatus[\"prop-4\"])\n\t\tassert.True(t, got6.ShouldRun())\n\n\t\t// Edge case: complete dependent tasks\n\t\tassert.NoError(t, q.Done(ctx, got5.ID, model.StatusSkipped))\n\t\tassert.NoError(t, q.Done(ctx, got6.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t})\n\n\t// Edge case: circular dependency detection (should be handled or cause issue)\n\tt.Run(\"circular dependencies\", func(t *testing.T) {\n\t\ttask1 := &model.Task{\n\t\t\tID:           \"circ-1\",\n\t\t\tDependencies: []string{\"circ-2\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\ttask2 := &model.Task{\n\t\t\tID:           \"circ-2\",\n\t\t\tDependencies: []string{\"circ-1\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\t// Both should be waiting on deps - this is a deadlock scenario\n\t\tassert.Equal(t, 2, info.Stats.WaitingOnDeps)\n\t\tassert.Len(t, info.Pending, 0)\n\n\t\t// Verify they never become available for polling\n\t\tpollCtx, pollCancel := context.WithTimeout(ctx, 200*time.Millisecond)\n\t\t_, err := q.Poll(pollCtx, 99, filterFnTrue)\n\t\tpollCancel()\n\t\tassert.Error(t, err) // Should timeout\n\n\t\t// Clean up the deadlocked tasks\n\t\tassert.NoError(t, q.ErrorAtOnce(ctx, []string{\"circ-1\", \"circ-2\"}, fmt.Errorf(\"circular dep\")))\n\t\twaitForProcess()\n\t})\n\n\t// Edge case: dependency on non-existent task\n\t// NOTE: This reveals a potential issue - the queue doesn't validate dependencies exist.\n\t// If a dependency was never added to the queue, the task will run immediately since\n\t// depsInQueue() only checks currently pending/running tasks, not if deps will arrive.\n\tt.Run(\"non-existent dependency\", func(t *testing.T) {\n\t\ttask1 := &model.Task{\n\t\t\tID:           \"orphan-1\",\n\t\t\tDependencies: []string{\"does-not-exist\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1}))\n\t\twaitForProcess()\n\n\t\tinfo := q.Info(ctx)\n\t\t// Current implementation: task doesn't wait if dependency not in queue\n\t\t// This means tasks with typos in dependency names will run immediately!\n\t\tassert.Equal(t, 0, info.Stats.WaitingOnDeps)\n\t\tassert.Len(t, info.Pending, 1)\n\n\t\t// Task will be available for polling even though dependency doesn't exist\n\t\tgot, err := q.Poll(ctx, 99, filterFnTrue)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"orphan-1\", got.ID)\n\n\t\t// DepStatus will be empty since dependency never completed\n\t\tassert.Empty(t, got.DepStatus)\n\n\t\t// Clean up\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t})\n\n\t// Edge case: dependency added AFTER dependent task (race condition)\n\tt.Run(\"dependency added after dependent\", func(t *testing.T) {\n\t\t// Push dependent task first\n\t\tdependent := &model.Task{\n\t\t\tID:           \"late-dep-child\",\n\t\t\tDependencies: []string{\"late-dep-parent\"},\n\t\t\tDepStatus:    make(map[string]model.StatusValue),\n\t\t}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dependent}))\n\t\twaitForProcess()\n\n\t\t// At this point, dependent doesn't see parent in queue, so it won't wait\n\t\tinfo := q.Info(ctx)\n\t\t// Dependent should NOT be waiting since parent doesn't exist yet\n\t\tinitialWaiting := info.Stats.WaitingOnDeps\n\n\t\t// Now add the parent task\n\t\tparent := &model.Task{ID: \"late-dep-parent\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{parent}))\n\t\twaitForProcess()\n\n\t\t// After filterWaiting runs, dependent SHOULD now see parent and wait\n\t\tinfo = q.Info(ctx)\n\t\t// The implementation calls filterWaiting() which rechecks dependencies\n\t\t// So dependent should now be waiting\n\t\tassert.Greater(t, info.Stats.WaitingOnDeps, initialWaiting,\n\t\t\t\"dependent should start waiting once parent is added\")\n\n\t\t// Complete parent first\n\t\tgotParent, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.Equal(t, \"late-dep-parent\", gotParent.ID, \"parent should be polled first\")\n\t\tassert.NoError(t, q.Done(ctx, gotParent.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\t// Now child should be unblocked with parent's status\n\t\tgotChild, _ := q.Poll(ctx, 2, filterFnTrue)\n\t\tassert.Equal(t, \"late-dep-child\", gotChild.ID)\n\t\tassert.Equal(t, model.StatusSuccess, gotChild.DepStatus[\"late-dep-parent\"])\n\n\t\tassert.NoError(t, q.Done(ctx, gotChild.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t})\n}\n\nfunc TestFifoLeaseManagement(t *testing.T) {\n\tctx, cancel, q := setupTestQueue(t)\n\tdefer cancel(nil)\n\n\tt.Run(\"lease expiration\", func(t *testing.T) {\n\t\tq.extension = 0\n\t\tt.Cleanup(func() {\n\t\t\tq.extension = 50 * time.Millisecond\n\t\t})\n\t\tdummyTask := &model.Task{ID: \"lease-exp-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask}))\n\n\t\twaitForProcess()\n\t\tgot, err := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.NoError(t, err)\n\n\t\terrCh := make(chan error, 1)\n\t\tgo func() { errCh <- q.Wait(ctx, got.ID) }()\n\n\t\twaitForProcess()\n\t\tselect {\n\t\tcase werr := <-errCh:\n\t\t\tassert.Error(t, werr)\n\t\t\t// Edge case: verify error is ErrTaskExpired\n\t\t\tassert.ErrorIs(t, werr, ErrTaskExpired)\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Fatal(\"timeout waiting for Wait to return\")\n\t\t}\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 1)\n\n\t\t// Edge case: verify task was resubmitted to front of queue\n\t\tgot2, _ := q.Poll(ctx, 1, filterFnTrue)\n\t\tassert.Equal(t, got.ID, got2.ID) // Same task resubmitted\n\n\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\t// Verify cleanup\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 0)\n\t\tassert.Len(t, info.Running, 0)\n\t})\n\n\tt.Run(\"extend lease\", func(t *testing.T) {\n\t\tq.extension = 50 * time.Millisecond\n\t\tdummyTask := &model.Task{ID: \"extend-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask}))\n\n\t\twaitForProcess()\n\t\tgot, _ := q.Poll(ctx, 5, filterFnTrue)\n\n\t\tassert.NoError(t, q.Extend(ctx, 5, got.ID))\n\t\tassert.ErrorIs(t, q.Extend(ctx, 999, got.ID), ErrAgentMissMatch)\n\t\tassert.ErrorIs(t, q.Extend(ctx, 1, got.ID), ErrAgentMissMatch)\n\t\tassert.ErrorIs(t, q.Extend(ctx, 1, \"non-existent\"), ErrNotFound)\n\n\t\t// Edge case: extend multiple times rapidly\n\t\tfor i := 0; i < 3; i++ {\n\t\t\ttime.Sleep(30 * time.Millisecond)\n\t\t\tassert.NoError(t, q.Extend(ctx, 5, got.ID))\n\t\t}\n\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Running, 1)\n\t\tassert.Len(t, info.Pending, 0)\n\n\t\t// Edge case: extend after Done should error\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\t\tassert.ErrorIs(t, q.Extend(ctx, 5, got.ID), ErrNotFound)\n\n\t\t// Verify cleanup\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 0)\n\t\tassert.Len(t, info.Running, 0)\n\t})\n\n\tt.Run(\"wait operations\", func(t *testing.T) {\n\t\t// Verify queue is clean before starting\n\t\tinfo := q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 0, \"queue should be empty at start of wait operations\")\n\t\tassert.Len(t, info.Running, 0, \"queue should be empty at start of wait operations\")\n\n\t\tdummyTask := &model.Task{ID: \"wait-1\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask}))\n\n\t\twaitForProcess()\n\t\tgot, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tassert.NoError(t, q.Wait(ctx, got.ID))\n\t\t\twg.Done()\n\t\t}()\n\n\t\ttime.Sleep(time.Millisecond)\n\t\tassert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess))\n\t\twg.Wait()\n\n\t\t// Edge case: Wait on non-existent task should return immediately\n\t\tassert.NoError(t, q.Wait(ctx, \"non-existent\"))\n\n\t\tdummyTask2 := &model.Task{ID: \"wait-2\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask2}))\n\t\twaitForProcess()\n\t\tgot2, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\twaitCtx, waitCancel := context.WithCancelCause(ctx)\n\t\terrCh := make(chan error, 1)\n\t\tgo func() { errCh <- q.Wait(waitCtx, got2.ID) }()\n\n\t\ttime.Sleep(50 * time.Millisecond)\n\t\twaitCancel(nil)\n\n\t\tselect {\n\t\tcase err := <-errCh:\n\t\t\tassert.NoError(t, err)\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Wait should return when context is canceled\")\n\t\t}\n\n\t\t// Clean up - complete the second wait task\n\t\tassert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess))\n\t\twaitForProcess()\n\n\t\t// Edge case: multiple concurrent waits on same task\n\t\tdummyTask3 := &model.Task{ID: \"wait-3\"}\n\t\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask3}))\n\t\twaitForProcess()\n\t\tgot3, _ := q.Poll(ctx, 1, filterFnTrue)\n\n\t\tvar wg2 sync.WaitGroup\n\t\twg2.Add(3)\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tgo func() {\n\t\t\t\tassert.NoError(t, q.Wait(ctx, got3.ID))\n\t\t\t\twg2.Done()\n\t\t\t}()\n\t\t}\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\t\tassert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess))\n\t\twg2.Wait()\n\n\t\t// Verify cleanup\n\t\tinfo = q.Info(ctx)\n\t\tassert.Len(t, info.Pending, 0)\n\t\tassert.Len(t, info.Running, 0)\n\t})\n}\n\nfunc TestFifoWorkerManagement(t *testing.T) {\n\tctx, cancel, q := setupTestQueue(t)\n\tdefer cancel(nil)\n\n\tt.Run(\"poll with context cancellation\", func(t *testing.T) {\n\t\tpollCtx, pollCancel := context.WithCancelCause(ctx)\n\t\terrCh := make(chan error, 1)\n\t\tgo func() {\n\t\t\t_, err := q.Poll(pollCtx, 1, filterFnTrue)\n\t\t\terrCh <- err\n\t\t}()\n\n\t\ttime.Sleep(50 * time.Millisecond)\n\t\tpollCancel(nil)\n\n\t\tselect {\n\t\tcase err := <-errCh:\n\t\t\tassert.ErrorIs(t, err, context.Canceled)\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Poll should return when context is canceled\")\n\t\t}\n\n\t\t// Edge case: verify worker is cleaned up\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 0, info.Stats.Workers)\n\t})\n\n\tt.Run(\"kick agent workers\", func(t *testing.T) {\n\t\tpollResults := make(chan error, 5)\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tgo func() {\n\t\t\t\t_, err := q.Poll(ctx, 42, filterFnTrue)\n\t\t\t\tpollResults <- err\n\t\t\t}()\n\t\t}\n\n\t\ttime.Sleep(50 * time.Millisecond)\n\n\t\t// Edge case: verify workers are registered before kicking\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 5, info.Stats.Workers)\n\n\t\tq.KickAgentWorkers(42)\n\n\t\tkickedCount := 0\n\t\tfor i := 0; i < 5; i++ {\n\t\t\tselect {\n\t\t\tcase err := <-pollResults:\n\t\t\t\tif errors.Is(err, context.Canceled) {\n\t\t\t\t\tkickedCount++\n\t\t\t\t}\n\t\t\tcase <-time.After(time.Second):\n\t\t\t\tt.Fatal(\"expected all workers to be kicked\")\n\t\t\t}\n\t\t}\n\t\tassert.Equal(t, 5, kickedCount)\n\n\t\t// Edge case: verify workers are removed after kicking\n\t\twaitForProcess()\n\t\tinfo = q.Info(ctx)\n\t\tassert.Equal(t, 0, info.Stats.Workers)\n\n\t\t// Edge case: kick non-existent agent should be no-op\n\t\tq.KickAgentWorkers(999)\n\t})\n\n\t// Edge case: mixed agent workers\n\tt.Run(\"kick specific agent among multiple\", func(t *testing.T) {\n\t\tpollResults := make(chan struct {\n\t\t\tagentID int64\n\t\t\terr     error\n\t\t}, 10)\n\n\t\t// Start workers for agent 1\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tgo func() {\n\t\t\t\t_, err := q.Poll(ctx, 1, filterFnTrue)\n\t\t\t\tpollResults <- struct {\n\t\t\t\t\tagentID int64\n\t\t\t\t\terr     error\n\t\t\t\t}{1, err}\n\t\t\t}()\n\t\t}\n\n\t\t// Start workers for agent 2\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tgo func() {\n\t\t\t\t_, err := q.Poll(ctx, 2, filterFnTrue)\n\t\t\t\tpollResults <- struct {\n\t\t\t\t\tagentID int64\n\t\t\t\t\terr     error\n\t\t\t\t}{2, err}\n\t\t\t}()\n\t\t}\n\n\t\ttime.Sleep(50 * time.Millisecond)\n\t\tinfo := q.Info(ctx)\n\t\tassert.Equal(t, 6, info.Stats.Workers)\n\n\t\t// Kick only agent 1\n\t\tq.KickAgentWorkers(1)\n\n\t\tkickedAgent1 := 0\n\t\tkickedAgent2 := 0\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tselect {\n\t\t\tcase result := <-pollResults:\n\t\t\t\tif errors.Is(result.err, context.Canceled) {\n\t\t\t\t\tif result.agentID == 1 {\n\t\t\t\t\t\tkickedAgent1++\n\t\t\t\t\t} else {\n\t\t\t\t\t\tkickedAgent2++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase <-time.After(time.Second):\n\t\t\t\tt.Fatal(\"expected kicked workers to return\")\n\t\t\t}\n\t\t}\n\n\t\tassert.Equal(t, 3, kickedAgent1)\n\t\tassert.Equal(t, 0, kickedAgent2)\n\n\t\t// Clean up agent 2 workers\n\t\tq.KickAgentWorkers(2)\n\t\tfor i := 0; i < 3; i++ {\n\t\t\t<-pollResults\n\t\t}\n\t})\n}\n\nfunc TestFifoLabelBasedScoring(t *testing.T) {\n\tctx, cancel := context.WithCancelCause(t.Context())\n\tdefer cancel(nil)\n\n\tq := NewMemoryQueue(ctx)\n\n\ttasks := []*model.Task{\n\t\t{ID: \"1\", Labels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"}},\n\t\t{ID: \"2\", Labels: map[string]string{\"org-id\": \"456\", \"platform\": \"linux\"}},\n\t\t{ID: \"3\", Labels: map[string]string{\"org-id\": \"123\", \"platform\": \"windows\"}},\n\t}\n\n\tassert.NoError(t, q.PushAtOnce(ctx, tasks))\n\n\tfilter123 := func(task *model.Task) (bool, int) {\n\t\tif task.Labels[\"org-id\"] == \"123\" {\n\t\t\treturn true, 20\n\t\t}\n\t\treturn true, 1\n\t}\n\n\tfilter456 := func(task *model.Task) (bool, int) {\n\t\tif task.Labels[\"org-id\"] == \"456\" {\n\t\t\treturn true, 20\n\t\t}\n\t\treturn true, 1\n\t}\n\n\tresults := make(chan *model.Task, 2)\n\tgo func() {\n\t\ttask, _ := q.Poll(ctx, 1, filter123)\n\t\tresults <- task\n\t}()\n\tgo func() {\n\t\ttask, _ := q.Poll(ctx, 2, filter456)\n\t\tresults <- task\n\t}()\n\n\treceivedTasks := make(map[string]int64)\n\tfor i := 0; i < 2; i++ {\n\t\tselect {\n\t\tcase task := <-results:\n\t\t\treceivedTasks[task.ID] = task.AgentID\n\t\tcase <-time.After(time.Second):\n\t\t\tt.Fatal(\"Timeout waiting for tasks\")\n\t\t}\n\t}\n\n\tassert.Contains(t, []string{\"1\", \"3\"}, findTaskByAgent(receivedTasks, 1))\n\tassert.Equal(t, \"2\", findTaskByAgent(receivedTasks, 2))\n\n\t// Edge case: filter that rejects all tasks\n\tfilterRejectAll := func(task *model.Task) (bool, int) {\n\t\treturn false, 0\n\t}\n\n\ttask4 := &model.Task{ID: \"4\", Labels: map[string]string{\"org-id\": \"789\"}}\n\tassert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4}))\n\twaitForProcess()\n\n\tpollCtx, pollCancel := context.WithTimeout(ctx, 200*time.Millisecond)\n\t_, err := q.Poll(pollCtx, 99, filterRejectAll)\n\tpollCancel()\n\tassert.Error(t, err) // Should timeout as filter rejects task\n\n\t// Clean up remaining tasks\n\ttask3, _ := q.Poll(ctx, 1, filterFnTrue)\n\tassert.NoError(t, q.Done(ctx, task3.ID, model.StatusSuccess))\n\ttask4Got, _ := q.Poll(ctx, 99, filterFnTrue)\n\tassert.NoError(t, q.Done(ctx, task4Got.ID, model.StatusSuccess))\n\twaitForProcess()\n}\n\nfunc TestShouldRunLogic(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tdepStatus model.StatusValue\n\t\trunOn     []string\n\t\texpected  bool\n\t}{\n\t\t{\"Success without RunOn\", model.StatusSuccess, nil, true},\n\t\t{\"Failure without RunOn\", model.StatusFailure, nil, false},\n\t\t{\"Success with failure RunOn\", model.StatusSuccess, []string{\"failure\"}, false},\n\t\t{\"Failure with failure RunOn\", model.StatusFailure, []string{\"failure\"}, true},\n\t\t{\"Success with both RunOn\", model.StatusSuccess, []string{\"success\", \"failure\"}, true},\n\t\t{\"Skipped without RunOn\", model.StatusSkipped, nil, false},\n\t\t{\"Skipped with failure RunOn\", model.StatusSkipped, []string{\"failure\"}, true},\n\t\t// Edge cases\n\t\t{\"Killed without RunOn\", model.StatusKilled, nil, false},\n\t\t{\"Killed with failure RunOn\", model.StatusKilled, []string{\"failure\"}, true},\n\t\t{\"Success with success RunOn only\", model.StatusSuccess, []string{\"success\"}, true},\n\t\t{\"Failure with success RunOn only\", model.StatusFailure, []string{\"success\"}, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttask := &model.Task{\n\t\t\t\tID:           \"2\",\n\t\t\t\tDependencies: []string{\"1\"},\n\t\t\t\tDepStatus:    map[string]model.StatusValue{\"1\": tt.depStatus},\n\t\t\t\tRunOn:        tt.runOn,\n\t\t\t}\n\t\t\tassert.Equal(t, tt.expected, task.ShouldRun())\n\t\t})\n\t}\n\n\t// Edge case: multiple dependencies with mixed statuses\n\tt.Run(\"multiple deps mixed status\", func(t *testing.T) {\n\t\ttask := &model.Task{\n\t\t\tID:           \"3\",\n\t\t\tDependencies: []string{\"1\", \"2\"},\n\t\t\tDepStatus: map[string]model.StatusValue{\n\t\t\t\t\"1\": model.StatusSuccess,\n\t\t\t\t\"2\": model.StatusFailure,\n\t\t\t},\n\t\t\tRunOn: nil,\n\t\t}\n\t\t// With default RunOn (nil), needs all deps successful\n\t\tassert.False(t, task.ShouldRun())\n\n\t\ttask.RunOn = []string{\"success\", \"failure\"}\n\t\t// With both RunOn, should run regardless\n\t\tassert.True(t, task.ShouldRun())\n\t})\n}\n\nfunc findTaskByAgent(tasks map[string]int64, agentID int64) string {\n\tfor taskID, aid := range tasks {\n\t\tif aid == agentID {\n\t\t\treturn taskID\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "server/queue/mocks/mock_Queue.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n)\n\n// NewMockQueue creates a new instance of MockQueue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockQueue(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockQueue {\n\tmock := &MockQueue{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockQueue is an autogenerated mock type for the Queue type\ntype MockQueue struct {\n\tmock.Mock\n}\n\ntype MockQueue_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockQueue) EXPECT() *MockQueue_Expecter {\n\treturn &MockQueue_Expecter{mock: &_m.Mock}\n}\n\n// Done provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Done(c context.Context, id string, exitStatus model.StatusValue) error {\n\tret := _mock.Called(c, id, exitStatus)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Done\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string, model.StatusValue) error); ok {\n\t\tr0 = returnFunc(c, id, exitStatus)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done'\ntype MockQueue_Done_Call struct {\n\t*mock.Call\n}\n\n// Done is a helper method to define mock.On call\n//   - c context.Context\n//   - id string\n//   - exitStatus model.StatusValue\nfunc (_e *MockQueue_Expecter) Done(c interface{}, id interface{}, exitStatus interface{}) *MockQueue_Done_Call {\n\treturn &MockQueue_Done_Call{Call: _e.mock.On(\"Done\", c, id, exitStatus)}\n}\n\nfunc (_c *MockQueue_Done_Call) Run(run func(c context.Context, id string, exitStatus model.StatusValue)) *MockQueue_Done_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 model.StatusValue\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(model.StatusValue)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Done_Call) Return(err error) *MockQueue_Done_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Done_Call) RunAndReturn(run func(c context.Context, id string, exitStatus model.StatusValue) error) *MockQueue_Done_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Error provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Error(c context.Context, id string, err error) error {\n\tret := _mock.Called(c, id, err)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Error\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string, error) error); ok {\n\t\tr0 = returnFunc(c, id, err)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error'\ntype MockQueue_Error_Call struct {\n\t*mock.Call\n}\n\n// Error is a helper method to define mock.On call\n//   - c context.Context\n//   - id string\n//   - err error\nfunc (_e *MockQueue_Expecter) Error(c interface{}, id interface{}, err interface{}) *MockQueue_Error_Call {\n\treturn &MockQueue_Error_Call{Call: _e.mock.On(\"Error\", c, id, err)}\n}\n\nfunc (_c *MockQueue_Error_Call) Run(run func(c context.Context, id string, err error)) *MockQueue_Error_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 error\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(error)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Error_Call) Return(err1 error) *MockQueue_Error_Call {\n\t_c.Call.Return(err1)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Error_Call) RunAndReturn(run func(c context.Context, id string, err error) error) *MockQueue_Error_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ErrorAtOnce provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) ErrorAtOnce(c context.Context, ids []string, err error) error {\n\tret := _mock.Called(c, ids, err)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ErrorAtOnce\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, []string, error) error); ok {\n\t\tr0 = returnFunc(c, ids, err)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_ErrorAtOnce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ErrorAtOnce'\ntype MockQueue_ErrorAtOnce_Call struct {\n\t*mock.Call\n}\n\n// ErrorAtOnce is a helper method to define mock.On call\n//   - c context.Context\n//   - ids []string\n//   - err error\nfunc (_e *MockQueue_Expecter) ErrorAtOnce(c interface{}, ids interface{}, err interface{}) *MockQueue_ErrorAtOnce_Call {\n\treturn &MockQueue_ErrorAtOnce_Call{Call: _e.mock.On(\"ErrorAtOnce\", c, ids, err)}\n}\n\nfunc (_c *MockQueue_ErrorAtOnce_Call) Run(run func(c context.Context, ids []string, err error)) *MockQueue_ErrorAtOnce_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 []string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]string)\n\t\t}\n\t\tvar arg2 error\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(error)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_ErrorAtOnce_Call) Return(err1 error) *MockQueue_ErrorAtOnce_Call {\n\t_c.Call.Return(err1)\n\treturn _c\n}\n\nfunc (_c *MockQueue_ErrorAtOnce_Call) RunAndReturn(run func(c context.Context, ids []string, err error) error) *MockQueue_ErrorAtOnce_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Extend provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Extend(c context.Context, agentID int64, workflowID string) error {\n\tret := _mock.Called(c, agentID, workflowID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Extend\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, int64, string) error); ok {\n\t\tr0 = returnFunc(c, agentID, workflowID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_Extend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extend'\ntype MockQueue_Extend_Call struct {\n\t*mock.Call\n}\n\n// Extend is a helper method to define mock.On call\n//   - c context.Context\n//   - agentID int64\n//   - workflowID string\nfunc (_e *MockQueue_Expecter) Extend(c interface{}, agentID interface{}, workflowID interface{}) *MockQueue_Extend_Call {\n\treturn &MockQueue_Extend_Call{Call: _e.mock.On(\"Extend\", c, agentID, workflowID)}\n}\n\nfunc (_c *MockQueue_Extend_Call) Run(run func(c context.Context, agentID int64, workflowID string)) *MockQueue_Extend_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Extend_Call) Return(err error) *MockQueue_Extend_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Extend_Call) RunAndReturn(run func(c context.Context, agentID int64, workflowID string) error) *MockQueue_Extend_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Info provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Info(c context.Context) queue.InfoT {\n\tret := _mock.Called(c)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Info\")\n\t}\n\n\tvar r0 queue.InfoT\n\tif returnFunc, ok := ret.Get(0).(func(context.Context) queue.InfoT); ok {\n\t\tr0 = returnFunc(c)\n\t} else {\n\t\tr0 = ret.Get(0).(queue.InfoT)\n\t}\n\treturn r0\n}\n\n// MockQueue_Info_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Info'\ntype MockQueue_Info_Call struct {\n\t*mock.Call\n}\n\n// Info is a helper method to define mock.On call\n//   - c context.Context\nfunc (_e *MockQueue_Expecter) Info(c interface{}) *MockQueue_Info_Call {\n\treturn &MockQueue_Info_Call{Call: _e.mock.On(\"Info\", c)}\n}\n\nfunc (_c *MockQueue_Info_Call) Run(run func(c context.Context)) *MockQueue_Info_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Info_Call) Return(infoT queue.InfoT) *MockQueue_Info_Call {\n\t_c.Call.Return(infoT)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Info_Call) RunAndReturn(run func(c context.Context) queue.InfoT) *MockQueue_Info_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// KickAgentWorkers provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) KickAgentWorkers(agentID int64) {\n\t_mock.Called(agentID)\n\treturn\n}\n\n// MockQueue_KickAgentWorkers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KickAgentWorkers'\ntype MockQueue_KickAgentWorkers_Call struct {\n\t*mock.Call\n}\n\n// KickAgentWorkers is a helper method to define mock.On call\n//   - agentID int64\nfunc (_e *MockQueue_Expecter) KickAgentWorkers(agentID interface{}) *MockQueue_KickAgentWorkers_Call {\n\treturn &MockQueue_KickAgentWorkers_Call{Call: _e.mock.On(\"KickAgentWorkers\", agentID)}\n}\n\nfunc (_c *MockQueue_KickAgentWorkers_Call) Run(run func(agentID int64)) *MockQueue_KickAgentWorkers_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_KickAgentWorkers_Call) Return() *MockQueue_KickAgentWorkers_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockQueue_KickAgentWorkers_Call) RunAndReturn(run func(agentID int64)) *MockQueue_KickAgentWorkers_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// Pause provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Pause() {\n\t_mock.Called()\n\treturn\n}\n\n// MockQueue_Pause_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pause'\ntype MockQueue_Pause_Call struct {\n\t*mock.Call\n}\n\n// Pause is a helper method to define mock.On call\nfunc (_e *MockQueue_Expecter) Pause() *MockQueue_Pause_Call {\n\treturn &MockQueue_Pause_Call{Call: _e.mock.On(\"Pause\")}\n}\n\nfunc (_c *MockQueue_Pause_Call) Run(run func()) *MockQueue_Pause_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Pause_Call) Return() *MockQueue_Pause_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockQueue_Pause_Call) RunAndReturn(run func()) *MockQueue_Pause_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// Poll provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) {\n\tret := _mock.Called(c, agentID, f)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Poll\")\n\t}\n\n\tvar r0 *model.Task\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) (*model.Task, error)); ok {\n\t\treturn returnFunc(c, agentID, f)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) *model.Task); ok {\n\t\tr0 = returnFunc(c, agentID, f)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Task)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, int64, queue.FilterFn) error); ok {\n\t\tr1 = returnFunc(c, agentID, f)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockQueue_Poll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Poll'\ntype MockQueue_Poll_Call struct {\n\t*mock.Call\n}\n\n// Poll is a helper method to define mock.On call\n//   - c context.Context\n//   - agentID int64\n//   - f queue.FilterFn\nfunc (_e *MockQueue_Expecter) Poll(c interface{}, agentID interface{}, f interface{}) *MockQueue_Poll_Call {\n\treturn &MockQueue_Poll_Call{Call: _e.mock.On(\"Poll\", c, agentID, f)}\n}\n\nfunc (_c *MockQueue_Poll_Call) Run(run func(c context.Context, agentID int64, f queue.FilterFn)) *MockQueue_Poll_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 queue.FilterFn\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(queue.FilterFn)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Poll_Call) Return(task *model.Task, err error) *MockQueue_Poll_Call {\n\t_c.Call.Return(task, err)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Poll_Call) RunAndReturn(run func(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error)) *MockQueue_Poll_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PushAtOnce provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) PushAtOnce(c context.Context, tasks []*model.Task) error {\n\tret := _mock.Called(c, tasks)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PushAtOnce\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, []*model.Task) error); ok {\n\t\tr0 = returnFunc(c, tasks)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_PushAtOnce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushAtOnce'\ntype MockQueue_PushAtOnce_Call struct {\n\t*mock.Call\n}\n\n// PushAtOnce is a helper method to define mock.On call\n//   - c context.Context\n//   - tasks []*model.Task\nfunc (_e *MockQueue_Expecter) PushAtOnce(c interface{}, tasks interface{}) *MockQueue_PushAtOnce_Call {\n\treturn &MockQueue_PushAtOnce_Call{Call: _e.mock.On(\"PushAtOnce\", c, tasks)}\n}\n\nfunc (_c *MockQueue_PushAtOnce_Call) Run(run func(c context.Context, tasks []*model.Task)) *MockQueue_PushAtOnce_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 []*model.Task\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]*model.Task)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_PushAtOnce_Call) Return(err error) *MockQueue_PushAtOnce_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockQueue_PushAtOnce_Call) RunAndReturn(run func(c context.Context, tasks []*model.Task) error) *MockQueue_PushAtOnce_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Resume provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Resume() {\n\t_mock.Called()\n\treturn\n}\n\n// MockQueue_Resume_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resume'\ntype MockQueue_Resume_Call struct {\n\t*mock.Call\n}\n\n// Resume is a helper method to define mock.On call\nfunc (_e *MockQueue_Expecter) Resume() *MockQueue_Resume_Call {\n\treturn &MockQueue_Resume_Call{Call: _e.mock.On(\"Resume\")}\n}\n\nfunc (_c *MockQueue_Resume_Call) Run(run func()) *MockQueue_Resume_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Resume_Call) Return() *MockQueue_Resume_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockQueue_Resume_Call) RunAndReturn(run func()) *MockQueue_Resume_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// Wait provides a mock function for the type MockQueue\nfunc (_mock *MockQueue) Wait(c context.Context, id string) error {\n\tret := _mock.Called(c, id)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Wait\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {\n\t\tr0 = returnFunc(c, id)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockQueue_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait'\ntype MockQueue_Wait_Call struct {\n\t*mock.Call\n}\n\n// Wait is a helper method to define mock.On call\n//   - c context.Context\n//   - id string\nfunc (_e *MockQueue_Expecter) Wait(c interface{}, id interface{}) *MockQueue_Wait_Call {\n\treturn &MockQueue_Wait_Call{Call: _e.mock.On(\"Wait\", c, id)}\n}\n\nfunc (_c *MockQueue_Wait_Call) Run(run func(c context.Context, id string)) *MockQueue_Wait_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockQueue_Wait_Call) Return(err error) *MockQueue_Wait_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockQueue_Wait_Call) RunAndReturn(run func(c context.Context, id string) error) *MockQueue_Wait_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/queue/persistent.go",
    "content": "// Copyright 2021 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage queue\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// WithTaskStore returns a queue that is backed by the TaskStore. This\n// ensures the task Queue can be restored when the system starts.\nfunc WithTaskStore(ctx context.Context, q Queue, s store.Store) Queue {\n\ttasks, _ := s.TaskList()\n\tif err := q.PushAtOnce(ctx, tasks); err != nil {\n\t\tlog.Error().Err(err).Msg(\"PushAtOnce failed\")\n\t}\n\treturn &persistentQueue{q, s}\n}\n\ntype persistentQueue struct {\n\tQueue\n\tstore store.Store\n}\n\n// PushAtOnce pushes multiple tasks to the tail of this queue.\nfunc (q *persistentQueue) PushAtOnce(c context.Context, tasks []*model.Task) error {\n\t// TODO: invent store.NewSession who return context including a session and make TaskInsert & TaskDelete use it\n\tfor _, task := range tasks {\n\t\tif err := q.store.TaskInsert(task); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\terr := q.Queue.PushAtOnce(c, tasks)\n\tif err != nil {\n\t\tfor _, task := range tasks {\n\t\t\tif err := q.store.TaskDelete(task.ID); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\treturn err\n}\n\n// Poll retrieves and removes a task head of this queue.\nfunc (q *persistentQueue) Poll(c context.Context, agentID int64, f FilterFn) (*model.Task, error) {\n\ttask, err := q.Queue.Poll(c, agentID, f)\n\tif task != nil {\n\t\tlog.Debug().Msgf(\"pull queue item: %s: remove from backup\", task.ID)\n\t\tif deleteErr := q.store.TaskDelete(task.ID); deleteErr != nil {\n\t\t\tlog.Error().Err(deleteErr).Msgf(\"pull queue item: %s: failed to remove from backup\", task.ID)\n\t\t} else {\n\t\t\tlog.Debug().Msgf(\"pull queue item: %s: successfully removed from backup\", task.ID)\n\t\t}\n\t}\n\treturn task, err\n}\n\n// Error signals the task is done with an error.\nfunc (q *persistentQueue) Error(c context.Context, id string, err error) error {\n\tif err := q.Queue.Error(c, id, err); err != nil {\n\t\treturn err\n\t}\n\n\tif deleteErr := q.store.TaskDelete(id); deleteErr != nil {\n\t\tif !errors.Is(deleteErr, types.ErrRecordNotExist) {\n\t\t\treturn deleteErr\n\t\t}\n\t\tlog.Debug().Msgf(\"task %s already removed from store\", id)\n\t}\n\treturn nil\n}\n\n// ErrorAtOnce signals multiple tasks are done and complete with an error.\n// If still pending they will just get removed from the queue.\nfunc (q *persistentQueue) ErrorAtOnce(c context.Context, ids []string, err error) error {\n\tif err := q.Queue.ErrorAtOnce(c, ids, err); err != nil {\n\t\treturn err\n\t}\n\n\tvar errs []error\n\tfor _, id := range ids {\n\t\tif deleteErr := q.store.TaskDelete(id); deleteErr != nil && !errors.Is(deleteErr, types.ErrRecordNotExist) {\n\t\t\terrs = append(errs, fmt.Errorf(\"task id [%s]: %w\", id, deleteErr))\n\t\t}\n\t}\n\n\tif len(errs) != 0 {\n\t\treturn fmt.Errorf(\"failed to delete tasks from persistent store: %w\", errors.Join(errs...))\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/queue/queue.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage queue\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nvar (\n\t// ErrCancel indicates the task was canceled.\n\tErrCancel = errors.New(\"queue: task canceled\")\n\n\t// ErrNotFound indicates the task was not found in the queue.\n\tErrNotFound = errors.New(\"queue: task not found\")\n\n\t// ErrAgentMissMatch indicates a task is assigned to a different agent.\n\tErrAgentMissMatch = errors.New(\"task assigned to different agent\")\n\n\t// ErrTaskExpired indicates a running task exceeded its lease/deadline and was resubmitted.\n\tErrTaskExpired = errors.New(\"queue: task expired\")\n\n\t// ErrWorkerKicked worker of an agent got kicked.\n\tErrWorkerKicked = errors.New(\"worker was kicked\")\n)\n\n// ErrExternal wraps an external error.\ntype ErrExternal struct {\n\terr error\n}\n\nfunc (e *ErrExternal) Error() string {\n\treturn fmt.Sprintf(\"external error: %s\", e.err)\n}\n\n// Unwrap allows errors.Is and errors.As to work with the wrapped error.\nfunc (e *ErrExternal) Unwrap() error {\n\treturn e.err\n}\n\n// Is allows errors.Is to match against ErrExternal types.\nfunc (e *ErrExternal) Is(target error) bool {\n\t_, ok := target.(*ErrExternal)\n\treturn ok\n}\n\n// NewErrExternal wraps an error as external one so queue can filter it out if needed.\nfunc NewErrExternal(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn &ErrExternal{err: err}\n}\n\n// InfoT provides runtime information.\ntype InfoT struct {\n\tPending       []*model.Task `json:\"pending\"`\n\tWaitingOnDeps []*model.Task `json:\"waiting_on_deps\"`\n\tRunning       []*model.Task `json:\"running\"`\n\tStats         struct {\n\t\tWorkers       int `json:\"worker_count\"`\n\t\tPending       int `json:\"pending_count\"`\n\t\tWaitingOnDeps int `json:\"waiting_on_deps_count\"`\n\t\tRunning       int `json:\"running_count\"`\n\t} `json:\"stats\"`\n\tPaused bool `json:\"paused\"`\n} //\t@name\tInfoT\n\nfunc (t *InfoT) String() string {\n\tvar sb strings.Builder\n\n\tfor _, task := range t.Pending {\n\t\tsb.WriteString(\"\\t\" + task.String())\n\t}\n\n\tfor _, task := range t.Running {\n\t\tsb.WriteString(\"\\t\" + task.String())\n\t}\n\n\tfor _, task := range t.WaitingOnDeps {\n\t\tsb.WriteString(\"\\t\" + task.String())\n\t}\n\n\treturn sb.String()\n}\n\n// FilterFn filters tasks in the queue. If the Filter returns false,\n// the Task is skipped and not returned to the subscriber.\n// The int return value represents the matching score (higher is better).\ntype FilterFn func(*model.Task) (bool, int)\n\n// Queue defines a task queue for scheduling tasks among\n// a pool of workers.\ntype Queue interface {\n\t// PushAtOnce pushes multiple tasks to the tail of this queue.\n\tPushAtOnce(c context.Context, tasks []*model.Task) error\n\n\t// Poll retrieves and removes a task head of this queue.\n\tPoll(c context.Context, agentID int64, f FilterFn) (*model.Task, error)\n\n\t// Extend extends the deadline for a task.\n\tExtend(c context.Context, agentID int64, workflowID string) error\n\n\t// Done signals the task is complete.\n\tDone(c context.Context, id string, exitStatus model.StatusValue) error\n\n\t// Error signals the task is done with an error.\n\tError(c context.Context, id string, err error) error\n\n\t// ErrorAtOnce signals multiple tasks are done and complete with an error.\n\t// If still pending they will just get removed from the queue.\n\tErrorAtOnce(c context.Context, ids []string, err error) error\n\n\t// Wait waits until the task is complete.\n\t// Also signals via error ErrCancel if workflow got canceled.\n\tWait(c context.Context, id string) error\n\n\t// Info returns internal queue information.\n\tInfo(c context.Context) InfoT\n\n\t// Pause stops the queue from handing out new work items in Poll\n\tPause()\n\n\t// Resume starts the queue again.\n\tResume()\n\n\t// KickAgentWorkers kicks all workers for a given agent.\n\tKickAgentWorkers(agentID int64)\n}\n\n// Config holds the configuration for the queue.\ntype Config struct {\n\tBackend Type\n\tStore   store.Store\n}\n\n// Queue type.\ntype Type string\n\nconst (\n\tTypeMemory Type = \"memory\"\n)\n\n// New creates a new queue based on the provided configuration.\nfunc New(ctx context.Context, config Config) (Queue, error) {\n\tvar q Queue\n\n\tswitch config.Backend {\n\tcase TypeMemory:\n\t\tq = NewMemoryQueue(ctx)\n\t\tif config.Store != nil {\n\t\t\tq = WithTaskStore(ctx, q, config.Store)\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported queue backend: %s\", config.Backend)\n\t}\n\n\treturn q, nil\n}\n"
  },
  {
    "path": "server/router/api.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage router\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api/debug\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n)\n\nfunc apiRoutes(e *gin.RouterGroup) {\n\tapiBase := e.Group(\"/api\")\n\t{\n\t\tuser := apiBase.Group(\"/user\")\n\t\t{\n\t\t\tuser.Use(session.MustUser())\n\t\t\tuser.GET(\"\", api.GetSelf)\n\t\t\tuser.GET(\"/feed\", api.GetFeed)\n\t\t\tuser.GET(\"/repos\", api.GetRepos)\n\t\t\tuser.POST(\"/token\", api.PostToken)\n\t\t\tuser.DELETE(\"/token\", api.DeleteToken)\n\t\t}\n\n\t\tusers := apiBase.Group(\"/users\")\n\t\t{\n\t\t\tusers.Use(session.MustAdmin())\n\t\t\tusers.GET(\"\", api.GetUsers)\n\t\t\tusers.POST(\"\", api.PostUser)\n\t\t\tusers.GET(\"/:login\", api.GetUser)\n\t\t\tusers.PATCH(\"/:login\", api.PatchUser)\n\t\t\tusers.DELETE(\"/:login\", api.DeleteUser)\n\t\t}\n\n\t\torgs := apiBase.Group(\"/orgs\")\n\t\t{\n\t\t\torgs.GET(\"\", session.MustAdmin(), api.GetOrgs)\n\t\t\torgs.GET(\"/lookup/*org_full_name\", api.LookupOrg)\n\t\t\torgBase := orgs.Group(\"/:org_id\")\n\t\t\t{\n\t\t\t\torgBase.Use(session.SetOrg())\n\t\t\t\torgBase.Use(session.MustOrg())\n\t\t\t\torgBase.GET(\"/permissions\", api.GetOrgPermissions)\n\t\t\t\torgBase.GET(\"\", session.MustOrgMember(false), api.GetOrg)\n\n\t\t\t\torg := orgBase.Group(\"\")\n\t\t\t\t{\n\t\t\t\t\torg.Use(session.MustOrgMember(true))\n\t\t\t\t\torg.DELETE(\"\", session.MustAdmin(), api.DeleteOrg)\n\n\t\t\t\t\torg.GET(\"/secrets\", api.GetOrgSecretList)\n\t\t\t\t\torg.POST(\"/secrets\", api.PostOrgSecret)\n\t\t\t\t\torg.GET(\"/secrets/:secret\", api.GetOrgSecret)\n\t\t\t\t\torg.PATCH(\"/secrets/:secret\", api.PatchOrgSecret)\n\t\t\t\t\torg.DELETE(\"/secrets/:secret\", api.DeleteOrgSecret)\n\n\t\t\t\t\torg.GET(\"/registries\", api.GetOrgRegistryList)\n\t\t\t\t\torg.POST(\"/registries\", api.PostOrgRegistry)\n\t\t\t\t\torg.GET(\"/registries/:registry\", api.GetOrgRegistry)\n\t\t\t\t\torg.PATCH(\"/registries/:registry\", api.PatchOrgRegistry)\n\t\t\t\t\torg.DELETE(\"/registries/:registry\", api.DeleteOrgRegistry)\n\n\t\t\t\t\tif !server.Config.Agent.DisableUserRegisteredAgentRegistration {\n\t\t\t\t\t\torg.GET(\"/agents\", api.GetOrgAgents)\n\t\t\t\t\t\torg.POST(\"/agents\", api.PostOrgAgent)\n\t\t\t\t\t\torg.PATCH(\"/agents/:agent_id\", api.PatchOrgAgent)\n\t\t\t\t\t\torg.DELETE(\"/agents/:agent_id\", api.DeleteOrgAgent)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\trepo := apiBase.Group(\"/repos\")\n\t\t{\n\t\t\trepo.GET(\"/lookup/*repo_full_name\", session.SetRepo(), session.SetPerm(), session.MustPull, api.LookupRepo)\n\t\t\trepo.POST(\"\", session.MustUser(), api.PostRepo)\n\t\t\trepo.GET(\"\", session.MustAdmin(), api.GetAllRepos)\n\t\t\trepo.POST(\"/repair\", session.MustAdmin(), api.RepairAllRepos)\n\t\t\trepoBase := repo.Group(\"/:repo_id\")\n\t\t\t{\n\t\t\t\trepoBase.Use(session.SetRepo())\n\t\t\t\trepoBase.Use(session.SetPerm())\n\n\t\t\t\trepoBase.GET(\"/permissions\", api.GetRepoPermissions)\n\n\t\t\t\trepo := repoBase.Group(\"\")\n\t\t\t\t{\n\t\t\t\t\trepo.Use(session.MustPull)\n\n\t\t\t\t\trepo.GET(\"\", api.GetRepo)\n\n\t\t\t\t\trepo.GET(\"/branches\", api.GetRepoBranches)\n\t\t\t\t\trepo.GET(\"/pull_requests\", api.GetRepoPullRequests)\n\n\t\t\t\t\trepo.GET(\"/pipelines\", api.GetPipelines)\n\t\t\t\t\trepo.POST(\"/pipelines\", session.MustPush, api.CreatePipeline)\n\t\t\t\t\trepo.DELETE(\"/pipelines/:pipeline_number\", session.MustRepoAdmin(), api.DeletePipeline)\n\t\t\t\t\trepo.GET(\"/pipelines/:pipeline_number\", api.GetPipeline)\n\t\t\t\t\trepo.GET(\"/pipelines/:pipeline_number/config\", api.GetPipelineConfig)\n\t\t\t\t\trepo.GET(\"/pipelines/:pipeline_number/metadata\", session.MustPush, api.GetPipelineMetadata)\n\n\t\t\t\t\t// requires push permissions\n\t\t\t\t\trepo.POST(\"/pipelines/:pipeline_number\", session.MustPush, api.PostPipeline)\n\t\t\t\t\trepo.POST(\"/pipelines/:pipeline_number/cancel\", session.MustPush, api.CancelPipeline)\n\t\t\t\t\trepo.POST(\"/pipelines/:pipeline_number/approve\", session.MustPush, api.PostApproval)\n\t\t\t\t\trepo.POST(\"/pipelines/:pipeline_number/decline\", session.MustPush, api.PostDecline)\n\n\t\t\t\t\trepo.GET(\"/logs/:pipeline_number/:step_id\", api.GetStepLogs)\n\t\t\t\t\trepo.DELETE(\"/logs/:pipeline_number/:step_id\", session.MustPush, api.DeleteStepLogs)\n\n\t\t\t\t\t// requires push permissions\n\t\t\t\t\trepo.DELETE(\"/logs/:pipeline_number\", session.MustPush, api.DeletePipelineLogs)\n\n\t\t\t\t\t// requires push permissions\n\t\t\t\t\trepo.GET(\"/secrets\", session.MustPush, api.GetSecretList)\n\t\t\t\t\trepo.POST(\"/secrets\", session.MustPush, api.PostSecret)\n\t\t\t\t\trepo.GET(\"/secrets/:secret\", session.MustPush, api.GetSecret)\n\t\t\t\t\trepo.PATCH(\"/secrets/:secret\", session.MustPush, api.PatchSecret)\n\t\t\t\t\trepo.DELETE(\"/secrets/:secret\", session.MustPush, api.DeleteSecret)\n\n\t\t\t\t\t// requires push permissions\n\t\t\t\t\trepo.GET(\"/registries\", session.MustPush, api.GetRegistryList)\n\t\t\t\t\trepo.POST(\"/registries\", session.MustPush, api.PostRegistry)\n\t\t\t\t\trepo.GET(\"/registries/:registry\", session.MustPush, api.GetRegistry)\n\t\t\t\t\trepo.PATCH(\"/registries/:registry\", session.MustPush, api.PatchRegistry)\n\t\t\t\t\trepo.DELETE(\"/registries/:registry\", session.MustPush, api.DeleteRegistry)\n\n\t\t\t\t\t// requires push permissions\n\t\t\t\t\trepo.GET(\"/cron\", session.MustPush, api.GetCronList)\n\t\t\t\t\trepo.POST(\"/cron\", session.MustPush, api.PostCron)\n\t\t\t\t\trepo.GET(\"/cron/:cron\", session.MustPush, api.GetCron)\n\t\t\t\t\trepo.POST(\"/cron/:cron\", session.MustPush, api.RunCron)\n\t\t\t\t\trepo.PATCH(\"/cron/:cron\", session.MustPush, api.PatchCron)\n\t\t\t\t\trepo.DELETE(\"/cron/:cron\", session.MustPush, api.DeleteCron)\n\n\t\t\t\t\t// requires admin permissions\n\t\t\t\t\trepo.PATCH(\"\", session.MustRepoAdmin(), api.PatchRepo)\n\t\t\t\t\trepo.DELETE(\"\", session.MustRepoAdmin(), api.DeleteRepo)\n\t\t\t\t\trepo.POST(\"/chown\", session.MustRepoAdmin(), api.ChownRepo)\n\t\t\t\t\trepo.POST(\"/repair\", session.MustRepoAdmin(), api.RepairRepo)\n\t\t\t\t\trepo.POST(\"/move\", session.MustRepoAdmin(), api.MoveRepo)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tbadges := apiBase.Group(\"/badges/:repo_id_or_owner\")\n\t\t{\n\t\t\tbadges.GET(\"/status.svg\", api.GetBadge)\n\t\t\tbadges.GET(\"/cc.xml\", api.GetCC)\n\t\t}\n\n\t\t_badges := apiBase.Group(\"/badges/:repo_id_or_owner/:repo_name\")\n\t\t{\n\t\t\t_badges.GET(\"/status.svg\", api.GetBadge)\n\t\t\t_badges.GET(\"/cc.xml\", api.GetCC)\n\t\t}\n\n\t\tpipelines := apiBase.Group(\"/pipelines\")\n\t\t{\n\t\t\tpipelines.Use(session.MustAdmin())\n\t\t\tpipelines.GET(\"\", api.GetPipelineQueue)\n\t\t}\n\n\t\tqueue := apiBase.Group(\"/queue\")\n\t\t{\n\t\t\tqueue.Use(session.MustAdmin())\n\t\t\tqueue.GET(\"/info\", api.GetQueueInfo)\n\t\t\tqueue.POST(\"/pause\", api.PauseQueue)\n\t\t\tqueue.POST(\"/resume\", api.ResumeQueue)\n\t\t\tqueue.GET(\"/norunningpipelines\", api.BlockTilQueueHasRunningItem)\n\t\t}\n\n\t\t// global secrets can be read without actual values by any user\n\t\treadGlobalSecrets := apiBase.Group(\"/secrets\")\n\t\t{\n\t\t\treadGlobalSecrets.Use(session.MustUser())\n\t\t\treadGlobalSecrets.GET(\"\", api.GetGlobalSecretList)\n\t\t\treadGlobalSecrets.GET(\"/:secret\", api.GetGlobalSecret)\n\t\t}\n\t\tsecrets := apiBase.Group(\"/secrets\")\n\t\t{\n\t\t\tsecrets.Use(session.MustAdmin())\n\t\t\tsecrets.POST(\"\", api.PostGlobalSecret)\n\t\t\tsecrets.PATCH(\"/:secret\", api.PatchGlobalSecret)\n\t\t\tsecrets.DELETE(\"/:secret\", api.DeleteGlobalSecret)\n\t\t}\n\n\t\t// global registries can be read without actual values by any user\n\t\treadGlobalRegistries := apiBase.Group(\"/registries\")\n\t\t{\n\t\t\treadGlobalRegistries.Use(session.MustUser())\n\t\t\treadGlobalRegistries.GET(\"\", api.GetGlobalRegistryList)\n\t\t\treadGlobalRegistries.GET(\"/:registry\", api.GetGlobalRegistry)\n\t\t}\n\t\tregistries := apiBase.Group(\"/registries\")\n\t\t{\n\t\t\tregistries.Use(session.MustAdmin())\n\t\t\tregistries.POST(\"\", api.PostGlobalRegistry)\n\t\t\tregistries.PATCH(\"/:registry\", api.PatchGlobalRegistry)\n\t\t\tregistries.DELETE(\"/:registry\", api.DeleteGlobalRegistry)\n\t\t}\n\n\t\tlogLevel := apiBase.Group(\"/log-level\")\n\t\t{\n\t\t\tlogLevel.Use(session.MustAdmin())\n\t\t\tlogLevel.GET(\"\", api.LogLevel)\n\t\t\tlogLevel.POST(\"\", api.SetLogLevel)\n\t\t}\n\n\t\tagentBase := apiBase.Group(\"/agents\")\n\t\t{\n\t\t\tagentBase.Use(session.MustAdmin())\n\t\t\tagentBase.GET(\"\", api.GetAgents)\n\t\t\tagentBase.POST(\"\", api.PostAgent)\n\t\t\tagentBase.GET(\"/:agent_id\", api.GetAgent)\n\t\t\tagentBase.GET(\"/:agent_id/tasks\", api.GetAgentTasks)\n\t\t\tagentBase.PATCH(\"/:agent_id\", api.PatchAgent)\n\t\t\tagentBase.DELETE(\"/:agent_id\", api.DeleteAgent)\n\t\t}\n\n\t\tapiBase.GET(\"/forges\", api.GetForges)\n\t\tapiBase.GET(\"/forges/:forge_id\", api.GetForge)\n\t\tforgeBase := apiBase.Group(\"/forges\")\n\t\t{\n\t\t\tforgeBase.Use(session.MustAdmin())\n\t\t\tforgeBase.POST(\"\", api.PostForge)\n\t\t\tforgeBase.PATCH(\"/:forge_id\", api.PatchForge)\n\t\t\tforgeBase.DELETE(\"/:forge_id\", api.DeleteForge)\n\t\t}\n\n\t\tapiBase.GET(\"/signature/public-key\", api.GetSignaturePublicKey)\n\n\t\tapiBase.POST(\"/hook\", api.PostHook)\n\n\t\tstream := apiBase.Group(\"/stream\")\n\t\t{\n\t\t\tstream.GET(\"/logs/:repo_id/:pipeline/:step_id\",\n\t\t\t\tsession.SetRepo(),\n\t\t\t\tsession.SetPerm(),\n\t\t\t\tsession.MustPull,\n\t\t\t\tapi.LogStreamSSE)\n\t\t\tstream.GET(\"/events\", api.EventStreamSSE)\n\t\t}\n\n\t\tif zerolog.GlobalLevel() <= zerolog.DebugLevel {\n\t\t\tdebugger := apiBase.Group(\"/debug\")\n\t\t\t{\n\t\t\t\tdebugger.Use(session.MustAdmin())\n\t\t\t\tdebugger.GET(\"/pprof/\", debug.IndexHandler())\n\t\t\t\tdebugger.GET(\"/pprof/heap\", debug.HeapHandler())\n\t\t\t\tdebugger.GET(\"/pprof/goroutine\", debug.GoroutineHandler())\n\t\t\t\tdebugger.GET(\"/pprof/block\", debug.BlockHandler())\n\t\t\t\tdebugger.GET(\"/pprof/threadcreate\", debug.ThreadCreateHandler())\n\t\t\t\tdebugger.GET(\"/pprof/cmdline\", debug.CmdlineHandler())\n\t\t\t\tdebugger.GET(\"/pprof/profile\", debug.ProfileHandler())\n\t\t\t\tdebugger.GET(\"/pprof/symbol\", debug.SymbolHandler())\n\t\t\t\tdebugger.POST(\"/pprof/symbol\", debug.SymbolHandler())\n\t\t\t\tdebugger.GET(\"/pprof/trace\", debug.TraceHandler())\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/header/header.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage header\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// NoCache is a middleware function that appends headers\n// to prevent the client from caching the HTTP response.\nfunc NoCache(c *gin.Context) {\n\tc.Header(\"Cache-Control\", \"no-cache, no-store, max-age=0, must-revalidate, value\")\n\tc.Header(\"Expires\", \"Thu, 01 Jan 1970 00:00:00 GMT\")\n\tc.Header(\"Last-Modified\", time.Now().UTC().Format(http.TimeFormat))\n\tc.Next()\n}\n\n// Options is a middleware function that appends headers\n// for options requests and aborts then exits the middleware\n// chain and ends the request.\nfunc Options(c *gin.Context) {\n\tif c.Request.Method != \"OPTIONS\" {\n\t\tc.Next()\n\t} else {\n\t\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\t\tc.Header(\"Access-Control-Allow-Methods\", \"GET,POST,PUT,PATCH,DELETE,OPTIONS\")\n\t\tc.Header(\"Access-Control-Allow-Headers\", \"authorization, origin, content-type, accept\")\n\t\tc.Header(\"Allow\", \"HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS\")\n\t\tc.Header(\"Content-Type\", \"application/json\")\n\t\tc.AbortWithStatus(http.StatusOK)\n\t}\n}\n\n// Secure is a middleware function that appends security\n// and resource access headers.\nfunc Secure(c *gin.Context) {\n\tc.Header(\"Access-Control-Allow-Origin\", \"*\")\n\tc.Header(\"X-Frame-Options\", \"DENY\")\n\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\tc.Header(\"X-XSS-Protection\", \"1; mode=block\")\n\tif c.Request.TLS != nil {\n\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000\")\n\t}\n\n\t// Also consider adding Content-Security-Policy headers\n\t// c.Header(\"Content-Security-Policy\", \"script-src 'self' https://cdnjs.cloudflare.com\")\n}\n"
  },
  {
    "path": "server/router/middleware/logger.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage middleware\n\nimport (\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n)\n\n// Logger returns a gin.HandlerFunc (middleware) that logs requests using zerolog.\n//\n// Requests with errors are logged using log.Err().\n// Requests without errors are logged using log.Info().\n//\n// It receives:\n//  1. A time package format string (e.g. time.RFC3339).\n//  2. A boolean stating whether to use UTC time zone or local.\nfunc Logger(timeFormat string, utc bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\t// some evil middlewares modify this values\n\t\tpath := c.Request.URL.Path\n\t\tc.Next()\n\n\t\tend := time.Now()\n\t\tlatency := end.Sub(start)\n\t\tif utc {\n\t\t\tend = end.UTC()\n\t\t}\n\n\t\tentry := map[string]any{\n\t\t\t\"status\":     c.Writer.Status(),\n\t\t\t\"method\":     c.Request.Method,\n\t\t\t\"path\":       path,\n\t\t\t\"ip\":         c.ClientIP(),\n\t\t\t\"latency\":    latency,\n\t\t\t\"user-agent\": c.Request.UserAgent(),\n\t\t\t\"time\":       end.Format(timeFormat),\n\t\t}\n\n\t\tif len(c.Errors) > 0 {\n\t\t\t// Append error field if this is an erroneous request.\n\t\t\tlog.Error().Str(\"error\", c.Errors.String()).Fields(entry).Msg(\"\")\n\t\t} else {\n\t\t\tlog.Debug().Fields(entry).Msg(\"\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/session/agent.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\n// AuthorizeAgent authorizes requests from agent to access the queue.\nfunc AuthorizeAgent(c *gin.Context) {\n\tsecret, _ := c.MustGet(\"agent\").(string)\n\tif secret == \"\" {\n\t\tc.String(http.StatusUnauthorized, \"invalid or empty token.\")\n\t\treturn\n\t}\n\n\t_, err := token.ParseRequest([]token.Type{token.AgentToken}, c.Request, func(_ *token.Token) (string, error) {\n\t\treturn secret, nil\n\t})\n\tif err != nil {\n\t\tc.String(http.StatusInternalServerError, \"invalid or empty token. %s\", err)\n\t\tc.Abort()\n\t\treturn\n\t}\n\n\tc.Next()\n}\n"
  },
  {
    "path": "server/router/middleware/session/org.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc Org(c *gin.Context) *model.Org {\n\tv, ok := c.Get(\"org\")\n\tif !ok {\n\t\treturn nil\n\t}\n\tr, ok := v.(*model.Org)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn r\n}\n\nfunc SetOrg() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar (\n\t\t\torgID int64\n\t\t\terr   error\n\t\t)\n\n\t\torgParam := c.Param(\"org_id\")\n\t\tif orgParam != \"\" {\n\t\t\torgID, err = strconv.ParseInt(orgParam, 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tc.String(http.StatusBadRequest, \"Invalid organization ID\")\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\torg, err := store.FromContext(c).OrgGet(orgID)\n\t\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\treturn\n\t\t}\n\n\t\tif org == nil {\n\t\t\tc.String(http.StatusNotFound, \"Organization not found\")\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Set(\"org\", org)\n\t\tc.Next()\n\t}\n}\n\nfunc MustOrg() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\torg := Org(c)\n\t\tswitch org {\n\t\tcase nil:\n\t\t\tc.String(http.StatusNotFound, \"Organization not loaded\")\n\t\t\tc.Abort()\n\t\tdefault:\n\t\t\tc.Next()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/session/pagination.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst maxPageSize = 50\n\nfunc Pagination(c *gin.Context) *model.ListOptions {\n\tpage, err := strconv.ParseInt(c.Query(\"page\"), 10, 64)\n\tif err != nil || page < 1 {\n\t\tpage = 1\n\t}\n\tperPage, err := strconv.ParseInt(c.Query(\"perPage\"), 10, 64)\n\tif err != nil || perPage < 1 || perPage > maxPageSize {\n\t\tperPage = maxPageSize\n\t}\n\treturn &model.ListOptions{\n\t\tPage:    int(page),\n\t\tPerPage: int(perPage),\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/session/repo.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc Repo(c *gin.Context) *model.Repo {\n\tv, ok := c.Get(\"repo\")\n\tif !ok {\n\t\treturn nil\n\t}\n\tr, ok := v.(*model.Repo)\n\tif !ok {\n\t\treturn nil\n\t}\n\tr.Perm = Perm(c)\n\treturn r\n}\n\nfunc SetRepo() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar (\n\t\t\t_store   = store.FromContext(c)\n\t\t\tfullName = strings.TrimLeft(c.Param(\"repo_full_name\"), \"/\")\n\t\t\t_repoID  = c.Param(\"repo_id\")\n\t\t\tuser     = User(c)\n\t\t)\n\n\t\tvar repo *model.Repo\n\t\tvar err error\n\t\tif _repoID != \"\" {\n\t\t\tvar repoID int64\n\t\t\trepoID, err = strconv.ParseInt(_repoID, 10, 64)\n\t\t\tif err != nil {\n\t\t\t\tc.AbortWithStatus(http.StatusBadRequest)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trepo, err = _store.GetRepo(repoID)\n\t\t} else {\n\t\t\trepo, err = _store.GetRepoName(fullName)\n\t\t}\n\n\t\tif repo != nil && err == nil {\n\t\t\tc.Set(\"repo\", repo)\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// debugging\n\t\tlog.Debug().Err(err).Msgf(\"cannot find repository %s\", fullName)\n\n\t\tif user == nil {\n\t\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\n\t\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t}\n}\n\nfunc Perm(c *gin.Context) *model.Perm {\n\tv, ok := c.Get(\"perm\")\n\tif !ok {\n\t\treturn nil\n\t}\n\tu, ok := v.(*model.Perm)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn u\n}\n\nfunc SetPerm() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t_store := store.FromContext(c)\n\t\tuser := User(c)\n\t\trepo := Repo(c)\n\t\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"Cannot get forge from repo\")\n\t\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tperm := new(model.Perm)\n\n\t\tif user != nil {\n\t\t\tvar err error\n\t\t\tperm, err = _store.PermFind(user, repo)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"error fetching permission for %s %s\",\n\t\t\t\t\tuser.Login, repo.FullName)\n\t\t\t}\n\t\t\tif time.Unix(perm.Synced, 0).Add(time.Hour).Before(time.Now()) {\n\t\t\t\t_repo, err := _forge.Repo(c, user, repo.ForgeRemoteID, repo.Owner, repo.Name)\n\t\t\t\tif err == nil {\n\t\t\t\t\tlog.Debug().Msgf(\"synced user permission for %s %s\", user.Login, repo.FullName)\n\t\t\t\t\t_repo.ForgeID = user.ForgeID\n\t\t\t\t\tperm = _repo.Perm\n\t\t\t\t\tperm.RepoID = repo.ID\n\t\t\t\t\tperm.UserID = user.ID\n\t\t\t\t\tperm.Synced = time.Now().Unix()\n\t\t\t\t\tif err := _store.PermUpsert(perm); err != nil {\n\t\t\t\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif perm == nil {\n\t\t\tperm = new(model.Perm)\n\t\t}\n\n\t\tif user != nil && user.Admin {\n\t\t\tperm.Pull = true\n\t\t\tperm.Push = true\n\t\t\tperm.Admin = true\n\t\t}\n\n\t\tif repo.Visibility == model.VisibilityPublic || (repo.Visibility == model.VisibilityInternal && user != nil) {\n\t\t\tperm.Pull = true\n\t\t}\n\n\t\tif user != nil {\n\t\t\tlog.Debug().Msgf(\"%s granted %+v permission to %s\",\n\t\t\t\tuser.Login, perm, repo.FullName)\n\t\t} else {\n\t\t\tlog.Debug().Msgf(\"guest granted %+v to %s\", perm, repo.FullName)\n\t\t}\n\n\t\tc.Set(\"perm\", perm)\n\t\tc.Next()\n\t}\n}\n\nfunc MustPull(c *gin.Context) {\n\tuser := User(c)\n\tperm := Perm(c)\n\n\tif perm.Pull {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\t// debugging\n\tif user != nil {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\tlog.Debug().Msgf(\"user %s denied read access to %s\",\n\t\t\tuser.Login, c.Request.URL.Path)\n\t} else {\n\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\tlog.Debug().Msgf(\"guest denied read access to %s %s\",\n\t\t\tc.Request.Method,\n\t\t\tc.Request.URL.Path,\n\t\t)\n\t}\n}\n\nfunc MustPush(c *gin.Context) {\n\tuser := User(c)\n\tperm := Perm(c)\n\n\t// if the user has push access, immediately proceed\n\t// the middleware execution chain.\n\tif perm.Push {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\t// debugging\n\tif user != nil {\n\t\tc.AbortWithStatus(http.StatusNotFound)\n\t\tlog.Debug().Msgf(\"user %s denied write access to %s\",\n\t\t\tuser.Login, c.Request.URL.Path)\n\t} else {\n\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\tlog.Debug().Msgf(\"guest denied write access to %s %s\",\n\t\t\tc.Request.Method,\n\t\t\tc.Request.URL.Path,\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/session/user.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\nfunc User(c *gin.Context) *model.User {\n\tv, ok := c.Get(\"user\")\n\tif !ok {\n\t\treturn nil\n\t}\n\tu, ok := v.(*model.User)\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn u\n}\n\nfunc SetUser() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar user *model.User\n\n\t\tt, err := token.ParseRequest([]token.Type{token.UserToken, token.SessToken}, c.Request, func(t *token.Token) (string, error) {\n\t\t\tvar err error\n\t\t\tuserID, err := strconv.ParseInt(t.Get(\"user-id\"), 10, 64)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\", err\n\t\t\t}\n\t\t\tuser, err = store.FromContext(c).GetUser(userID)\n\t\t\treturn user.Hash, err\n\t\t})\n\t\tif err == nil {\n\t\t\tc.Set(\"user\", user)\n\n\t\t\t// if this is a session token (ie not the API token)\n\t\t\t// this means the user is accessing with a web browser,\n\t\t\t// so we should implement CSRF protection measures.\n\t\t\tif t.Type == token.SessToken {\n\t\t\t\terr = token.CheckCsrf(c.Request, func(_ *token.Token) (string, error) {\n\t\t\t\t\treturn user.Hash, nil\n\t\t\t\t})\n\t\t\t\t// if csrf token validation fails, exit immediately\n\t\t\t\t// with a not authorized error.\n\t\t\t\tif err != nil {\n\t\t\t\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tc.Next()\n\t}\n}\n\nfunc MustAdmin() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tuser := User(c)\n\t\tswitch {\n\t\tcase user == nil:\n\t\t\tc.String(http.StatusUnauthorized, \"User not authorized\")\n\t\t\tc.Abort()\n\t\tcase !user.Admin:\n\t\t\tc.String(http.StatusForbidden, \"User not authorized\")\n\t\t\tc.Abort()\n\t\tdefault:\n\t\t\tc.Next()\n\t\t}\n\t}\n}\n\nfunc MustRepoAdmin() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tuser := User(c)\n\t\tperm := Perm(c)\n\t\tswitch {\n\t\tcase user == nil:\n\t\t\tc.String(http.StatusUnauthorized, \"User not authorized\")\n\t\t\tc.Abort()\n\t\tcase !perm.Admin:\n\t\t\tc.String(http.StatusForbidden, \"User not authorized\")\n\t\t\tc.Abort()\n\t\tdefault:\n\t\t\tc.Next()\n\t\t}\n\t}\n}\n\nfunc MustUser() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tuser := User(c)\n\t\tswitch user {\n\t\tcase nil:\n\t\t\tc.String(http.StatusUnauthorized, \"User not authorized\")\n\t\t\tc.Abort()\n\t\tdefault:\n\t\t\tc.Next()\n\t\t}\n\t}\n}\n\nfunc MustOrgMember(admin bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tuser := User(c)\n\t\tif user == nil {\n\t\t\tc.String(http.StatusUnauthorized, \"User not authorized\")\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\torg := Org(c)\n\t\tif org == nil {\n\t\t\tc.String(http.StatusBadRequest, \"Organization not loaded\")\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// User can access his own, admin can access all\n\t\tif (org.Name == user.Login) || user.Admin {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"Cannot get forge from user\")\n\t\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tperm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"failed to check membership\")\n\t\t\tc.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif perm == nil || (!admin && !perm.Member) || (admin && !perm.Admin) {\n\t\t\tc.String(http.StatusForbidden, \"user not authorized\")\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/store.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\n// Store is a middleware function that initializes the Datastore and attaches to\n// the context of every http.Request.\nfunc Store(v store.Store) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstore.ToContext(c, v)\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "server/router/middleware/token/token.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage token\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nfunc Refresh(c *gin.Context) {\n\tuser := session.User(c)\n\tif user != nil {\n\t\t_forge, err := server.Config.Services.Manager.ForgeFromUser(user)\n\t\tif err != nil {\n\t\t\t_ = c.AbortWithError(http.StatusInternalServerError, err)\n\t\t\treturn\n\t\t}\n\n\t\tforge.Refresh(c, _forge, store.FromContext(c), user)\n\t}\n\n\tc.Next()\n}\n"
  },
  {
    "path": "server/router/middleware/version.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// Version is a middleware function that appends the Woodpecker version information\n// to the HTTP response. This is intended for debugging and troubleshooting.\nfunc Version(c *gin.Context) {\n\tc.Header(\"X-WOODPECKER-VERSION\", version.String())\n}\n"
  },
  {
    "path": "server/router/router.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage router\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\tswaggo_files \"github.com/swaggo/files\"\n\tswaggo_gin_swagger \"github.com/swaggo/gin-swagger\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/api/metrics\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/header\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/token\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/web\"\n)\n\n// Load loads the router.\nfunc Load(noRouteHandler http.HandlerFunc, middleware ...gin.HandlerFunc) http.Handler {\n\te := gin.New()\n\te.UseRawPath = true\n\te.Use(gin.Recovery())\n\n\te.Use(func(c *gin.Context) {\n\t\tlog.Trace().Msgf(\"[%s] %s\", c.Request.Method, c.Request.URL.String())\n\t\tc.Next()\n\t})\n\n\te.Use(header.NoCache)\n\te.Use(header.Options)\n\te.Use(header.Secure)\n\te.Use(middleware...)\n\te.Use(session.SetUser())\n\te.Use(token.Refresh)\n\n\te.NoRoute(gin.WrapF(noRouteHandler))\n\n\tbase := e.Group(server.Config.Server.RootPath)\n\t{\n\t\tbase.GET(\"/web-config.js\", web.Config)\n\n\t\tbase.GET(\"/logout\", api.GetLogout)\n\t\tauth := base.Group(\"/authorize\")\n\t\t{\n\t\t\tauth.GET(\"\", api.HandleAuth)\n\t\t\tauth.POST(\"\", api.HandleAuth)\n\t\t}\n\n\t\tbase.GET(\"/metrics\", metrics.PromHandler())\n\t\tbase.GET(\"/version\", api.Version)\n\t\tbase.GET(\"/healthz\", api.Health)\n\t}\n\n\tapiRoutes(base)\n\tif server.Config.WebUI.EnableSwagger {\n\t\tsetupSwaggerConfigAndRoutes(e)\n\t}\n\n\treturn e\n}\n\nfunc setupSwaggerConfigAndRoutes(e *gin.Engine) {\n\topenapi.SwaggerInfo.Host = getHost(server.Config.Server.Host)\n\topenapi.SwaggerInfo.BasePath = server.Config.Server.RootPath + \"/api\"\n\te.GET(server.Config.Server.RootPath+\"/swagger/*any\", swaggo_gin_swagger.WrapHandler(swaggo_files.Handler))\n}\n\nfunc getHost(s string) string {\n\tparse, _ := url.Parse(s)\n\treturn parse.Host\n}\n"
  },
  {
    "path": "server/rpc/auth_server.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\ntype WoodpeckerAuthServer struct {\n\tproto.UnimplementedWoodpeckerAuthServer\n\tjwtManager       *JWTManager\n\tagentMasterToken string\n\tstore            store.Store\n}\n\nfunc NewWoodpeckerAuthServer(jwtManager *JWTManager, agentMasterToken string, store store.Store) *WoodpeckerAuthServer {\n\treturn &WoodpeckerAuthServer{jwtManager: jwtManager, agentMasterToken: agentMasterToken, store: store}\n}\n\nfunc (s *WoodpeckerAuthServer) Auth(_ context.Context, req *proto.AuthRequest) (*proto.AuthResponse, error) {\n\tagent, err := s.getAgent(req.AgentId, req.AgentToken)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"agent could not auth: %w\", err)\n\t}\n\n\taccessToken, err := s.jwtManager.Generate(agent.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &proto.AuthResponse{\n\t\tStatus:      \"ok\",\n\t\tAgentId:     agent.ID,\n\t\tAccessToken: accessToken,\n\t}, nil\n}\n\nfunc (s *WoodpeckerAuthServer) getAgent(agentID int64, agentToken string) (*model.Agent, error) {\n\t// global agent secret auth\n\tif s.agentMasterToken != \"\" {\n\t\tif agentToken == s.agentMasterToken && agentID == -1 {\n\t\t\tagent := &model.Agent{\n\t\t\t\tOwnerID:  model.IDNotSet,\n\t\t\t\tOrgID:    model.IDNotSet,\n\t\t\t\tToken:    s.agentMasterToken,\n\t\t\t\tCapacity: -1,\n\t\t\t}\n\t\t\terr := s.store.AgentCreate(agent)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"error creating system agent\")\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn agent, nil\n\t\t}\n\n\t\tif agentToken == s.agentMasterToken {\n\t\t\tagent, err := s.store.AgentFind(agentID)\n\t\t\tif err != nil {\n\t\t\t\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\t\t\t\treturn nil, fmt.Errorf(\"AgentID not found in database\")\n\t\t\t\t} else {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !agent.IsSystemAgent() {\n\t\t\t\treturn nil, fmt.Errorf(\"the agent with this ID is not a system agent\")\n\t\t\t}\n\t\t\treturn agent, nil\n\t\t}\n\t}\n\n\t// individual agent token auth\n\tagent, err := s.store.AgentFindByToken(agentToken)\n\tif err != nil && errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn nil, fmt.Errorf(\"individual agent not found by token: %w\", err)\n\t}\n\treturn agent, err\n}\n"
  },
  {
    "path": "server/rpc/auth_server_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// newAuthServer is a test helper that wires up a WoodpeckerAuthServer with the\n// given master token and a mock store, then returns both so tests can set\n// expectations before calling Auth / getAgent.\nfunc newAuthServer(t *testing.T, masterToken string, store *store_mocks.MockStore) *WoodpeckerAuthServer {\n\tt.Helper()\n\tjwtManager := NewJWTManager(\"test-secret\")\n\treturn NewWoodpeckerAuthServer(jwtManager, masterToken, store)\n}\n\nfunc TestAuth(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"master token with agentID=-1 creates new system agent and returns access token\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentCreate\", &model.Agent{\n\t\t\tOwnerID:  model.IDNotSet,\n\t\t\tOrgID:    model.IDNotSet,\n\t\t\tToken:    \"master-secret\",\n\t\t\tCapacity: -1,\n\t\t}).Return(nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master-secret\", store)\n\t\tresp, err := srv.Auth(t.Context(), &proto.AuthRequest{\n\t\t\tAgentId:    -1,\n\t\t\tAgentToken: \"master-secret\",\n\t\t})\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"ok\", resp.Status)\n\t\tassert.NotEmpty(t, resp.AccessToken)\n\t\t// The newly created agent has ID 0 (zero-value) because AgentCreate\n\t\t// doesn't set it in the mock – verify the token at least round-trips.\n\t\tclaims, verifyErr := NewJWTManager(\"test-secret\").Verify(resp.AccessToken)\n\t\trequire.NoError(t, verifyErr)\n\t\tassert.Equal(t, resp.AgentId, claims.AgentID)\n\t})\n\n\tt.Run(\"master token with existing agentID returns access token for that agent\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\texistingAgent := &model.Agent{\n\t\t\tID:      42,\n\t\t\tOrgID:   model.IDNotSet, // system agent\n\t\t\tOwnerID: model.IDNotSet,\n\t\t}\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFind\", int64(42)).Return(existingAgent, nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master-secret\", store)\n\t\tresp, err := srv.Auth(t.Context(), &proto.AuthRequest{\n\t\t\tAgentId:    42,\n\t\t\tAgentToken: \"master-secret\",\n\t\t})\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"ok\", resp.Status)\n\t\tassert.EqualValues(t, 42, resp.AgentId)\n\t\tassert.NotEmpty(t, resp.AccessToken)\n\t})\n\n\tt.Run(\"individual agent token authenticates successfully\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tagent := &model.Agent{ID: 7, Token: \"individual-token\"}\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFindByToken\", \"individual-token\").Return(agent, nil).Once()\n\n\t\t// no master token configured\n\t\tsrv := newAuthServer(t, \"\", store)\n\t\tresp, err := srv.Auth(t.Context(), &proto.AuthRequest{\n\t\t\tAgentId:    0,\n\t\t\tAgentToken: \"individual-token\",\n\t\t})\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"ok\", resp.Status)\n\t\tassert.EqualValues(t, 7, resp.AgentId)\n\t})\n\n\tt.Run(\"bad token returns error\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFindByToken\", \"wrong-token\").\n\t\t\tReturn(nil, types.ErrRecordNotExist).Once()\n\n\t\tsrv := newAuthServer(t, \"\", store)\n\t\t_, err := srv.Auth(t.Context(), &proto.AuthRequest{\n\t\t\tAgentToken: \"wrong-token\",\n\t\t})\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"agent could not auth\")\n\t})\n}\n\nfunc TestGetAgent(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"master token + agentID=-1 creates and returns a new system agent\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentCreate\", &model.Agent{\n\t\t\tOwnerID:  model.IDNotSet,\n\t\t\tOrgID:    model.IDNotSet,\n\t\t\tToken:    \"master\",\n\t\t\tCapacity: -1,\n\t\t}).Return(nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\tagent, err := srv.getAgent(-1, \"master\")\n\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, agent)\n\t\tassert.Equal(t, \"master\", agent.Token)\n\t\tassert.EqualValues(t, model.IDNotSet, agent.OrgID)\n\t})\n\n\tt.Run(\"master token + agentID=-1 propagates AgentCreate error\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentCreate\", &model.Agent{\n\t\t\tOwnerID:  model.IDNotSet,\n\t\t\tOrgID:    model.IDNotSet,\n\t\t\tToken:    \"master\",\n\t\t\tCapacity: -1,\n\t\t}).Return(errors.New(\"db error\")).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\t_, err := srv.getAgent(-1, \"master\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"db error\")\n\t})\n\n\tt.Run(\"master token + existing agentID returns the stored agent\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tsystemAgent := &model.Agent{ID: 99, OrgID: model.IDNotSet, OwnerID: model.IDNotSet}\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFind\", int64(99)).Return(systemAgent, nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\tagent, err := srv.getAgent(99, \"master\")\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(99), agent.ID)\n\t})\n\n\tt.Run(\"master token + agentID not found in database returns error\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFind\", int64(404)).Return(nil, types.ErrRecordNotExist).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\t_, err := srv.getAgent(404, \"master\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"AgentID not found in database\")\n\t})\n\n\tt.Run(\"master token + agentID store returns unexpected error is propagated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFind\", int64(1)).Return(nil, errors.New(\"connection reset\")).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\t_, err := srv.getAgent(1, \"master\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"connection reset\")\n\t})\n\n\tt.Run(\"master token + agentID that is not a system agent returns error\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\t// An agent with a non-IDNotSet OrgID is not a system agent.\n\t\torgAgent := &model.Agent{ID: 5, OrgID: 100, OwnerID: model.IDNotSet}\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFind\", int64(5)).Return(orgAgent, nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\t_, err := srv.getAgent(5, \"master\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not a system agent\")\n\t})\n\n\tt.Run(\"individual token auth succeeds when token is found\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tagent := &model.Agent{ID: 3, Token: \"ind-token\"}\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFindByToken\", \"ind-token\").Return(agent, nil).Once()\n\n\t\t// No master token set – falls straight to individual auth.\n\t\tsrv := newAuthServer(t, \"\", store)\n\t\tgot, err := srv.getAgent(0, \"ind-token\")\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(3), got.ID)\n\t})\n\n\tt.Run(\"individual token not found returns wrapped error\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFindByToken\", \"bad-token\").\n\t\t\tReturn(nil, types.ErrRecordNotExist).Once()\n\n\t\tsrv := newAuthServer(t, \"\", store)\n\t\t_, err := srv.getAgent(0, \"bad-token\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"individual agent not found by token\")\n\t})\n\n\tt.Run(\"individual token store returns unexpected error is propagated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstore.On(\"AgentFindByToken\", \"token\").\n\t\t\tReturn(nil, errors.New(\"timeout\")).Once()\n\n\t\tsrv := newAuthServer(t, \"\", store)\n\t\t_, err := srv.getAgent(0, \"token\")\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"timeout\")\n\t})\n\n\tt.Run(\"master token configured but wrong token falls through to individual auth\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tagent := &model.Agent{ID: 8, Token: \"ind-token\"}\n\t\tstore := store_mocks.NewMockStore(t)\n\t\t// master token is \"master\" but caller sends \"ind-token\" → individual path\n\t\tstore.On(\"AgentFindByToken\", \"ind-token\").Return(agent, nil).Once()\n\n\t\tsrv := newAuthServer(t, \"master\", store)\n\t\tgot, err := srv.getAgent(0, \"ind-token\")\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(8), got.ID)\n\t})\n}\n"
  },
  {
    "path": "server/rpc/authorizer.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package grpc provides gRPC server implementation with JWT-based authentication.\n//\n// # Authentication Flow\n//\n// Uses a two-token approach:\n//\n//  1. Agent Token (long-lived): Configured via WOODPECKER_AGENT_SECRET, used only for initial Auth() call\n//  2. JWT Access Token (short-lived, 1 hour): Obtained from Auth(), included in metadata[\"token\"] for all service calls\n//\n// # Interceptor Architecture\n//\n// Authorizer interceptors validate JWT tokens on every request:\n//  1. Extract JWT from metadata[\"token\"]\n//  2. Verify signature and expiration\n//  3. Extract agent_id from JWT claims and store in context\n//\n// Auth endpoint (/proto.WoodpeckerAuth/Auth) bypasses validation to allow initial authentication.\n//\n// # Usage\n//\n//\t// Server setup\n//\tjwtManager := NewJWTManager(c.String(\"grpc-secret\"))\n//\tauthorizer := NewAuthorizer(jwtManager)\n//\tgrpcServer := grpc.NewServer(\n//\t    grpc.StreamInterceptor(authorizer.StreamInterceptor),\n//\t    grpc.UnaryInterceptor(authorizer.UnaryInterceptor),\n//\t)\n//\n//\t// Client usage\n//\tresp, _ := authClient.Auth(ctx, &proto.AuthRequest{AgentToken: \"secret\", AgentId: -1})\n//\tctx = metadata.AppendToOutgoingContext(ctx, \"token\", resp.AccessToken)\n//\tworkflow, _ := woodpeckerClient.Next(ctx, &proto.NextRequest{...})\npackage rpc\n\nimport (\n\t\"context\"\n\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/codes\"\n\t\"google.golang.org/grpc/metadata\"\n\t\"google.golang.org/grpc/status\"\n)\n\n// StreamContextWrapper wraps gRPC ServerStream to allow context modification.\ntype StreamContextWrapper interface {\n\tgrpc.ServerStream\n\tSetContext(context.Context)\n}\n\ntype wrapper struct {\n\tgrpc.ServerStream\n\tctx context.Context\n}\n\nfunc (w *wrapper) Context() context.Context {\n\treturn w.ctx\n}\n\nfunc (w *wrapper) SetContext(ctx context.Context) {\n\tw.ctx = ctx\n}\n\nfunc newStreamContextWrapper(inner grpc.ServerStream) StreamContextWrapper {\n\tctx := inner.Context()\n\treturn &wrapper{\n\t\tinner,\n\t\tctx,\n\t}\n}\n\n// Authorizer validates JWT tokens and enriches context with agent information.\ntype Authorizer struct {\n\tjwtManager *JWTManager\n}\n\n// NewAuthorizer creates a new JWT authorizer.\nfunc NewAuthorizer(jwtManager *JWTManager) *Authorizer {\n\treturn &Authorizer{jwtManager: jwtManager}\n}\n\n// StreamInterceptor validates JWT tokens for streaming gRPC calls.\nfunc (a *Authorizer) StreamInterceptor(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {\n\t_stream := newStreamContextWrapper(stream)\n\n\tnewCtx, err := a.authorize(stream.Context(), info.FullMethod)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_stream.SetContext(newCtx)\n\n\treturn handler(srv, _stream)\n}\n\n// UnaryInterceptor validates JWT tokens for unary gRPC calls.\nfunc (a *Authorizer) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {\n\tnewCtx, err := a.authorize(ctx, info.FullMethod)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn handler(newCtx, req)\n}\n\n// authorize validates JWT and injects verified agent_id into the context.\n// Bypasses validation for /proto.WoodpeckerAuth/Auth endpoint.\nfunc (a *Authorizer) authorize(ctx context.Context, fullMethod string) (context.Context, error) {\n\t// bypass auth for token endpoint\n\tif fullMethod == \"/proto.WoodpeckerAuth/Auth\" {\n\t\treturn ctx, nil\n\t}\n\n\tmd, ok := metadata.FromIncomingContext(ctx)\n\tif !ok {\n\t\treturn ctx, status.Errorf(codes.Unauthenticated, \"metadata is not provided\")\n\t}\n\n\tvalues := md[\"token\"]\n\tif len(values) == 0 {\n\t\treturn ctx, status.Errorf(codes.Unauthenticated, \"token is not provided\")\n\t}\n\n\taccessToken := values[0]\n\tclaims, err := a.jwtManager.Verify(accessToken)\n\tif err != nil {\n\t\treturn ctx, status.Errorf(codes.Unauthenticated, \"access token is invalid: %v\", err)\n\t}\n\n\t// inject agentID into context\n\tctx = context.WithValue(ctx, agentIDKey, claims.AgentID)\n\n\treturn ctx, nil\n}\n"
  },
  {
    "path": "server/rpc/authorizer_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/codes\"\n\t\"google.golang.org/grpc/metadata\"\n\t\"google.golang.org/grpc/status\"\n)\n\nfunc newAuthorizer(t *testing.T) *Authorizer {\n\tt.Helper()\n\treturn NewAuthorizer(NewJWTManager(\"auth-test-secret\"))\n}\n\n// validTokenForAgent generates a JWT that the authorizer will accept.\nfunc validTokenForAgent(t *testing.T, agentID int64) string {\n\tt.Helper()\n\ttoken, err := NewJWTManager(\"auth-test-secret\").Generate(agentID)\n\trequire.NoError(t, err)\n\treturn token\n}\n\n// ctxWithToken builds an incoming gRPC context carrying metadata[\"token\"].\nfunc ctxWithToken(ctx context.Context, token string) context.Context {\n\treturn metadata.NewIncomingContext(ctx, metadata.Pairs(\"token\", token))\n}\n\nfunc TestAuthorize(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Auth endpoint bypasses JWT validation\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\t// Plain context with no metadata – would normally fail, but Auth is exempt.\n\t\tctx, err := a.authorize(t.Context(), \"/proto.WoodpeckerAuth/Auth\")\n\n\t\trequire.NoError(t, err)\n\t\tassert.NotNil(t, ctx)\n\t})\n\n\tt.Run(\"missing metadata returns Unauthenticated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\t// A plain context has no gRPC incoming metadata.\n\t\t_, err := a.authorize(t.Context(), \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.Error(t, err)\n\t\ts, ok := status.FromError(err)\n\t\trequire.True(t, ok)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t\tassert.Contains(t, s.Message(), \"metadata is not provided\")\n\t})\n\n\tt.Run(\"metadata present but token key absent returns Unauthenticated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\tctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(\"other-key\", \"value\"))\n\n\t\t_, err := a.authorize(ctx, \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.Error(t, err)\n\t\ts, _ := status.FromError(err)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t\tassert.Contains(t, s.Message(), \"token is not provided\")\n\t})\n\n\tt.Run(\"invalid (garbage) token returns Unauthenticated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\tctx := ctxWithToken(t.Context(), \"this-is-not-a-jwt\")\n\n\t\t_, err := a.authorize(ctx, \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.Error(t, err)\n\t\ts, _ := status.FromError(err)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t\tassert.Contains(t, s.Message(), \"access token is invalid\")\n\t})\n\n\tt.Run(\"token signed with wrong secret returns Unauthenticated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\twrongManager := NewJWTManager(\"DIFFERENT-secret\")\n\t\ttoken, err := wrongManager.Generate(55)\n\t\trequire.NoError(t, err)\n\n\t\ta := newAuthorizer(t) // uses \"auth-test-secret\"\n\t\tctx := ctxWithToken(t.Context(), token)\n\n\t\t_, err = a.authorize(ctx, \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.Error(t, err)\n\t\ts, _ := status.FromError(err)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t})\n\n\tt.Run(\"valid token enriches context with agent_id metadata\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\ttoken := validTokenForAgent(t, 77)\n\t\tctx := ctxWithToken(t.Context(), token)\n\n\t\tnewCtx, err := a.authorize(ctx, \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.NoError(t, err)\n\n\t\trawAgentID := newCtx.Value(agentIDKey)\n\t\tagentID, ok := rawAgentID.(int64)\n\t\trequire.True(t, ok)\n\t\tassert.EqualValues(t, 77, agentID)\n\t})\n\n\tt.Run(\"valid token preserves existing metadata keys\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\ttoken := validTokenForAgent(t, 10)\n\t\tctx := metadata.NewIncomingContext(t.Context(),\n\t\t\tmetadata.Pairs(\"token\", token, \"hostname\", \"worker-1\"),\n\t\t)\n\n\t\tnewCtx, err := a.authorize(ctx, \"/proto.WoodpeckerServer/Init\")\n\n\t\trequire.NoError(t, err)\n\t\tmd, _ := metadata.FromIncomingContext(newCtx)\n\t\tassert.Equal(t, []string{\"worker-1\"}, md[\"hostname\"])\n\n\t\tagentID, _ := (newCtx.Value(agentIDKey)).(int64)\n\t\tassert.EqualValues(t, 10, agentID)\n\t})\n\n\tt.Run(\"empty token value in metadata slice returns Unauthenticated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\t// Passing an empty string as the token value.\n\t\tctx := ctxWithToken(t.Context(), \"\")\n\n\t\t_, err := a.authorize(ctx, \"/proto.WoodpeckerServer/Next\")\n\n\t\trequire.Error(t, err)\n\t\ts, _ := status.FromError(err)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t})\n}\n\nfunc TestUnaryInterceptor(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"valid token calls handler with enriched context\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\ttoken := validTokenForAgent(t, 21)\n\t\tctx := ctxWithToken(t.Context(), token)\n\n\t\tvar capturedCtx context.Context\n\t\thandler := func(ctx context.Context, _ any) (any, error) {\n\t\t\tcapturedCtx = ctx\n\t\t\treturn \"ok\", nil\n\t\t}\n\n\t\tresp, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerServer/Next\",\n\t\t}, handler)\n\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"ok\", resp)\n\n\t\t_, ok := metadata.FromIncomingContext(capturedCtx)\n\t\trequire.True(t, ok)\n\t\tagentID, _ := (capturedCtx.Value(agentIDKey)).(int64)\n\t\tassert.EqualValues(t, 21, agentID)\n\t})\n\n\tt.Run(\"invalid token does not call handler\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\tctx := ctxWithToken(t.Context(), \"bad-token\")\n\n\t\thandlerCalled := false\n\t\thandler := func(_ context.Context, _ any) (any, error) {\n\t\t\thandlerCalled = true\n\t\t\treturn nil, nil\n\t\t}\n\n\t\t_, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerServer/Next\",\n\t\t}, handler)\n\n\t\trequire.Error(t, err)\n\t\tassert.False(t, handlerCalled)\n\t})\n\n\tt.Run(\"Auth endpoint bypasses token check and calls handler\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\t// No token in context – fine because Auth is exempt.\n\t\tctx := metadata.NewIncomingContext(t.Context(), metadata.MD{})\n\n\t\thandlerCalled := false\n\t\thandler := func(_ context.Context, _ any) (any, error) {\n\t\t\thandlerCalled = true\n\t\t\treturn nil, nil\n\t\t}\n\n\t\t_, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerAuth/Auth\",\n\t\t}, handler)\n\n\t\trequire.NoError(t, err)\n\t\tassert.True(t, handlerCalled)\n\t})\n\n\tt.Run(\"handler error is propagated\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\ttoken := validTokenForAgent(t, 1)\n\t\tctx := ctxWithToken(t.Context(), token)\n\n\t\thandler := func(_ context.Context, _ any) (any, error) {\n\t\t\treturn nil, errors.New(\"handler boom\")\n\t\t}\n\n\t\t_, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerServer/Next\",\n\t\t}, handler)\n\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"handler boom\")\n\t})\n}\n\n// mockServerStream is a minimal grpc.ServerStream for testing.\ntype mockServerStream struct {\n\tctx context.Context\n}\n\nfunc (m *mockServerStream) SetHeader(metadata.MD) error  { return nil }\nfunc (m *mockServerStream) SendHeader(metadata.MD) error { return nil }\nfunc (m *mockServerStream) SetTrailer(metadata.MD)       {}\nfunc (m *mockServerStream) Context() context.Context     { return m.ctx }\nfunc (m *mockServerStream) SendMsg(any) error            { return nil }\nfunc (m *mockServerStream) RecvMsg(any) error            { return nil }\n\nfunc TestStreamInterceptor(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"valid token calls handler with enriched stream context\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\ttoken := validTokenForAgent(t, 33)\n\t\tctx := ctxWithToken(t.Context(), token)\n\t\tstream := &mockServerStream{ctx: ctx}\n\n\t\tvar capturedStream grpc.ServerStream\n\t\thandler := func(_ any, s grpc.ServerStream) error {\n\t\t\tcapturedStream = s\n\t\t\treturn nil\n\t\t}\n\n\t\terr := a.StreamInterceptor(nil, stream, &grpc.StreamServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerServer/Next\",\n\t\t}, handler)\n\n\t\trequire.NoError(t, err)\n\t\tcapturedCtx := capturedStream.Context()\n\n\t\t_, ok := metadata.FromIncomingContext(capturedCtx)\n\t\trequire.True(t, ok)\n\t\tagentID, _ := (capturedCtx.Value(agentIDKey)).(int64)\n\t\tassert.EqualValues(t, 33, agentID)\n\t})\n\n\tt.Run(\"invalid token does not call handler\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\ta := newAuthorizer(t)\n\t\tctx := ctxWithToken(t.Context(), \"garbage\")\n\t\tstream := &mockServerStream{ctx: ctx}\n\n\t\thandlerCalled := false\n\t\thandler := func(_ any, _ grpc.ServerStream) error {\n\t\t\thandlerCalled = true\n\t\t\treturn nil\n\t\t}\n\n\t\terr := a.StreamInterceptor(nil, stream, &grpc.StreamServerInfo{\n\t\t\tFullMethod: \"/proto.WoodpeckerServer/Next\",\n\t\t}, handler)\n\n\t\trequire.Error(t, err)\n\t\tassert.False(t, handlerCalled)\n\t\ts, _ := status.FromError(err)\n\t\tassert.Equal(t, codes.Unauthenticated, s.Code())\n\t})\n\n\tt.Run(\"stream context wrapper SetContext and Context round-trip\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tstream := &mockServerStream{ctx: t.Context()}\n\t\twrapper := newStreamContextWrapper(stream)\n\n\t\tnewCtx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(\"foo\", \"bar\"))\n\t\twrapper.SetContext(newCtx)\n\n\t\tmd, ok := metadata.FromIncomingContext(wrapper.Context())\n\t\trequire.True(t, ok)\n\t\tassert.Equal(t, []string{\"bar\"}, md[\"foo\"])\n\t})\n}\n"
  },
  {
    "path": "server/rpc/errors.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport \"errors\"\n\nvar (\n\tErrAgentIllegalWorkflowReRunStateChange = errors.New(\"workflow was already marked as finished\")\n\tErrAgentIllegalWorkflowRun              = errors.New(\"workflow is currently in blocked state\")\n\n\tErrAgentIllegalLogStreaming = errors.New(\"agent can not append logs to a step that is marked not running\")\n\n\tErrAgentIllegalRepo = errors.New(\"agent is not allowed to interact with repo\")\n)\n"
  },
  {
    "path": "server/rpc/filter.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"maps\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n)\n\nfunc createFilterFunc(agentFilter rpc.Filter) queue.FilterFn {\n\treturn func(task *model.Task) (bool, int) {\n\t\t// Create a copy of the labels for filtering to avoid modifying the original task\n\t\tlabels := maps.Clone(task.Labels)\n\n\t\tif requiredLabelsMissing(labels, agentFilter.Labels) {\n\t\t\treturn false, 0\n\t\t}\n\n\t\t// ignore internal labels for filtering\n\t\tfor k := range labels {\n\t\t\tif strings.HasPrefix(k, pipeline.InternalLabelPrefix) {\n\t\t\t\tdelete(labels, k)\n\t\t\t}\n\t\t}\n\n\t\tscore := 0\n\t\tfor taskLabel, taskLabelValue := range labels {\n\t\t\t// if a task label is empty it will be ignored\n\t\t\tif taskLabelValue == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// all task labels are required to be present for an agent to match\n\t\t\tagentLabelValue, ok := agentFilter.Labels[taskLabel]\n\t\t\tif !ok {\n\t\t\t\t// Check for required label\n\t\t\t\tagentLabelValue, ok = agentFilter.Labels[\"!\"+taskLabel]\n\t\t\t\tif !ok {\n\t\t\t\t\treturn false, 0\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tswitch agentLabelValue {\n\t\t\t// if agent label has a wildcard\n\t\t\tcase \"*\":\n\t\t\t\tscore++\n\t\t\t// if agent label has an exact match\n\t\t\tcase taskLabelValue:\n\t\t\t\tscore += 10\n\t\t\t// agent doesn't match\n\t\t\tdefault:\n\t\t\t\treturn false, 0\n\t\t\t}\n\t\t}\n\t\treturn true, score\n\t}\n}\n\nfunc requiredLabelsMissing(taskLabels, agentLabels map[string]string) bool {\n\tfor label, value := range agentLabels {\n\t\tif len(label) > 0 && label[0] == '!' {\n\t\t\tval, ok := taskLabels[label[1:]]\n\t\t\tif !ok || val != value {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "server/rpc/filter_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestCreateFilterFunc(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tagentFilter rpc.Filter\n\t\ttask        *model.Task\n\t\twantMatched bool\n\t\twantScore   int\n\t}{\n\t\t{\n\t\t\tname: \"Two exact matches\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   20,\n\t\t},\n\t\t{\n\t\t\tname: \"Wildcard and exact match\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"*\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   11,\n\t\t},\n\t\t{\n\t\t\tname: \"Partial match\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"windows\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"No match\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"456\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"windows\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Missing required label on agent\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"needed\": \"some\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Empty task labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Agent with additional label\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\", \"extra\": \"value\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\", \"empty\": \"\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   20,\n\t\t},\n\t\t{\n\t\t\tname: \"Two wildcard matches\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"*\", \"platform\": \"*\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   2,\n\t\t},\n\t\t{\n\t\t\tname: \"Required label matches without shebang\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"!org-id\": \"123\", \"platform\": \"linux\", \"extra\": \"value\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"org-id\": \"123\", \"platform\": \"linux\", \"empty\": \"\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   20,\n\t\t},\n\t\t{\n\t\t\tname: \"Two different labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"hello\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Exact match\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   10,\n\t\t},\n\t\t{\n\t\t\tname: \"Agent without labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Task without labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Agent and task without labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   0,\n\t\t},\n\t\t{\n\t\t\tname: \"Multiple matching labels\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\", \"gpu\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\", \"gpu\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   30,\n\t\t},\n\t\t{\n\t\t\tname: \"Additional label in agent\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\", \"gpu\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: true,\n\t\t\twantScore:   20,\n\t\t},\n\t\t{\n\t\t\tname: \"Additional label in task\",\n\t\t\tagentFilter: rpc.Filter{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\"},\n\t\t\t},\n\t\t\ttask: &model.Task{\n\t\t\t\tLabels: map[string]string{\"docker\": \"true\", \"shell\": \"true\", \"gpu\": \"true\"},\n\t\t\t},\n\t\t\twantMatched: false,\n\t\t\twantScore:   0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfilterFunc := createFilterFunc(tt.agentFilter)\n\t\t\tgotMatched, gotScore := filterFunc(tt.task)\n\n\t\t\tassert.Equal(t, tt.wantMatched, gotMatched, \"Matched result\")\n\t\t\tassert.Equal(t, tt.wantScore, gotScore, \"Score\")\n\t\t})\n\t}\n}\n\nfunc TestMissingRequiredLabels(t *testing.T) {\n\tt.Parallel()\n\n\ttestdata := []struct {\n\t\ttaskLabels     map[string]string\n\t\trequiredLabels map[string]string\n\t\twant           bool\n\t}{\n\t\t// Required label present and matches\n\t\t{\n\t\t\ttaskLabels:     map[string]string{\"os\": \"linux\"},\n\t\t\trequiredLabels: map[string]string{\"!os\": \"linux\", \"platform\": \"arm64\"},\n\t\t\twant:           false,\n\t\t},\n\t\t// Required label present but does not match\n\t\t{\n\t\t\ttaskLabels:     map[string]string{\"os\": \"windows\"},\n\t\t\trequiredLabels: map[string]string{\"!os\": \"linux\", \"platform\": \"amd64\"},\n\t\t\twant:           true,\n\t\t},\n\t\t// Required label missing\n\t\t{\n\t\t\ttaskLabels:     map[string]string{\"arch\": \"amd64\"},\n\t\t\trequiredLabels: map[string]string{\"!os\": \"linux\"},\n\t\t\twant:           true,\n\t\t},\n\t\t// No agent labels\n\t\t{\n\t\t\ttaskLabels:     map[string]string{\"os\": \"linux\"},\n\t\t\trequiredLabels: map[string]string{},\n\t\t\twant:           false,\n\t\t},\n\t}\n\n\tfor _, tt := range testdata {\n\t\tif got := requiredLabelsMissing(tt.taskLabels, tt.requiredLabels); got != tt.want {\n\t\t\tt.Errorf(\"requiredLabelsMissing(%v, %v) = %v, want %v\", tt.taskLabels, tt.requiredLabels, got, tt.want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/rpc/jwt_manager.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\n// JWTManager is a JSON web token manager.\ntype JWTManager struct {\n\tsecretKey     string\n\ttokenDuration time.Duration\n}\n\n// AgentTokenClaims is a custom JWT claims that contains an agent's information.\ntype AgentTokenClaims struct {\n\tjwt.RegisteredClaims\n\tAgentID int64 `json:\"agent_id\"`\n}\n\nconst jwtTokenDuration = 1 * time.Hour\n\n// NewJWTManager returns a new JWT manager.\nfunc NewJWTManager(secretKey string) *JWTManager {\n\treturn &JWTManager{secretKey, jwtTokenDuration}\n}\n\n// Generate generates and signs a new token for a user.\nfunc (manager *JWTManager) Generate(agentID int64) (string, error) {\n\tclaims := AgentTokenClaims{\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tIssuer:    \"woodpecker\",\n\t\t\tSubject:   fmt.Sprintf(\"%d\", agentID),\n\t\t\tAudience:  jwt.ClaimStrings{},\n\t\t\tNotBefore: jwt.NewNumericDate(time.Now()),\n\t\t\tIssuedAt:  jwt.NewNumericDate(time.Now()),\n\t\t\tID:        fmt.Sprintf(\"%d\", agentID),\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(manager.tokenDuration)),\n\t\t},\n\t\tAgentID: agentID,\n\t}\n\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\treturn token.SignedString([]byte(manager.secretKey))\n}\n\n// Verify verifies the access token string and return a user claim if the token is valid.\nfunc (manager *JWTManager) Verify(accessToken string) (*AgentTokenClaims, error) {\n\ttoken, err := jwt.ParseWithClaims(\n\t\taccessToken,\n\t\t&AgentTokenClaims{},\n\t\tfunc(token *jwt.Token) (any, error) {\n\t\t\t_, ok := token.Method.(*jwt.SigningMethodHMAC)\n\t\t\tif !ok {\n\t\t\t\treturn nil, errors.New(\"unexpected token signing method\")\n\t\t\t}\n\n\t\t\treturn []byte(manager.secretKey), nil\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid token: %w\", err)\n\t}\n\n\tclaims, ok := token.Claims.(*AgentTokenClaims)\n\tif !ok {\n\t\treturn nil, errors.New(\"invalid token claims\")\n\t}\n\n\treturn claims, nil\n}\n"
  },
  {
    "path": "server/rpc/jwt_manager_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestJWTManager(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"generate and verify roundtrip\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken, err := manager.Generate(42)\n\t\trequire.NoError(t, err)\n\t\tassert.NotEmpty(t, token)\n\n\t\tclaims, err := manager.Verify(token)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(42), claims.AgentID)\n\t})\n\n\tt.Run(\"claims contain correct fields\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken, err := manager.Generate(99)\n\t\trequire.NoError(t, err)\n\n\t\tclaims, err := manager.Verify(token)\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, int64(99), claims.AgentID)\n\t\tassert.Equal(t, \"woodpecker\", claims.Issuer)\n\t\tassert.Equal(t, fmt.Sprintf(\"%d\", 99), claims.Subject)\n\t\tassert.Equal(t, fmt.Sprintf(\"%d\", 99), claims.ID)\n\t})\n\n\tt.Run(\"different agent IDs produce different tokens\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken1, err := manager.Generate(1)\n\t\trequire.NoError(t, err)\n\n\t\ttoken2, err := manager.Generate(2)\n\t\trequire.NoError(t, err)\n\n\t\tassert.NotEqual(t, token1, token2)\n\t})\n\n\tt.Run(\"expired token is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := &JWTManager{\n\t\t\tsecretKey:     \"test-secret\",\n\t\t\ttokenDuration: 1 * time.Millisecond,\n\t\t}\n\n\t\ttoken, err := manager.Generate(42)\n\t\trequire.NoError(t, err)\n\n\t\ttime.Sleep(10 * time.Millisecond)\n\n\t\t_, err = manager.Verify(token)\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"invalid token\")\n\t})\n\n\tt.Run(\"wrong signing secret rejects token\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanagerA := NewJWTManager(\"secret-A\")\n\t\tmanagerB := NewJWTManager(\"secret-B\")\n\n\t\ttoken, err := managerA.Generate(42)\n\t\trequire.NoError(t, err)\n\n\t\t_, err = managerB.Verify(token)\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"invalid token\")\n\t})\n\n\tt.Run(\"tampered token is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken, err := manager.Generate(42)\n\t\trequire.NoError(t, err)\n\n\t\t// Flip a byte inside the decoded signature, then re-encode.\n\t\tparts := strings.Split(token, \".\")\n\t\trequire.Len(t, parts, 3)\n\t\tsig, err := base64.RawURLEncoding.DecodeString(parts[2])\n\t\trequire.NoError(t, err)\n\t\tsig[0] ^= 0xFF\n\t\tparts[2] = base64.RawURLEncoding.EncodeToString(sig)\n\t\ttampered := strings.Join(parts, \".\")\n\n\t\t_, err = manager.Verify(tampered)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"empty token is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\t_, err := manager.Verify(\"\")\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"garbage token is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\t_, err := manager.Verify(\"this-is-not-a-jwt\")\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"token generated with negative agent ID\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken, err := manager.Generate(-1)\n\t\trequire.NoError(t, err)\n\n\t\tclaims, err := manager.Verify(token)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(-1), claims.AgentID)\n\t})\n}\n\n// buildUnsignedToken manually constructs a JWT with alg=none so we can verify\n// that Verify() rejects it even though the signature section is empty.\n// We do NOT use the golang-jwt library here because modern versions refuse to\n// produce none-signed tokens — that is exactly the property we want to test.\nfunc buildUnsignedToken(t *testing.T, agentID int64) string {\n\tt.Helper()\n\theader := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]string{\"alg\": \"none\", \"typ\": \"JWT\"}),\n\t)\n\tpayload := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]any{\n\t\t\t\"agent_id\": agentID,\n\t\t\t\"iss\":      \"woodpecker\",\n\t\t}),\n\t)\n\t// A none-signed JWT carries an empty signature segment.\n\treturn header + \".\" + payload + \".\"\n}\n\n// buildRS256FakeToken constructs a JWT header claiming RS256 to exercise the\n// unexpected-signing-method guard inside JWTManager.Verify().\nfunc buildRS256FakeToken(t *testing.T) string {\n\tt.Helper()\n\theader := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]string{\"alg\": \"RS256\", \"typ\": \"JWT\"}),\n\t)\n\tpayload := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]any{\"agent_id\": 1, \"iss\": \"woodpecker\"}),\n\t)\n\tsig := base64.RawURLEncoding.EncodeToString([]byte(\"fake-rsa-sig\"))\n\treturn header + \".\" + payload + \".\" + sig\n}\n\n// buildFutureNbfToken constructs a JWT whose nbf claim is set far in the\n// future. The token must be rejected regardless of which check fires first.\nfunc buildFutureNbfToken(t *testing.T) string {\n\tt.Helper()\n\tconst farFuture = int64(9_999_999_999) // year 2286\n\theader := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]string{\"alg\": \"HS256\", \"typ\": \"JWT\"}),\n\t)\n\tpayload := base64.RawURLEncoding.EncodeToString(\n\t\tjwtMustMarshal(t, map[string]any{\n\t\t\t\"agent_id\": 1,\n\t\t\t\"iss\":      \"woodpecker\",\n\t\t\t\"nbf\":      farFuture,\n\t\t\t\"exp\":      farFuture + 3600,\n\t\t}),\n\t)\n\tbadSig := base64.RawURLEncoding.EncodeToString([]byte(\"bad\"))\n\treturn header + \".\" + payload + \".\" + badSig\n}\n\nfunc jwtMustMarshal(t *testing.T, v any) []byte {\n\tt.Helper()\n\tb, err := json.Marshal(v)\n\trequire.NoError(t, err)\n\treturn b\n}\n\nfunc TestJWTManagerAdditional(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"none-algorithm token is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\tnoneToken := buildUnsignedToken(t, 42)\n\n\t\t// Sanity: token really does carry the none algorithm header.\n\t\tparts := strings.Split(noneToken, \".\")\n\t\trequire.Len(t, parts, 3)\n\t\tassert.Equal(t, \"\", parts[2], \"signature part must be empty for none-alg tokens\")\n\n\t\t_, err := manager.Verify(noneToken)\n\t\tassert.Error(t, err, \"verifier must reject a none-algorithm token\")\n\t\tassert.Contains(t, err.Error(), \"invalid token\")\n\t})\n\n\tt.Run(\"RS256 token (unexpected signing method) is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\trs256Token := buildRS256FakeToken(t)\n\n\t\t_, err := manager.Verify(rs256Token)\n\t\tassert.Error(t, err, \"verifier must reject tokens with an unexpected signing method\")\n\t\tassert.Contains(t, err.Error(), \"invalid token\")\n\t})\n\n\tt.Run(\"token with far-future NotBefore is rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\tfutureToken := buildFutureNbfToken(t)\n\n\t\t_, err := manager.Verify(futureToken)\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"two valid tokens for same agent are each independently verifiable\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\n\t\ttok1, err := manager.Generate(5)\n\t\trequire.NoError(t, err)\n\t\ttok2, err := manager.Generate(5)\n\t\trequire.NoError(t, err)\n\n\t\tclaims1, err := manager.Verify(tok1)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(5), claims1.AgentID)\n\n\t\tclaims2, err := manager.Verify(tok2)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(5), claims2.AgentID)\n\t})\n\n\tt.Run(\"zero agent ID is preserved through generate/verify roundtrip\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tmanager := NewJWTManager(\"test-secret\")\n\t\ttoken, err := manager.Generate(0)\n\t\trequire.NoError(t, err)\n\n\t\tclaims, err := manager.Verify(token)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, int64(0), claims.AgentID)\n\t})\n}\n"
  },
  {
    "path": "server/rpc/rpc.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/oklog/ulid/v2\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/rs/zerolog/log\"\n\tgrpc_metadata \"google.golang.org/grpc/metadata\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype ctxKey struct{}\n\n// agentIDKey is a non imitable context key.\nvar agentIDKey = &ctxKey{}\n\n// updateAgentLastWorkDelay the delay before the LastWork info should be updated.\nconst updateAgentLastWorkDelay = time.Minute\n\ntype RPC struct {\n\tscheduler     scheduler.Scheduler\n\tlogger        logging.Log\n\tstore         store.Store\n\tpipelineTime  *prometheus.GaugeVec\n\tpipelineCount *prometheus.CounterVec\n}\n\n// Next blocks until it provides the next workflow to execute.\n// TODO (6038): Server does not release waiting agents on graceful shutdown.\nfunc (s *RPC) Next(c context.Context, agentFilter rpc.Filter) (*rpc.Workflow, error) {\n\tif hostname, err := s.getHostnameFromContext(c); err == nil {\n\t\tlog.Debug().Msgf(\"agent connected: %s: polling\", hostname)\n\t}\n\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif agent.NoSchedule {\n\t\ttime.Sleep(1 * time.Second)\n\t\treturn nil, nil\n\t}\n\n\tagentServerLabels, err := agent.GetServerLabels()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// enforce labels from server by overwriting agent labels\n\tmaps.Copy(agentFilter.Labels, agentServerLabels)\n\n\tlog.Trace().Msgf(\"Agent %s[%d] tries to pull task with labels: %v\", agent.Name, agent.ID, agentFilter.Labels)\n\n\tfilterFn := createFilterFunc(agentFilter)\n\n\tfor {\n\t\t// poll blocks until a task is available or the context is canceled / worker is kicked\n\t\ttask, err := s.scheduler.Poll(c, agent.ID, filterFn)\n\t\tif err != nil || task == nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif task.ShouldRun() {\n\t\t\tworkflow := new(rpc.Workflow)\n\t\t\terr = json.Unmarshal(task.Data, workflow)\n\t\t\treturn workflow, err\n\t\t}\n\n\t\t// task should not run, so mark it as done\n\t\tif err := s.Done(c, task.ID, rpc.WorkflowState{}); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"marking workflow task '%s' as done failed\", task.ID)\n\t\t}\n\t}\n}\n\n// Wait blocks until the workflow with the given ID is completed or got canceled.\n// Used to let agents wait for cancel signals from server side.\nfunc (s *RPC) Wait(c context.Context, workflowID string) (canceled bool, err error) {\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil {\n\t\treturn false, err\n\t}\n\n\tif err := s.scheduler.Wait(c, workflowID); err != nil {\n\t\tif errors.Is(err, queue.ErrCancel) {\n\t\t\t// we explicit send a cancel signal\n\t\t\tlog.Debug().Str(\"workflowID\", workflowID).Msg(\"while waiting the queue reported the workflow as canceled\")\n\t\t\treturn true, nil\n\t\t}\n\t\t// unknown error happened\n\t\tlog.Error().Err(err).Str(\"workflowID\", workflowID).Msg(\"while waiting the queue returned an unexpected error\")\n\t\treturn false, err\n\t}\n\n\t// workflow finished and on issues appeared\n\tlog.Debug().Str(\"workflowID\", workflowID).Msg(\"queue reported the workflow as finished\")\n\treturn false, nil\n}\n\n// Extend extends the lease for the workflow with the given ID.\nfunc (s *RPC) Extend(c context.Context, workflowID string) error {\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = s.updateAgentLastWork(agent)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil {\n\t\treturn err\n\t}\n\n\treturn s.scheduler.Extend(c, agent.ID, workflowID)\n}\n\n// Update let agent updates the step state at the server.\nfunc (s *RPC) Update(c context.Context, strWorkflowID string, state rpc.StepState) error {\n\tworkflowID, err := strconv.ParseInt(strWorkflowID, 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tworkflow, err := s.store.WorkflowLoad(workflowID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"rpc.update: cannot find workflow with id %d\", workflowID)\n\t\treturn err\n\t}\n\n\tcurrentPipeline, err := s.store.GetPipeline(workflow.PipelineID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find pipeline with id %d\", workflow.PipelineID)\n\t\treturn err\n\t}\n\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstep, err := s.store.StepByUUID(state.StepUUID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find step with uuid %s\", state.StepUUID)\n\t\treturn err\n\t}\n\n\tif step.PipelineID != currentPipeline.ID {\n\t\tmsg := fmt.Sprintf(\"agent returned status with step uuid '%s' which does not belong to current pipeline\", state.StepUUID)\n\t\tlog.Error().\n\t\t\tInt64(\"stepPipelineID\", step.PipelineID).\n\t\t\tInt64(\"currentPipelineID\", currentPipeline.ID).\n\t\t\tMsg(msg)\n\t\treturn errors.New(msg)\n\t}\n\n\trepo, err := s.store.GetRepo(currentPipeline.RepoID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find repo with id %d\", currentPipeline.RepoID)\n\t\treturn err\n\t}\n\n\t// check before agent can alter some state\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil {\n\t\treturn err\n\t}\n\n\t// sanitize agent input: only allow step updates that the workflow state permits\n\tif err := checkWorkflowAllowsStepUpdate(workflow.State, step, state); err != nil {\n\t\treturn err\n\t}\n\n\tif err := pipeline.UpdateStepStatus(c, s.store, step, state); err != nil {\n\t\tlog.Error().Err(err).Msg(\"rpc.update: cannot update step\")\n\t}\n\n\tif state.Exited {\n\t\tserver.Config.Services.LogStore.StepFinished(step)\n\t}\n\n\tif currentPipeline.Workflows, err = s.store.WorkflowGetTree(currentPipeline); err != nil {\n\t\tlog.Error().Err(err).Msg(\"cannot build tree from step list\")\n\t\treturn err\n\t}\n\n\treturn s.notify(c, repo, currentPipeline)\n}\n\n// Init signals the workflow is initialized.\nfunc (s *RPC) Init(c context.Context, strWorkflowID string, state rpc.WorkflowState) error {\n\tworkflowID, err := strconv.ParseInt(strWorkflowID, 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tworkflow, err := s.store.WorkflowLoad(workflowID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find workflow with id %d\", workflowID)\n\t\treturn err\n\t}\n\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tworkflow.AgentID = agent.ID\n\n\tcurrentPipeline, err := s.store.GetPipeline(workflow.PipelineID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find pipeline with id %d\", workflow.PipelineID)\n\t\treturn err\n\t}\n\n\trepo, err := s.store.GetRepo(currentPipeline.RepoID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find repo with id %d\", currentPipeline.RepoID)\n\t\treturn err\n\t}\n\n\t// check before agent can alter some state\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil {\n\t\treturn err\n\t}\n\n\t// check workflow's own state to prevent re-initializing a finished or blocked workflow\n\tif err := checkWorkflowState(workflow.State); err != nil {\n\t\treturn err\n\t}\n\n\tif currentPipeline.Status == model.StatusPending {\n\t\tif currentPipeline, err = pipeline.UpdateToStatusRunning(s.store, *currentPipeline, state.Started); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"init: cannot update pipeline %d state\", currentPipeline.ID)\n\t\t}\n\t}\n\n\ts.updateForgeStatus(c, repo, currentPipeline, workflow)\n\n\tdefer func() {\n\t\tcurrentPipeline.Workflows, _ = s.store.WorkflowGetTree(currentPipeline)\n\n\t\tif err := s.notify(c, repo, currentPipeline); err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"could not publish pipeline state change to pubsub\")\n\t\t}\n\t}()\n\n\tworkflow, err = pipeline.UpdateWorkflowStatusToRunning(s.store, *workflow, state)\n\tif err != nil {\n\t\treturn err\n\t}\n\ts.updateForgeStatus(c, repo, currentPipeline, workflow)\n\n\treturn s.updateAgentLastWork(agent)\n}\n\n// Done marks the workflow with the given ID as stopped.\nfunc (s *RPC) Done(c context.Context, strWorkflowID string, state rpc.WorkflowState) error {\n\tworkflowID, err := strconv.ParseInt(strWorkflowID, 10, 64)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tworkflow, err := s.store.WorkflowLoad(workflowID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find workflow with id %d\", workflowID)\n\t\treturn err\n\t}\n\n\tworkflow.Children, err = s.store.StepListFromWorkflowFind(workflow)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrentPipeline, err := s.store.GetPipeline(workflow.PipelineID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find pipeline with id %d\", workflow.PipelineID)\n\t\treturn err\n\t}\n\n\trepo, err := s.store.GetRepo(currentPipeline.RepoID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find repo with id %d\", currentPipeline.RepoID)\n\t\treturn err\n\t}\n\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// check before agent can alter some state\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil {\n\t\treturn err\n\t}\n\n\t// check workflow's own state to prevent finishing an already-finished or blocked workflow\n\tif err := checkWorkflowState(workflow.State); err != nil {\n\t\treturn err\n\t}\n\n\tlogger := log.With().\n\t\tStr(\"repo_id\", fmt.Sprint(repo.ID)).\n\t\tStr(\"pipeline_id\", fmt.Sprint(currentPipeline.ID)).\n\t\tStr(\"workflow_id\", strWorkflowID).Logger()\n\n\tlogger.Debug().Msgf(\"workflow state in store: %#v\", workflow)\n\tlogger.Debug().Msgf(\"gRPC Done with state: %#v\", state)\n\n\t// Complete any still-running children (e.g. service containers) before\n\t// computing the workflow status, so their final state is reflected.\n\ts.completeChildrenIfParentCompleted(workflow, state.Finished)\n\n\tif workflow, err = pipeline.UpdateWorkflowStatusToDone(s.store, *workflow, state); err != nil {\n\t\tlogger.Error().Err(err).Msgf(\"pipeline.UpdateWorkflowStatusToDone: cannot update workflow state: %s\", err)\n\t}\n\n\tvar queueErr error\n\tif !state.Canceled {\n\t\tif workflow.Failing() {\n\t\t\tqueueErr = s.scheduler.Error(c, strWorkflowID, fmt.Errorf(\"workflow finished with error %s\", state.Error))\n\t\t} else {\n\t\t\tqueueErr = s.scheduler.Done(c, strWorkflowID, workflow.State)\n\t\t}\n\t} else {\n\t\tif workflow.Started > 0 {\n\t\t\tqueueErr = s.scheduler.Done(c, strWorkflowID, model.StatusKilled)\n\t\t} else {\n\t\t\tqueueErr = s.scheduler.Done(c, strWorkflowID, model.StatusCanceled)\n\t\t}\n\t}\n\tif queueErr != nil {\n\t\tlogger.Error().Err(queueErr).Msg(\"queue.Done: cannot ack workflow\")\n\t}\n\n\tcurrentPipeline.Workflows, err = s.store.WorkflowGetTree(currentPipeline)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !model.IsThereRunningStage(currentPipeline.Workflows) {\n\t\tif currentPipeline, err = pipeline.UpdateStatusToDone(s.store, *currentPipeline, pipeline.PipelineStatus(currentPipeline.Workflows), workflow.Finished); err != nil {\n\t\t\tlogger.Error().Err(err).Msgf(\"pipeline.UpdateStatusToDone: cannot update workflows final state\")\n\t\t}\n\t}\n\n\ts.updateForgeStatus(c, repo, currentPipeline, workflow)\n\n\t// make sure writes to pubsub are non blocking (https://github.com/woodpecker-ci/woodpecker/blob/c919f32e0b6432a95e1a6d3d0ad662f591adf73f/server/logging/log.go#L9)\n\tgo func() {\n\t\tfor _, step := range workflow.Children {\n\t\t\tif step.State != model.StatusSkipped {\n\t\t\t\tif err := s.logger.Close(c, step.ID); err != nil {\n\t\t\t\t\tlogger.Error().Err(err).Msgf(\"done: cannot close log stream for step %d\", step.ID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\tif err := s.notify(c, repo, currentPipeline); err != nil {\n\t\treturn err\n\t}\n\n\tif currentPipeline.Status == model.StatusSuccess || currentPipeline.Status == model.StatusFailure {\n\t\ts.pipelineCount.WithLabelValues(repo.FullName, currentPipeline.Branch, string(currentPipeline.Status), \"total\").Inc()\n\t\ts.pipelineTime.WithLabelValues(repo.FullName, currentPipeline.Branch, string(currentPipeline.Status), \"total\").Set(float64(currentPipeline.Finished - currentPipeline.Started))\n\t}\n\tif currentPipeline.IsMultiPipeline() {\n\t\ts.pipelineTime.WithLabelValues(repo.FullName, currentPipeline.Branch, string(workflow.State), workflow.Name).Set(float64(workflow.Finished - workflow.Started))\n\t}\n\n\treturn s.updateAgentLastWork(agent)\n}\n\n// Log writes a log entry to the database and publishes it to the pubsub.\n// An explicit stepUUID makes it obvious that all entries must come from the same step.\nfunc (s *RPC) Log(c context.Context, stepUUID string, rpcLogEntries []*rpc.LogEntry) error {\n\tstep, err := s.store.StepByUUID(stepUUID)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not find step with uuid %s in store: %w\", stepUUID, err)\n\t}\n\n\tagent, err := s.getAgentFromContext(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrentPipeline, err := s.store.GetPipeline(step.PipelineID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot find pipeline with id %d\", step.PipelineID)\n\t\treturn err\n\t}\n\n\t// check before agent can alter some state\n\tif err := s.checkAgentPermissionByWorkflow(c, agent, \"\", currentPipeline, nil); err != nil {\n\t\treturn err\n\t}\n\n\t// sanitize agent input\n\tif err := allowAppendingLogs(currentPipeline, step); err != nil {\n\t\treturn fmt.Errorf(\"can not alter logs: %w\", err)\n\t}\n\n\terr = s.updateAgentLastWork(agent)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar logEntries []*model.LogEntry\n\n\tfor _, rpcLogEntry := range rpcLogEntries {\n\t\tif rpcLogEntry.StepUUID != stepUUID {\n\t\t\treturn fmt.Errorf(\"expected step UUID %s, got %s\", stepUUID, rpcLogEntry.StepUUID)\n\t\t}\n\t\tlogEntries = append(logEntries, &model.LogEntry{\n\t\t\tStepID: step.ID,\n\t\t\tTime:   rpcLogEntry.Time,\n\t\t\tLine:   rpcLogEntry.Line,\n\t\t\tData:   rpcLogEntry.Data,\n\t\t\tType:   model.LogEntryType(rpcLogEntry.Type),\n\t\t})\n\t}\n\n\t// make sure writes to pubsub are non blocking (https://github.com/woodpecker-ci/woodpecker/blob/c919f32e0b6432a95e1a6d3d0ad662f591adf73f/server/logging/log.go#L9)\n\tgo func() {\n\t\t// write line to listening web clients\n\t\tif err := s.logger.Write(c, step.ID, logEntries); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"rpc server could not write to logger\")\n\t\t}\n\t}()\n\n\tif err = server.Config.Services.LogStore.LogAppend(step, logEntries); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not store log entries\")\n\t}\n\n\treturn nil\n}\n\nfunc (s *RPC) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) {\n\tagent, err := s.getAgentFromContext(ctx)\n\tif err != nil {\n\t\treturn -1, err\n\t}\n\n\tif agent.Name == \"\" {\n\t\tif hostname, err := s.getHostnameFromContext(ctx); err == nil {\n\t\t\tagent.Name = hostname\n\t\t}\n\t}\n\n\tagent.Backend = info.Backend\n\tagent.Platform = info.Platform\n\tagent.Capacity = int32(info.Capacity)\n\tagent.Version = info.Version\n\tagent.CustomLabels = info.CustomLabels\n\n\terr = s.store.AgentUpdate(agent)\n\tif err != nil {\n\t\treturn -1, err\n\t}\n\n\treturn agent.ID, nil\n}\n\n// UnregisterAgent removes the agent from the database.\nfunc (s *RPC) UnregisterAgent(ctx context.Context) error {\n\tagent, err := s.getAgentFromContext(ctx)\n\tif !agent.IsSystemAgent() {\n\t\t// registered with individual agent token -> do not unregister\n\t\treturn nil\n\t}\n\tlog.Debug().Msgf(\"un-registering agent with ID %d\", agent.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = s.store.AgentDelete(agent)\n\n\treturn err\n}\n\nfunc (s *RPC) ReportHealth(ctx context.Context, status string) error {\n\tagent, err := s.getAgentFromContext(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif status != \"I am alive!\" {\n\t\t//nolint:staticcheck\n\t\treturn errors.New(\"Are you alive?\")\n\t}\n\n\tagent.LastContact = time.Now().Unix()\n\n\treturn s.store.AgentUpdate(agent)\n}\n\nfunc (s *RPC) completeChildrenIfParentCompleted(completedWorkflow *model.Workflow, finished int64) {\n\tfor _, c := range completedWorkflow.Children {\n\t\tif c.Running() {\n\t\t\tif updated, err := pipeline.UpdateStepToStatusSkipped(s.store, *c, finished, model.StatusKilled); err != nil {\n\t\t\t\tlog.Error().Err(err).Msgf(\"done: cannot update step_id %d child state\", c.ID)\n\t\t\t} else {\n\t\t\t\t// Update in-memory state so WorkflowStatus sees the final state\n\t\t\t\tc.State = updated.State\n\t\t\t\tc.Finished = updated.Finished\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *RPC) updateForgeStatus(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) {\n\tuser, err := s.store.GetUser(repo.UserID)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"cannot get user with id '%d'\", repo.UserID)\n\t\treturn\n\t}\n\n\t_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"can not get forge for repo '%s'\", repo.FullName)\n\t\treturn\n\t}\n\n\tforge.Refresh(ctx, _forge, s.store, user)\n\n\t// only do status updates for parent steps\n\tif workflow != nil {\n\t\terr = _forge.Status(ctx, user, repo, pipeline, workflow)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"error setting commit status for %s/%d\", repo.FullName, pipeline.Number)\n\t\t}\n\t}\n}\n\n// Notify push to our pubsub infra pipeline state changes.\nfunc (s *RPC) notify(c context.Context, repo *model.Repo, pipeline *model.Pipeline) (err error) {\n\tmessage := pubsub.Message{ID: ulid.Make().String()}\n\tmessage.Data, err = json.Marshal(model.Event{\n\t\tRepo:     *repo,\n\t\tPipeline: *pipeline,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"can't marshal JSON: %w\", err)\n\t}\n\n\tsubTopics := make(map[string]struct{})\n\t// if repo is public, push to public topic\n\tif !repo.IsSCMPrivate {\n\t\tsubTopics[pubsub.PublicTopic] = struct{}{}\n\t}\n\t// publish to repo specific topic\n\tsubTopics[pubsub.GetRepoTopic(repo)] = struct{}{}\n\n\treturn s.scheduler.Publish(c, subTopics, message)\n}\n\nfunc (s *RPC) getAgentFromContext(ctx context.Context) (*model.Agent, error) {\n\trawAgentID := ctx.Value(agentIDKey)\n\tif rawAgentID == nil {\n\t\treturn nil, errors.New(\"agent_id is not provided\")\n\t}\n\tagentID, ok := rawAgentID.(int64)\n\tif !ok {\n\t\treturn nil, errors.New(\"agent_id is not a valid integer\")\n\t}\n\n\treturn s.store.AgentFind(agentID)\n}\n\nfunc (s *RPC) getHostnameFromContext(ctx context.Context) (string, error) {\n\tmetadata, ok := grpc_metadata.FromIncomingContext(ctx)\n\tif ok {\n\t\thostname, ok := metadata[\"hostname\"]\n\t\tif ok && len(hostname) != 0 {\n\t\t\treturn hostname[0], nil\n\t\t}\n\t}\n\treturn \"\", errors.New(\"no hostname in metadata\")\n}\n\nfunc (s *RPC) updateAgentLastWork(agent *model.Agent) error {\n\t// only update agent.LastWork if not recently updated\n\tif time.Unix(agent.LastWork, 0).Add(updateAgentLastWorkDelay).After(time.Now()) {\n\t\treturn nil\n\t}\n\n\tagent.LastWork = time.Now().Unix()\n\tif err := s.store.AgentUpdate(agent); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/rpc/rpc_integration_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"google.golang.org/grpc/metadata\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n\tqueue_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\tlog_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\n// newTestRPC creates an RPC instance with common test infrastructure.\nfunc newTestRPC(t *testing.T, mockStore *store_mocks.MockStore, q queue.Queue) RPC {\n\tt.Helper()\n\n\tpipelineTime := prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker_test\",\n\t\tName:      \"pipeline_time_\" + t.Name(),\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\tpipelineCount := prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"woodpecker_test\",\n\t\tName:      \"pipeline_count_\" + t.Name(),\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\n\treturn RPC{\n\t\tstore:         mockStore,\n\t\tscheduler:     scheduler.NewScheduler(q, memory.New()),\n\t\tlogger:        logging.New(),\n\t\tpipelineTime:  pipelineTime,\n\t\tpipelineCount: pipelineCount,\n\t}\n}\n\n// defaultAgent returns a system agent (OrgID=-1) that can access any repo.\nfunc defaultAgent() *model.Agent {\n\treturn &model.Agent{\n\t\tID:    1,\n\t\tName:  \"test-agent\",\n\t\tOrgID: model.IDNotSet,\n\t}\n}\n\n// orgAgent999 returns an agent scoped to a specific org.\nfunc orgAgent999() *model.Agent {\n\treturn &model.Agent{\n\t\tID:    2,\n\t\tName:  \"org-agent\",\n\t\tOrgID: 999,\n\t}\n}\n\nfunc defaultRepo() *model.Repo {\n\treturn &model.Repo{\n\t\tID:       10,\n\t\tOrgID:    100,\n\t\tFullName: \"test-org/test-repo\",\n\t}\n}\n\nfunc defaultPipeline(status model.StatusValue) *model.Pipeline {\n\treturn &model.Pipeline{\n\t\tID:     20,\n\t\tRepoID: 10,\n\t\tStatus: status,\n\t\tBranch: \"main\",\n\t}\n}\n\nfunc defaultWorkflow(state model.StatusValue) *model.Workflow {\n\treturn &model.Workflow{\n\t\tID:         30,\n\t\tPipelineID: 20,\n\t\tState:      state,\n\t\tName:       \"test-workflow\",\n\t}\n}\n\nfunc defaultStep(state model.StatusValue) *model.Step {\n\treturn &model.Step{\n\t\tID:         40,\n\t\tUUID:       \"step-uuid-123\",\n\t\tPipelineID: 20,\n\t\tState:      state,\n\t}\n}\n\nfunc TestRPCUpdate(t *testing.T) {\n\tt.Run(\"happy path\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\trepo := defaultRepo()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(repo, nil)\n\t\t// pipeline.UpdateStepStatus calls StepUpdate\n\t\tmockStore.On(\"StepUpdate\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{workflow}, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{\n\t\t\tStepUUID: \"step-uuid-123\",\n\t\t\tStarted:  100,\n\t\t\tExited:   false,\n\t\t})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"allow terminal step update when workflow already finished\", func(t *testing.T) {\n\t\t// When the workflow is already finished, a step update that moves the\n\t\t// step to a terminal state (e.g. reporting exit code) should be allowed.\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusSuccess) // finished\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"StepUpdate\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{workflow}, nil)\n\t\tmockLogStore.On(\"StepFinished\", mock.Anything).Return()\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\t// Step reports exit → it will transition to success/failure (terminal)\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{\n\t\t\tStepUUID: \"step-uuid-123\",\n\t\t\tExited:   true,\n\t\t\tExitCode: 0,\n\t\t})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"reject non-terminal step update when workflow already finished\", func(t *testing.T) {\n\t\t// When the workflow is already finished, a step update that would keep\n\t\t// the step in a non-terminal state (e.g. just started, no exit) is rejected.\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusSuccess) // finished\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\t// Step reports started but not exited → still running (non-terminal)\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"reject step update when workflow blocked\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusBlocked)\n\t\tworkflow := defaultWorkflow(model.StatusBlocked)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"reject step belongs to different pipeline\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tstep := &model.Step{\n\t\t\tID:         40,\n\t\t\tUUID:       \"step-uuid-123\",\n\t\t\tPipelineID: 999, // different pipeline!\n\t\t\tState:      model.StatusRunning,\n\t\t}\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"does not belong to current pipeline\")\n\t})\n\n\tt.Run(\"reject agent from wrong org\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\trepo := defaultRepo() // org 100\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(repo, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n\n\tt.Run(\"reject invalid workflow ID\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"not-a-number\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"reject nonexistent workflow\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"WorkflowLoad\", int64(999)).Return(nil, errors.New(\"not found\"))\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"999\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"reject nonexistent step UUID\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"StepByUUID\", \"nonexistent\").Return(nil, errors.New(\"not found\"))\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"nonexistent\"})\n\t\tassert.Error(t, err)\n\t})\n\n\tt.Run(\"reject missing agent metadata\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\t// no agent_id in metadata\n\t\tctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs())\n\n\t\terr := rpcInst.Update(ctx, \"30\", rpc.StepState{StepUUID: \"step-uuid-123\"})\n\t\tassert.Error(t, err)\n\t})\n}\n\nfunc TestRPCInit(t *testing.T) {\n\tt.Run(\"happy path - pending pipeline\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\trepo := defaultRepo()\n\t\tpipeline := defaultPipeline(model.StatusPending)\n\t\tworkflow := defaultWorkflow(model.StatusPending)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(repo, nil)\n\t\t// pipeline.UpdateToStatusRunning -> UpdatePipeline\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil)\n\t\t// updateForgeStatus -> GetUser returns error so forge interaction is skipped\n\t\tmockStore.On(\"GetUser\", mock.Anything).Return(nil, errors.New(\"user not found\"))\n\t\t// pipeline.UpdateWorkflowStatusToRunning -> WorkflowUpdate\n\t\tmockStore.On(\"WorkflowUpdate\", mock.Anything).Return(nil)\n\t\t// pubsub deferred -> WorkflowGetTree\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{workflow}, nil)\n\t\t// updateAgentLastWork -> AgentUpdate\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Init(ctx, \"30\", rpc.WorkflowState{Started: 100})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"happy path - already running pipeline\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\trepo := defaultRepo()\n\t\tpipeline := defaultPipeline(model.StatusRunning) // another workflow already started it\n\t\tworkflow := defaultWorkflow(model.StatusPending)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(repo, nil)\n\t\t// updateForgeStatus -> GetUser returns error so forge interaction is skipped\n\t\tmockStore.On(\"GetUser\", mock.Anything).Return(nil, errors.New(\"user not found\"))\n\t\tmockStore.On(\"WorkflowUpdate\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{workflow}, nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Init(ctx, \"30\", rpc.WorkflowState{Started: 100})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"reject workflow already finished\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusSuccess)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Init(ctx, \"30\", rpc.WorkflowState{Started: 100})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"reject workflow blocked\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusBlocked)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Init(ctx, \"30\", rpc.WorkflowState{Started: 100})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowRun)\n\t})\n\n\tt.Run(\"reject agent wrong org\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusPending)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\terr := rpcInst.Init(ctx, \"30\", rpc.WorkflowState{Started: 100})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n\n\tt.Run(\"reject invalid workflow ID\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Init(ctx, \"not-a-number\", rpc.WorkflowState{})\n\t\tassert.Error(t, err)\n\t})\n}\n\nfunc TestRPCDone(t *testing.T) {\n\tt.Run(\"happy path\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockQueue := queue_mocks.NewMockQueue(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\trepo := defaultRepo()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tworkflow.Children = []*model.Step{}\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"StepListFromWorkflowFind\", mock.Anything).Return([]*model.Step{}, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(repo, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"WorkflowGetTree\", mock.Anything).Return([]*model.Workflow{}, nil)\n\t\tmockStore.On(\"UpdatePipeline\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"GetUser\", mock.Anything).Return(nil, errors.New(\"user not found\"))\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\tmockQueue.On(\"Done\", mock.Anything, mock.Anything, mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, mockQueue)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Done(ctx, \"30\", rpc.WorkflowState{Started: 100, Finished: 200})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"reject workflow already finished\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusSuccess)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"StepListFromWorkflowFind\", mock.Anything).Return([]*model.Step{}, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Done(ctx, \"30\", rpc.WorkflowState{Finished: 200})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"reject workflow blocked\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusBlocked)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"StepListFromWorkflowFind\", mock.Anything).Return([]*model.Step{}, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Done(ctx, \"30\", rpc.WorkflowState{Finished: 200})\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalWorkflowRun)\n\t})\n\n\tt.Run(\"reject agent wrong org\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"StepListFromWorkflowFind\", mock.Anything).Return([]*model.Step{}, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\terr := rpcInst.Done(ctx, \"30\", rpc.WorkflowState{Finished: 200})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n\n\tt.Run(\"reject invalid workflow ID\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Done(ctx, \"invalid\", rpc.WorkflowState{})\n\t\tassert.Error(t, err)\n\t})\n}\n\nfunc TestRPCLog(t *testing.T) {\n\t// helper: a pipeline whose Finished timestamp is far enough in the past\n\t// that it is outside the drain window, so log appending is rejected.\n\tstalePipeline := func(status model.StatusValue) *model.Pipeline {\n\t\tp := defaultPipeline(status)\n\t\tp.Finished = time.Now().Add(-(logStreamDelayAllowed + time.Minute)).Unix()\n\t\treturn p\n\t}\n\n\t// helper: a pipeline that finished very recently (within drain window).\n\trecentPipeline := func(status model.StatusValue) *model.Pipeline {\n\t\tp := defaultPipeline(status)\n\t\tp.Finished = time.Now().Add(-30 * time.Second).Unix()\n\t\treturn p\n\t}\n\n\tt.Run(\"happy path: step running, pipeline running\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\tmockLogStore.On(\"LogAppend\", mock.Anything, mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\tentries := []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Line: 0, Data: []byte(\"hello\")},\n\t\t\t{StepUUID: \"step-uuid-123\", Line: 1, Data: []byte(\"world\")},\n\t\t}\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", entries)\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"allow: step finished but pipeline still running (logs draining)\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning) // pipeline still running\n\t\tstep := defaultStep(model.StatusSuccess)         // but step already finished\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\tmockLogStore.On(\"LogAppend\", mock.Anything, mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"late log\")},\n\t\t})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"allow: step running even though pipeline finished stale (step takes priority)\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusSuccess) // finished long ago\n\t\tstep := defaultStep(model.StatusRunning)       // but step is still running\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\tmockLogStore.On(\"LogAppend\", mock.Anything, mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"running log\")},\n\t\t})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"allow: pipeline finished recently — within drain window\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := recentPipeline(model.StatusSuccess) // finished 30s ago\n\t\tstep := defaultStep(model.StatusSuccess)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\tmockLogStore.On(\"LogAppend\", mock.Anything, mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"drain log\")},\n\t\t})\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"reject: pipeline finished stale and step not running\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusSuccess)\n\t\tstep := defaultStep(model.StatusSuccess)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"can not alter logs\")\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalLogStreaming)\n\t})\n\n\tt.Run(\"reject: pipeline failed stale and step not running\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusFailure)\n\t\tstep := defaultStep(model.StatusFailure)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalLogStreaming)\n\t})\n\n\tt.Run(\"reject: step pending (not running), pipeline not running, outside drain window\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusKilled)\n\t\tstep := defaultStep(model.StatusPending)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"can not alter logs\")\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalLogStreaming)\n\t})\n\n\tt.Run(\"reject: step already succeeded, pipeline succeeded stale\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusSuccess)\n\t\tstep := defaultStep(model.StatusSuccess)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalLogStreaming)\n\t})\n\n\tt.Run(\"reject: step killed, pipeline killed stale\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := defaultAgent()\n\t\tpipeline := stalePipeline(model.StatusKilled)\n\t\tstep := defaultStep(model.StatusKilled)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.ErrorIs(t, err, ErrAgentIllegalLogStreaming)\n\t})\n\n\tt.Run(\"reject mismatched step UUID in log entry\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockLogStore := log_mocks.NewMockService(t)\n\t\torigLogStore := server.Config.Services.LogStore\n\t\tserver.Config.Services.LogStore = mockLogStore\n\t\tt.Cleanup(func() { server.Config.Services.LogStore = origLogStore })\n\n\t\tagent := defaultAgent()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(1)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\t// Second entry has a rogue UUID — agent trying to inject into another step.\n\t\tentries := []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Line: 0, Data: []byte(\"ok\")},\n\t\t\t{StepUUID: \"DIFFERENT-UUID\", Line: 1, Data: []byte(\"injected!\")},\n\t\t}\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", entries)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"expected step UUID\")\n\t})\n\n\tt.Run(\"reject agent wrong org\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\t\tstep := defaultStep(model.StatusRunning)\n\n\t\tmockStore.On(\"StepByUUID\", \"step-uuid-123\").Return(step, nil)\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\terr := rpcInst.Log(ctx, \"step-uuid-123\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"step-uuid-123\", Data: []byte(\"test\")},\n\t\t})\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n\n\tt.Run(\"reject nonexistent step UUID\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"StepByUUID\", \"nonexistent\").Return(nil, errors.New(\"not found\"))\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(1))\n\n\t\terr := rpcInst.Log(ctx, \"nonexistent\", []*rpc.LogEntry{\n\t\t\t{StepUUID: \"nonexistent\", Data: []byte(\"test\")},\n\t\t})\n\t\tassert.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"could not find step\")\n\t})\n}\n\nfunc TestRPCExtend(t *testing.T) {\n\tt.Run(\"reject agent wrong org via permission check\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\t\tmockStore.On(\"AgentUpdate\", mock.Anything).Return(nil)\n\t\t// checkAgentPermissionByWorkflow with nil pipeline/repo -> loads from store\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\terr := rpcInst.Extend(ctx, \"30\")\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n}\n\nfunc TestRPCWait(t *testing.T) {\n\tt.Run(\"reject agent wrong org\", func(t *testing.T) {\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tagent := orgAgent999()\n\t\tworkflow := defaultWorkflow(model.StatusRunning)\n\t\tpipeline := defaultPipeline(model.StatusRunning)\n\n\t\tmockStore.On(\"AgentFind\", int64(2)).Return(agent, nil)\n\t\t// checkAgentPermissionByWorkflow loads from store\n\t\tmockStore.On(\"WorkflowLoad\", int64(30)).Return(workflow, nil)\n\t\tmockStore.On(\"GetPipeline\", int64(20)).Return(pipeline, nil)\n\t\tmockStore.On(\"GetRepo\", int64(10)).Return(defaultRepo(), nil)\n\n\t\trpcInst := newTestRPC(t, mockStore, nil)\n\t\tctx := context.WithValue(t.Context(), agentIDKey, int64(2))\n\n\t\t_, err := rpcInst.Wait(ctx, \"30\")\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not allowed to interact\")\n\t})\n}\n"
  },
  {
    "path": "server/rpc/rpc_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"google.golang.org/grpc/metadata\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestRegisterAgent(t *testing.T) {\n\tt.Run(\"When existing agent Name is empty it should update Name with hostname from metadata\", func(t *testing.T) {\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstoreAgent := new(model.Agent)\n\t\tstoreAgent.ID = 1337\n\t\tupdatedAgent := model.Agent{\n\t\t\tID:          1337,\n\t\t\tCreated:     0,\n\t\t\tUpdated:     0,\n\t\t\tName:        \"hostname\",\n\t\t\tOwnerID:     0,\n\t\t\tToken:       \"\",\n\t\t\tLastContact: 0,\n\t\t\tPlatform:    \"platform\",\n\t\t\tBackend:     \"backend\",\n\t\t\tCapacity:    2,\n\t\t\tVersion:     \"version\",\n\t\t\tNoSchedule:  false,\n\t\t}\n\n\t\tstore.On(\"AgentFind\", int64(1337)).Once().Return(storeAgent, nil)\n\t\tstore.On(\"AgentUpdate\", &updatedAgent).Once().Return(nil)\n\t\tgrpc := RPC{\n\t\t\tstore: store,\n\t\t}\n\t\tctx := metadata.NewIncomingContext(\n\t\t\tt.Context(),\n\t\t\tmetadata.Pairs(\"hostname\", \"hostname\"),\n\t\t)\n\t\tctx = context.WithValue(ctx, agentIDKey, int64(1337))\n\t\tagentID, err := grpc.RegisterAgent(ctx, rpc.AgentInfo{\n\t\t\tVersion:  \"version\",\n\t\t\tPlatform: \"platform\",\n\t\t\tBackend:  \"backend\",\n\t\t\tCapacity: 2,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tassert.EqualValues(t, 1337, agentID)\n\t})\n\n\tt.Run(\"When existing agent hostname is present it should not update the hostname\", func(t *testing.T) {\n\t\tstore := store_mocks.NewMockStore(t)\n\t\tstoreAgent := new(model.Agent)\n\t\tstoreAgent.ID = 1337\n\t\tstoreAgent.Name = \"originalHostname\"\n\t\tupdatedAgent := model.Agent{\n\t\t\tID:          1337,\n\t\t\tCreated:     0,\n\t\t\tUpdated:     0,\n\t\t\tName:        \"originalHostname\",\n\t\t\tOwnerID:     0,\n\t\t\tToken:       \"\",\n\t\t\tLastContact: 0,\n\t\t\tPlatform:    \"platform\",\n\t\t\tBackend:     \"backend\",\n\t\t\tCapacity:    2,\n\t\t\tVersion:     \"version\",\n\t\t\tNoSchedule:  false,\n\t\t}\n\n\t\tstore.On(\"AgentFind\", int64(1337)).Once().Return(storeAgent, nil)\n\t\tstore.On(\"AgentUpdate\", &updatedAgent).Once().Return(nil)\n\t\tgrpc := RPC{\n\t\t\tstore: store,\n\t\t}\n\t\tctx := metadata.NewIncomingContext(\n\t\t\tt.Context(),\n\t\t\tmetadata.Pairs(\"hostname\", \"newHostname\"),\n\t\t)\n\t\tctx = context.WithValue(ctx, agentIDKey, int64(1337))\n\t\tagentID, err := grpc.RegisterAgent(ctx, rpc.AgentInfo{\n\t\t\tVersion:  \"version\",\n\t\t\tPlatform: \"platform\",\n\t\t\tBackend:  \"backend\",\n\t\t\tCapacity: 2,\n\t\t})\n\t\trequire.NoError(t, err)\n\n\t\tassert.EqualValues(t, 1337, agentID)\n\t})\n}\n\nfunc TestCompleteChildrenIfParentCompleted(t *testing.T) {\n\tt.Run(\"When a service step is still running it should update state so workflow finishes as success\", func(t *testing.T) {\n\t\tsuccessStep := &model.Step{\n\t\t\tID:      1,\n\t\t\tState:   model.StatusSuccess,\n\t\t\tStarted: 1234567800,\n\t\t}\n\t\trunningService := &model.Step{\n\t\t\tID:      2,\n\t\t\tState:   model.StatusRunning,\n\t\t\tStarted: 1234567800,\n\t\t}\n\t\tworkflow := model.Workflow{\n\t\t\tID:       7,\n\t\t\tState:    model.StatusRunning,\n\t\t\tChildren: []*model.Step{successStep, runningService},\n\t\t}\n\n\t\tmockStore := store_mocks.NewMockStore(t)\n\t\tmockStore.On(\"StepUpdate\", mock.Anything).Return(nil)\n\t\tmockStore.On(\"WorkflowUpdate\", mock.Anything).Return(nil)\n\n\t\ts := RPC{store: mockStore}\n\t\ts.completeChildrenIfParentCompleted(&workflow, 1234567900)\n\n\t\tassert.Equal(t, model.StatusSuccess, runningService.State)\n\t\tassert.Equal(t, int64(1234567900), runningService.Finished)\n\n\t\tresult, err := pipeline.UpdateWorkflowStatusToDone(mockStore, workflow, rpc.WorkflowState{\n\t\t\tStarted:  1234567800,\n\t\t\tFinished: 1234567900,\n\t\t})\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, model.StatusSuccess, result.State)\n\t})\n}\n\nfunc TestUpdateAgentLastWork(t *testing.T) {\n\tt.Run(\"When last work was never updated it should update last work timestamp\", func(t *testing.T) {\n\t\tagent := model.Agent{\n\t\t\tLastWork: 0,\n\t\t}\n\t\tstore := store_mocks.NewMockStore(t)\n\t\trpc := RPC{\n\t\t\tstore: store,\n\t\t}\n\t\tstore.On(\"AgentUpdate\", mock.Anything).Once().Return(nil)\n\n\t\terr := rpc.updateAgentLastWork(&agent)\n\t\tassert.NoError(t, err)\n\n\t\tassert.NotZero(t, agent.LastWork)\n\t})\n\n\tt.Run(\"When last work was updated over a minute ago it should update last work timestamp\", func(t *testing.T) {\n\t\tlastWork := time.Now().Add(-time.Hour).Unix()\n\t\tagent := model.Agent{\n\t\t\tLastWork: lastWork,\n\t\t}\n\t\tstore := store_mocks.NewMockStore(t)\n\t\trpc := RPC{\n\t\t\tstore: store,\n\t\t}\n\t\tstore.On(\"AgentUpdate\", mock.Anything).Once().Return(nil)\n\n\t\terr := rpc.updateAgentLastWork(&agent)\n\t\tassert.NoError(t, err)\n\n\t\tassert.NotEqual(t, lastWork, agent.LastWork)\n\t})\n\n\tt.Run(\"When last work was updated in the last minute it should not update last work timestamp again\", func(t *testing.T) {\n\t\tlastWork := time.Now().Add(-time.Second * 30).Unix()\n\t\tagent := model.Agent{\n\t\t\tLastWork: lastWork,\n\t\t}\n\t\trpc := RPC{}\n\n\t\terr := rpc.updateAgentLastWork(&agent)\n\t\tassert.NoError(t, err)\n\n\t\tassert.Equal(t, lastWork, agent.LastWork)\n\t})\n}\n"
  },
  {
    "path": "server/rpc/sanitize.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pipeline\"\n)\n\nconst logStreamDelayAllowed = 5 * time.Minute\n\nfunc (s *RPC) checkAgentPermissionByWorkflow(_ context.Context, agent *model.Agent, strWorkflowID string, p *model.Pipeline, repo *model.Repo) error {\n\tvar err error\n\tif repo == nil && p == nil {\n\t\tworkflowID, err := strconv.ParseInt(strWorkflowID, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tworkflow, err := s.store.WorkflowLoad(workflowID)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot find workflow with id %d\", workflowID)\n\t\t\treturn err\n\t\t}\n\n\t\tp, err = s.store.GetPipeline(workflow.PipelineID)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot find pipeline with id %d\", workflow.PipelineID)\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif repo == nil {\n\t\trepo, err = s.store.GetRepo(p.RepoID)\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot find repo with id %d\", p.RepoID)\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif agent.CanAccessRepo(repo) {\n\t\treturn nil\n\t}\n\n\tlog.Error().Err(ErrAgentIllegalRepo).Int64(\"agentID\", agent.ID).Int64(\"repoId\", repo.ID).Send()\n\treturn fmt.Errorf(\"%w: agentId=%d repoID=%d\", ErrAgentIllegalRepo, agent.ID, repo.ID)\n}\n\n// isActiveState returns true for states where work is in progress or not yet started.\nfunc isActiveState(state model.StatusValue) bool {\n\tswitch state {\n\tcase model.StatusCreated,\n\t\tmodel.StatusPending,\n\t\tmodel.StatusRunning:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// isDoneState returns true for terminal states where no further work will happen.\nfunc isDoneState(state model.StatusValue) bool {\n\tswitch state {\n\tcase model.StatusSuccess,\n\t\tmodel.StatusFailure,\n\t\tmodel.StatusKilled,\n\t\tmodel.StatusCanceled,\n\t\tmodel.StatusSkipped,\n\t\tmodel.StatusError,\n\t\tmodel.StatusDeclined:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// checkWorkflowAllowsStepUpdate validates whether the workflow state permits\n// the given step state update. If the workflow is active (created/pending/running),\n// any step update is allowed. If the workflow is in a terminal state, only\n// updates that would move the step into a terminal state are permitted — this\n// lets the agent report final results for steps that completed after the\n// workflow was already marked done.\nfunc checkWorkflowAllowsStepUpdate(workflowState model.StatusValue, step *model.Step, state rpc.StepState) error {\n\tif isActiveState(workflowState) {\n\t\treturn nil\n\t}\n\n\tnewStep, _, err := pipeline.CalcStepStatus(*step, state)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif isDoneState(newStep.State) {\n\t\treturn nil\n\t}\n\n\tretErr := ErrAgentIllegalWorkflowReRunStateChange\n\tlog.Error().Err(retErr).Msg(\"caught agent performing illegal instruction\")\n\treturn retErr\n}\n\n// checkWorkflowState checks if a workflow's own state allows it to be\n// initialized or marked as done. A workflow that is already in a terminal\n// state (success, failure, killed, …) must not be re-run, and a blocked\n// workflow must not be started by an agent.\nfunc checkWorkflowState(state model.StatusValue) (err error) {\n\tswitch state {\n\tcase model.StatusCreated,\n\t\tmodel.StatusPending,\n\t\tmodel.StatusRunning:\n\t\treturn nil\n\n\tcase model.StatusBlocked:\n\t\terr = ErrAgentIllegalWorkflowRun\n\n\tdefault:\n\t\terr = ErrAgentIllegalWorkflowReRunStateChange\n\t}\n\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"caught agent performing illegal instruction\")\n\t}\n\treturn err\n}\n\nfunc allowAppendingLogs(currPipeline *model.Pipeline, currStep *model.Step) error {\n\t// As long as pipeline is running just let the agent send logs\n\tif currStep.State == model.StatusRunning || currPipeline.Status == model.StatusRunning {\n\t\treturn nil\n\t}\n\t// else give some delay where log caches can drain and be send ... because of network outage / server restart / ...\n\tif time.Unix(currPipeline.Finished, 0).Add(logStreamDelayAllowed).After(time.Now()) {\n\t\treturn nil\n\t}\n\n\terr := ErrAgentIllegalLogStreaming\n\tlog.Error().Err(err).Msg(\"caught agent performing illegal instruction\")\n\treturn err\n}\n"
  },
  {
    "path": "server/rpc/sanitize_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestCheckWorkflowAllowsStepUpdate(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"workflow running allows any step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\t// Non-terminal update (step stays running)\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusRunning, step, rpc.StepState{}))\n\t})\n\n\tt.Run(\"workflow pending allows any step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusPending}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusPending, step, rpc.StepState{}))\n\t})\n\n\tt.Run(\"workflow created allows any step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusPending}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusCreated, step, rpc.StepState{}))\n\t})\n\n\tt.Run(\"workflow finished allows terminal step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\t// Step exits with code 0 → CalcStepStatus produces StatusSuccess (terminal)\n\t\tstate := rpc.StepState{Exited: true, ExitCode: 0}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state))\n\t})\n\n\tt.Run(\"workflow finished allows failed step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\tstate := rpc.StepState{Exited: true, ExitCode: 1}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusFailure, step, state))\n\t})\n\n\tt.Run(\"workflow finished allows canceled step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\tstate := rpc.StepState{Canceled: true}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusKilled, step, state))\n\t})\n\n\tt.Run(\"workflow finished allows skipped step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusPending}\n\t\tstate := rpc.StepState{Skipped: true}\n\t\tassert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state))\n\t})\n\n\tt.Run(\"workflow finished rejects non-terminal step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\t// No exit, no cancel → step stays Running (non-terminal)\n\t\tstate := rpc.StepState{}\n\t\tassert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state), ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"workflow killed rejects non-terminal step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\tstate := rpc.StepState{}\n\t\tassert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusKilled, step, state), ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"workflow blocked rejects non-terminal step update\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusRunning}\n\t\tstate := rpc.StepState{}\n\t\tassert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusBlocked, step, state), ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n\n\tt.Run(\"workflow finished rejects pending-to-running transition\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tstep := &model.Step{State: model.StatusPending}\n\t\t// No skip, no exit → CalcStepStatus produces Running (non-terminal)\n\t\tstate := rpc.StepState{Started: 100}\n\t\tassert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state), ErrAgentIllegalWorkflowReRunStateChange)\n\t})\n}\n\nfunc TestCheckWorkflowState(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"allowed states\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tfor _, s := range []model.StatusValue{\n\t\t\tmodel.StatusCreated,\n\t\t\tmodel.StatusPending,\n\t\t\tmodel.StatusRunning,\n\t\t} {\n\t\t\tt.Run(string(s), func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tassert.NoError(t, checkWorkflowState(s))\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"blocked rejects\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tassert.ErrorIs(t, checkWorkflowState(model.StatusBlocked), ErrAgentIllegalWorkflowRun)\n\t})\n\n\tt.Run(\"terminal states reject\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tfor _, s := range []model.StatusValue{\n\t\t\tmodel.StatusSuccess,\n\t\t\tmodel.StatusFailure,\n\t\t\tmodel.StatusKilled,\n\t\t\tmodel.StatusError,\n\t\t\tmodel.StatusSkipped,\n\t\t\tmodel.StatusCanceled,\n\t\t\tmodel.StatusDeclined,\n\t\t} {\n\t\t\tt.Run(string(s), func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tassert.ErrorIs(t, checkWorkflowState(s), ErrAgentIllegalWorkflowReRunStateChange)\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestIsActiveState(t *testing.T) {\n\tt.Parallel()\n\n\tactive := []model.StatusValue{model.StatusCreated, model.StatusPending, model.StatusRunning}\n\tinactive := []model.StatusValue{\n\t\tmodel.StatusSuccess, model.StatusFailure, model.StatusKilled,\n\t\tmodel.StatusBlocked, model.StatusCanceled, model.StatusSkipped,\n\t\tmodel.StatusError, model.StatusDeclined,\n\t}\n\n\tfor _, s := range active {\n\t\tt.Run(fmt.Sprintf(\"%s is active\", s), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tassert.True(t, isActiveState(s))\n\t\t})\n\t}\n\tfor _, s := range inactive {\n\t\tt.Run(fmt.Sprintf(\"%s is not active\", s), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tassert.False(t, isActiveState(s))\n\t\t})\n\t}\n}\n\nfunc TestIsDoneState(t *testing.T) {\n\tt.Parallel()\n\n\tdone := []model.StatusValue{\n\t\tmodel.StatusSuccess, model.StatusFailure, model.StatusKilled,\n\t\tmodel.StatusCanceled, model.StatusSkipped, model.StatusError,\n\t\tmodel.StatusDeclined,\n\t}\n\tnotDone := []model.StatusValue{\n\t\tmodel.StatusCreated, model.StatusPending, model.StatusRunning,\n\t\tmodel.StatusBlocked,\n\t}\n\n\tfor _, s := range done {\n\t\tt.Run(fmt.Sprintf(\"%s is done\", s), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tassert.True(t, isDoneState(s))\n\t\t})\n\t}\n\tfor _, s := range notDone {\n\t\tt.Run(fmt.Sprintf(\"%s is not done\", s), func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tassert.False(t, isDoneState(s))\n\t\t})\n\t}\n}\n\nfunc TestAllowAppendingLogs(t *testing.T) {\n\tt.Parallel()\n\n\trecentFinish := time.Now().Add(-30 * time.Second).Unix()\n\tstaleFinish := time.Now().Add(-10 * time.Minute).Unix()\n\n\t// Step running always allows logs, regardless of pipeline state or age.\n\tt.Run(\"step running always allowed\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tfor _, tc := range []struct {\n\t\t\tname   string\n\t\t\tstatus model.StatusValue\n\t\t\tfinish int64\n\t\t}{\n\t\t\t{\"pipeline running\", model.StatusRunning, 0},\n\t\t\t{\"pipeline success stale\", model.StatusSuccess, staleFinish},\n\t\t\t{\"pipeline failure stale\", model.StatusFailure, staleFinish},\n\t\t\t{\"pipeline killed stale\", model.StatusKilled, staleFinish},\n\t\t} {\n\t\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tp := &model.Pipeline{Status: tc.status, Finished: tc.finish}\n\t\t\t\tassert.NoError(t, allowAppendingLogs(p, &model.Step{State: model.StatusRunning}))\n\t\t\t})\n\t\t}\n\t})\n\n\t// Pipeline running allows logs for any step state.\n\tt.Run(\"pipeline running any step allowed\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tfor _, ss := range []model.StatusValue{\n\t\t\tmodel.StatusSuccess, model.StatusFailure, model.StatusPending, model.StatusKilled,\n\t\t} {\n\t\t\tt.Run(string(ss), func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tp := &model.Pipeline{Status: model.StatusRunning}\n\t\t\t\tassert.NoError(t, allowAppendingLogs(p, &model.Step{State: ss}))\n\t\t\t})\n\t\t}\n\t})\n\n\t// Recent finish → drain window allows logs.\n\tt.Run(\"recent finish drain allowed\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tfor _, tc := range []struct {\n\t\t\tpStatus model.StatusValue\n\t\t\tsState  model.StatusValue\n\t\t}{\n\t\t\t{model.StatusSuccess, model.StatusSuccess},\n\t\t\t{model.StatusFailure, model.StatusFailure},\n\t\t\t{model.StatusKilled, model.StatusPending},\n\t\t} {\n\t\t\tt.Run(fmt.Sprintf(\"%s/%s\", tc.pStatus, tc.sState), func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tp := &model.Pipeline{Status: tc.pStatus, Finished: recentFinish}\n\t\t\t\tassert.NoError(t, allowAppendingLogs(p, &model.Step{State: tc.sState}))\n\t\t\t})\n\t\t}\n\t})\n\n\t// Stale finish → drain window expired → reject.\n\tt.Run(\"stale finish drain rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\n\t\tfor _, tc := range []struct {\n\t\t\tpStatus model.StatusValue\n\t\t\tsState  model.StatusValue\n\t\t\tfinish  int64\n\t\t}{\n\t\t\t{model.StatusSuccess, model.StatusSuccess, staleFinish},\n\t\t\t{model.StatusFailure, model.StatusFailure, staleFinish},\n\t\t\t{model.StatusKilled, model.StatusPending, staleFinish},\n\t\t\t{model.StatusError, model.StatusCreated, staleFinish},\n\t\t\t{model.StatusSuccess, model.StatusSuccess, 0}, // zero = never recorded\n\t\t} {\n\t\t\tt.Run(fmt.Sprintf(\"%s/%s/fin=%d\", tc.pStatus, tc.sState, tc.finish), func(t *testing.T) {\n\t\t\t\tt.Parallel()\n\t\t\t\tp := &model.Pipeline{Status: tc.pStatus, Finished: tc.finish}\n\t\t\t\tassert.ErrorIs(t, allowAppendingLogs(p, &model.Step{State: tc.sState}), ErrAgentIllegalLogStreaming)\n\t\t\t})\n\t\t}\n\t})\n}\n\n// TestAllowAppendingLogsDrainBoundary guards the exact edge of the 5-minute\n// drain window against off-by-one errors.\nfunc TestAllowAppendingLogsDrainBoundary(t *testing.T) {\n\tt.Parallel()\n\n\tstep := &model.Step{State: model.StatusSuccess}\n\n\tt.Run(\"just inside drain window allowed\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tp := &model.Pipeline{\n\t\t\tStatus:   model.StatusSuccess,\n\t\t\tFinished: time.Now().Add(-(logStreamDelayAllowed - time.Second)).Unix(),\n\t\t}\n\t\tassert.NoError(t, allowAppendingLogs(p, step))\n\t})\n\n\tt.Run(\"just outside drain window rejected\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tp := &model.Pipeline{\n\t\t\tStatus:   model.StatusSuccess,\n\t\t\tFinished: time.Now().Add(-(logStreamDelayAllowed + time.Second)).Unix(),\n\t\t}\n\t\tassert.ErrorIs(t, allowAppendingLogs(p, step), ErrAgentIllegalLogStreaming)\n\t})\n}\n"
  },
  {
    "path": "server/rpc/server.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage rpc\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/rpc/proto\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/logging\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/scheduler\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// WoodpeckerServer is a grpc server implementation.\ntype WoodpeckerServer struct {\n\tproto.UnimplementedWoodpeckerServer\n\tpeer RPC\n}\n\nfunc NewWoodpeckerServer(scheduler scheduler.Scheduler, logger logging.Log, store store.Store) proto.WoodpeckerServer {\n\tpipelineTime := promauto.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pipeline_time\",\n\t\tHelp:      \"Pipeline time.\",\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\tpipelineCount := promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pipeline_count\",\n\t\tHelp:      \"Pipeline count.\",\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\tpeer := RPC{\n\t\tstore:         store,\n\t\tscheduler:     scheduler,\n\t\tlogger:        logger,\n\t\tpipelineTime:  pipelineTime,\n\t\tpipelineCount: pipelineCount,\n\t}\n\treturn &WoodpeckerServer{peer: peer}\n}\n\n// NewTestWoodpeckerServer creates a WoodpeckerServer for e2e tests.\n// It is using a caller-supplied prometheus registry.\n// Use this in tests to avoid \"duplicate metrics collector registration\" panics when the server is created multiple times.\n// (promauto in NewWoodpeckerServer registers into the global default registry, which panics on duplicate names).\nfunc NewTestWoodpeckerServer(scheduler scheduler.Scheduler, logger logging.Log, store store.Store, registry *prometheus.Registry) proto.WoodpeckerServer {\n\tfactory := promauto.With(registry)\n\tpipelineTime := factory.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pipeline_time\",\n\t\tHelp:      \"Pipeline time.\",\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\tpipelineCount := factory.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: \"woodpecker\",\n\t\tName:      \"pipeline_count\",\n\t\tHelp:      \"Pipeline count.\",\n\t}, []string{\"repo\", \"branch\", \"status\", \"pipeline\"})\n\tpeer := RPC{\n\t\tstore:         store,\n\t\tscheduler:     scheduler,\n\t\tlogger:        logger,\n\t\tpipelineTime:  pipelineTime,\n\t\tpipelineCount: pipelineCount,\n\t}\n\treturn &WoodpeckerServer{peer: peer}\n}\n\n// Version returns the server- & grpc-version.\nfunc (s *WoodpeckerServer) Version(_ context.Context, _ *proto.Empty) (*proto.VersionResponse, error) {\n\treturn &proto.VersionResponse{\n\t\tGrpcVersion:   proto.Version,\n\t\tServerVersion: version.String(),\n\t}, nil\n}\n\n// Next blocks until it provides the next workflow to execute from the queue.\nfunc (s *WoodpeckerServer) Next(c context.Context, req *proto.NextRequest) (*proto.NextResponse, error) {\n\tfilter := rpc.Filter{\n\t\tLabels: req.GetFilter().GetLabels(),\n\t}\n\n\tres := new(proto.NextResponse)\n\tpipeline, err := s.peer.Next(c, filter)\n\tif err != nil || pipeline == nil {\n\t\treturn res, err\n\t}\n\n\tres.Workflow = new(proto.Workflow)\n\tres.Workflow.Id = pipeline.ID\n\tres.Workflow.Timeout = pipeline.Timeout\n\tres.Workflow.Payload, err = json.Marshal(pipeline.Config)\n\n\treturn res, err\n}\n\n// Init let agent signals to server the workflow is initialized.\nfunc (s *WoodpeckerServer) Init(c context.Context, req *proto.InitRequest) (*proto.Empty, error) {\n\tstate := rpc.WorkflowState{\n\t\tStarted:  req.GetState().GetStarted(),\n\t\tFinished: req.GetState().GetFinished(),\n\t\tError:    req.GetState().GetError(),\n\t}\n\tres := new(proto.Empty)\n\terr := s.peer.Init(c, req.GetId(), state)\n\treturn res, err\n}\n\n// Update let agent updates the step state at the server.\nfunc (s *WoodpeckerServer) Update(c context.Context, req *proto.UpdateRequest) (*proto.Empty, error) {\n\tstate := rpc.StepState{\n\t\tStepUUID: req.GetState().GetStepUuid(),\n\t\tStarted:  req.GetState().GetStarted(),\n\t\tFinished: req.GetState().GetFinished(),\n\t\tExited:   req.GetState().GetExited(),\n\t\tError:    req.GetState().GetError(),\n\t\tExitCode: int(req.GetState().GetExitCode()),\n\t\tCanceled: req.GetState().GetCanceled(),\n\t\tSkipped:  req.GetState().GetSkipped(),\n\t}\n\tres := new(proto.Empty)\n\terr := s.peer.Update(c, req.GetId(), state)\n\treturn res, err\n}\n\n// Done let agent signal to server the workflow has stopped.\nfunc (s *WoodpeckerServer) Done(c context.Context, req *proto.DoneRequest) (*proto.Empty, error) {\n\tstate := rpc.WorkflowState{\n\t\tStarted:  req.GetState().GetStarted(),\n\t\tFinished: req.GetState().GetFinished(),\n\t\tError:    req.GetState().GetError(),\n\t\tCanceled: req.GetState().GetCanceled(),\n\t}\n\tres := new(proto.Empty)\n\terr := s.peer.Done(c, req.GetId(), state)\n\treturn res, err\n}\n\n// Wait blocks until the workflow is complete.\n// Also signals via err if workflow got canceled.\nfunc (s *WoodpeckerServer) Wait(c context.Context, req *proto.WaitRequest) (*proto.WaitResponse, error) {\n\tres := new(proto.WaitResponse)\n\tcanceled, err := s.peer.Wait(c, req.GetId())\n\tres.Canceled = canceled\n\treturn res, err\n}\n\n// Extend extends the workflow deadline.\nfunc (s *WoodpeckerServer) Extend(c context.Context, req *proto.ExtendRequest) (*proto.Empty, error) {\n\tres := new(proto.Empty)\n\terr := s.peer.Extend(c, req.GetId())\n\treturn res, err\n}\n\nfunc (s *WoodpeckerServer) Log(c context.Context, req *proto.LogRequest) (*proto.Empty, error) {\n\tvar (\n\t\tentries  []*rpc.LogEntry\n\t\tstepUUID string\n\t)\n\n\twrite := func() error {\n\t\tif len(entries) > 0 {\n\t\t\tif err := s.peer.Log(c, stepUUID, entries); err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"could not write log entries\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tfor _, reqEntry := range req.GetLogEntries() {\n\t\tentry := &rpc.LogEntry{\n\t\t\tData:     reqEntry.GetData(),\n\t\t\tLine:     int(reqEntry.GetLine()),\n\t\t\tTime:     reqEntry.GetTime(),\n\t\t\tStepUUID: reqEntry.GetStepUuid(),\n\t\t\tType:     int(reqEntry.GetType()),\n\t\t}\n\t\tif entry.StepUUID != stepUUID {\n\t\t\t_ = write()\n\t\t\tstepUUID = entry.StepUUID\n\t\t\tentries = entries[:0]\n\t\t}\n\t\tentries = append(entries, entry)\n\t}\n\n\tres := new(proto.Empty)\n\terr := write()\n\treturn res, err\n}\n\n// RegisterAgent register our agent to the server.\nfunc (s *WoodpeckerServer) RegisterAgent(c context.Context, req *proto.RegisterAgentRequest) (*proto.RegisterAgentResponse, error) {\n\tres := new(proto.RegisterAgentResponse)\n\tagentInfo := req.GetInfo()\n\tagentID, err := s.peer.RegisterAgent(c, rpc.AgentInfo{\n\t\tVersion:      agentInfo.GetVersion(),\n\t\tPlatform:     agentInfo.GetPlatform(),\n\t\tBackend:      agentInfo.GetBackend(),\n\t\tCapacity:     int(agentInfo.GetCapacity()),\n\t\tCustomLabels: agentInfo.GetCustomLabels(),\n\t})\n\tres.AgentId = agentID\n\treturn res, err\n}\n\n// UnregisterAgent unregister our agent from the server.\nfunc (s *WoodpeckerServer) UnregisterAgent(ctx context.Context, _ *proto.Empty) (*proto.Empty, error) {\n\terr := s.peer.UnregisterAgent(ctx)\n\treturn new(proto.Empty), err\n}\n\n// ReportHealth reports health status of the agent to the server.\nfunc (s *WoodpeckerServer) ReportHealth(c context.Context, req *proto.ReportHealthRequest) (*proto.Empty, error) {\n\tres := new(proto.Empty)\n\terr := s.peer.ReportHealth(c, req.GetStatus())\n\treturn res, err\n}\n"
  },
  {
    "path": "server/scheduler/proxy.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n)\n\ntype proxy struct {\n\tq  queue.Queue\n\tps pubsub.PubSub\n}\n\n//\n// Queue.\n//\n\nfunc (p *proxy) Done(c context.Context, id string, exitStatus model.StatusValue) error {\n\treturn p.q.Done(c, id, exitStatus)\n}\n\nfunc (p *proxy) Error(c context.Context, id string, err error) error {\n\treturn p.q.Error(c, id, err)\n}\n\nfunc (p *proxy) ErrorAtOnce(c context.Context, ids []string, err error) error {\n\treturn p.q.ErrorAtOnce(c, ids, err)\n}\n\nfunc (p *proxy) Extend(c context.Context, agentID int64, workflowID string) error {\n\treturn p.q.Extend(c, agentID, workflowID)\n}\n\nfunc (p *proxy) Info(c context.Context) queue.InfoT {\n\treturn p.q.Info(c)\n}\n\nfunc (p *proxy) KickAgentWorkers(agentID int64) {\n\tp.q.KickAgentWorkers(agentID)\n}\n\nfunc (p *proxy) Pause() {\n\tp.q.Pause()\n}\n\nfunc (p *proxy) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) {\n\treturn p.q.Poll(c, agentID, f)\n}\n\nfunc (p *proxy) PushAtOnce(c context.Context, tasks []*model.Task) error {\n\treturn p.q.PushAtOnce(c, tasks)\n}\n\nfunc (p *proxy) Resume() {\n\tp.q.Resume()\n}\n\nfunc (p *proxy) Wait(c context.Context, id string) error {\n\treturn p.q.Wait(c, id)\n}\n\n//\n// PubSub.\n//\n\nfunc (p *proxy) Subscribe(c context.Context, t pubsub.Topics, r pubsub.Receiver) error {\n\treturn p.ps.Subscribe(c, t, r)\n}\n\nfunc (p *proxy) Publish(c context.Context, t pubsub.Topics, m pubsub.Message) error {\n\treturn p.ps.Publish(c, t, m)\n}\n"
  },
  {
    "path": "server/scheduler/scheduler.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/pubsub\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/queue\"\n)\n\n// Scheduler is currently just the combined interface of Queue & PubSub.\ntype Scheduler interface {\n\tqueue.Queue\n\tpubsub.PubSub\n}\n\nfunc NewScheduler(q queue.Queue, ps pubsub.PubSub) Scheduler {\n\treturn &proxy{\n\t\tq:  q,\n\t\tps: ps,\n\t}\n}\n"
  },
  {
    "path": "server/services/config/combined.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype combined struct {\n\tservices []Service\n}\n\nfunc NewCombined(services ...Service) Service {\n\treturn &combined{services: services}\n}\n\nfunc (c *combined) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (files []*types.FileMeta, err error) {\n\tfiles = oldConfigData\n\tfor _, s := range c.services {\n\t\tfiles, err = s.Fetch(ctx, forge, user, repo, pipeline, files, restart)\n\t}\n\n\treturn files, err\n}\n"
  },
  {
    "path": "server/services/config/combined_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config_test\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/yaronf/httpsign\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n)\n\nfunc TestFetchFromConfigService(t *testing.T) {\n\tt.Parallel()\n\n\ttype file struct {\n\t\tname string\n\t\tdata []byte\n\t}\n\n\tdummyData := []byte(\"TEST\")\n\n\ttestTable := []struct {\n\t\tname              string\n\t\trepoConfig        string\n\t\tfiles             []file\n\t\texpectedFileNames []string\n\t\texpectedError     bool\n\t}{\n\t\t{\n\t\t\tname:              \"External Fetch empty repo\",\n\t\t\trepoConfig:        \"\",\n\t\t\tfiles:             []file{},\n\t\t\texpectedFileNames: []string{\"override1\", \"override2\", \"override3\"},\n\t\t\texpectedError:     false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config - Additional sub-folders\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/sub-folder/config.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\"override1\", \"override2\", \"override3\"},\n\t\t\texpectedError:     false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Fetch empty\",\n\t\t\trepoConfig: \" \",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/.keep\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: nil,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{},\n\t\t\texpectedError:     true,\n\t\t},\n\t\t{\n\t\t\tname:       \"Use old config\",\n\t\t\trepoConfig: \".my-ci-folder/\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".my-ci-folder/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".my-ci-folder/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t}\n\n\tpubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)\n\tif err != nil {\n\t\tt.Fatal(\"can't generate ed25519 key pair\")\n\t}\n\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\t// check signature\n\t\tpubKeyID := \"woodpecker-ci-extensions\"\n\n\t\tverifier, err := httpsign.NewEd25519Verifier(pubEd25519Key,\n\t\t\thttpsign.NewVerifyConfig(),\n\t\t\thttpsign.Headers(\"@request-target\", \"content-digest\")) // The Content-Digest header will be auto-generated\n\t\tassert.NoError(t, err)\n\n\t\terr = httpsign.VerifyRequest(pubKeyID, *verifier, r)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Invalid signature\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\ttype config struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tData string `json:\"data\"`\n\t\t}\n\n\t\ttype incoming struct {\n\t\t\tRepo          *model.Repo     `json:\"repo\"`\n\t\t\tBuild         *model.Pipeline `json:\"pipeline\"`\n\t\t\tConfiguration []*config       `json:\"config\"`\n\t\t}\n\n\t\tvar req incoming\n\t\tbody, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"can't read body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\terr = json.Unmarshal(body, &req)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Failed to parse JSON\"+err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif req.Repo.Name == \"Fetch empty\" {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\tif req.Repo.Name == \"Use old config\" {\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t\treturn\n\t\t}\n\n\t\tfmt.Fprint(w, `{\n\t\t\t\"configs\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"override1\",\n\t\t\t\t\t\t\t\"data\": \"some new pipelineconfig \\n pipe, pipe, pipe\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"override2\",\n\t\t\t\t\t\t\t\"data\": \"some new pipelineconfig \\n pipe, pipe, pipe\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"override3\",\n\t\t\t\t\t\t\t\"data\": \"some new pipelineconfig \\n pipe, pipe, pipe\"\n\t\t\t\t\t}\n\t\t\t]\n}`)\n\t}\n\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tclient, err := utils.NewHTTPClient(privEd25519Key, \"loopback\")\n\trequire.NoError(t, err)\n\n\thttpFetcher := config.NewHTTP(ts.URL+\"/\", client, true)\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trepo := &model.Repo{Owner: \"laszlocph\", Name: tt.name, Config: tt.repoConfig} // Using test name as repo name to provide different responses in mock server\n\n\t\t\tf := new(mocks.MockForge)\n\t\t\tdirs := map[string][]*forge_types.FileMeta{}\n\t\t\tfor _, file := range tt.files {\n\t\t\t\tf.On(\"File\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, file.name).Return(file.data, nil)\n\t\t\t\tpath := filepath.Dir(file.name)\n\t\t\t\tif path != \".\" {\n\t\t\t\t\tdirs[path] = append(dirs[path], &forge_types.FileMeta{\n\t\t\t\t\t\tName: file.name,\n\t\t\t\t\t\tData: file.data,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor path, files := range dirs {\n\t\t\t\tf.On(\"Dir\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, path).Return(files, nil)\n\t\t\t}\n\n\t\t\t// if the previous mocks do not match return not found errors\n\t\t\tf.On(\"File\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf(\"file not found\"))\n\t\t\tf.On(\"Dir\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf(\"directory not found\"))\n\n\t\t\tf.On(\"Netrc\", mock.Anything, mock.Anything).Return(&model.Netrc{Machine: \"mock\", Login: \"mock\", Password: \"mock\"}, nil)\n\n\t\t\tforgeFetcher := config.NewForge(time.Second*3, 3)\n\t\t\tconfigFetcher := config.NewCombined(forgeFetcher, httpFetcher)\n\t\t\tfiles, err := configFetcher.Fetch(\n\t\t\t\tt.Context(),\n\t\t\t\tf,\n\t\t\t\t&model.User{AccessToken: \"xxx\"},\n\t\t\t\trepo,\n\t\t\t\t&model.Pipeline{Commit: \"89ab7b2d6bfb347144ac7c557e638ab402848fee\"},\n\t\t\t\t[]*forge_types.FileMeta{},\n\t\t\t\tfalse,\n\t\t\t)\n\t\t\tif tt.expectedError && err == nil {\n\t\t\t\tt.Fatal(\"expected an error\")\n\t\t\t} else if !tt.expectedError && err != nil {\n\t\t\t\tt.Fatal(\"error fetching config:\", err)\n\t\t\t}\n\n\t\t\tmatchingFiles := make([]string, len(files))\n\t\t\tfor i := range files {\n\t\t\t\tmatchingFiles[i] = files[i].Name\n\t\t\t}\n\t\t\tassert.ElementsMatch(t, tt.expectedFileNames, matchingFiles, \"expected some other pipeline files\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/services/config/forge.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/constant\"\n)\n\ntype forgeFetcher struct {\n\ttimeout    time.Duration\n\tretryCount uint\n}\n\nfunc NewForge(timeout time.Duration, retries uint) Service {\n\treturn &forgeFetcher{\n\t\ttimeout:    timeout,\n\t\tretryCount: retries,\n\t}\n}\n\nfunc (f *forgeFetcher) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (files []*types.FileMeta, err error) {\n\t// skip fetching if we are restarting and have the old config\n\tif restart && len(oldConfigData) > 0 {\n\t\treturn oldConfigData, nil\n\t}\n\n\tffc := &forgeFetcherContext{\n\t\tforge:    forge,\n\t\tuser:     user,\n\t\trepo:     repo,\n\t\tpipeline: pipeline,\n\t\ttimeout:  f.timeout,\n\t}\n\n\t// try to fetch multiple times\n\tfor i := 0; i < int(f.retryCount); i++ {\n\t\tfiles, err = ffc.fetch(ctx, strings.TrimSpace(repo.Config))\n\t\tif err != nil {\n\t\t\tlog.Trace().Err(err).Msgf(\"Fetching config files: Attempt #%d failed\", i+1)\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn files, err\n}\n\ntype forgeFetcherContext struct {\n\tforge    forge.Forge\n\tuser     *model.User\n\trepo     *model.Repo\n\tpipeline *model.Pipeline\n\ttimeout  time.Duration\n}\n\n// fetch attempts to fetch the configuration file(s) for the given config string.\nfunc (f *forgeFetcherContext) fetch(c context.Context, config string) ([]*types.FileMeta, error) {\n\tctx, cancel := context.WithTimeout(c, f.timeout)\n\tdefer cancel()\n\n\tif len(config) > 0 {\n\t\tlog.Trace().Msgf(\"configFetcher[%s]: use user config '%s'\", f.repo.FullName, config)\n\n\t\t// could be adapted to allow the user to supply a list like we do in the defaults\n\t\tconfigs := []string{config}\n\n\t\tfileMetas, err := f.getFirstAvailableConfig(ctx, configs)\n\t\tif err == nil {\n\t\t\treturn fileMetas, nil\n\t\t}\n\n\t\treturn nil, fmt.Errorf(\"user defined config '%s' not found: %w\", config, err)\n\t}\n\n\tlog.Trace().Msgf(\"configFetcher[%s]: user did not define own config, following default procedure\", f.repo.FullName)\n\t// for the order see shared/constants/constants.go\n\tfileMetas, err := f.getFirstAvailableConfig(ctx, constant.DefaultConfigOrder[:])\n\tif err == nil {\n\t\treturn fileMetas, nil\n\t}\n\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn nil, ctx.Err()\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"configFetcher: fallback did not find config: %w\", err)\n\t}\n}\n\nfunc filterPipelineFiles(files []*types.FileMeta) []*types.FileMeta {\n\tvar res []*types.FileMeta\n\n\tfor _, file := range files {\n\t\tif strings.HasSuffix(file.Name, \".yml\") || strings.HasSuffix(file.Name, \".yaml\") {\n\t\t\tres = append(res, file)\n\t\t}\n\t}\n\n\treturn res\n}\n\nfunc (f *forgeFetcherContext) checkPipelineFile(c context.Context, config string) ([]*types.FileMeta, error) {\n\tfile, err := f.forge.File(c, f.user, f.repo, f.pipeline, config)\n\n\tif err == nil && len(file) != 0 {\n\t\tlog.Trace().Msgf(\"configFetcher[%s]: found file '%s'\", f.repo.FullName, config)\n\n\t\treturn []*types.FileMeta{{\n\t\t\tName: config,\n\t\t\tData: file,\n\t\t}}, nil\n\t}\n\n\treturn nil, err\n}\n\nfunc (f *forgeFetcherContext) getFirstAvailableConfig(c context.Context, configs []string) ([]*types.FileMeta, error) {\n\tvar forgeErr []error\n\tfor _, fileOrFolder := range configs {\n\t\tlog.Trace().Msgf(\"fetching %s from forge\", fileOrFolder)\n\t\tif strings.HasSuffix(fileOrFolder, \"/\") {\n\t\t\t// config is a folder\n\t\t\tfiles, err := f.forge.Dir(c, f.user, f.repo, f.pipeline, strings.TrimSuffix(fileOrFolder, \"/\"))\n\t\t\t// if folder is not supported we will get a \"Not implemented\" error and continue\n\t\t\tif err != nil {\n\t\t\t\tif errors.Is(err, types.ErrNotImplemented) {\n\t\t\t\t\tlog.Debug().Msg(\"Could not fetch config folder as forge adapter did not implement it\")\n\t\t\t\t} else if !errors.Is(err, &types.ErrConfigNotFound{}) {\n\t\t\t\t\tlog.Error().Err(err).Str(\"repo\", f.repo.FullName).Str(\"user\", f.user.Login).Msgf(\"could not get folder from forge: %s\", err)\n\t\t\t\t\tforgeErr = append(forgeErr, err)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfiles = filterPipelineFiles(files)\n\t\t\tif len(files) != 0 {\n\t\t\t\tlog.Trace().Msgf(\"configFetcher[%s]: found %d files in '%s'\", f.repo.FullName, len(files), fileOrFolder)\n\t\t\t\treturn files, nil\n\t\t\t}\n\t\t}\n\n\t\t// config is a file\n\t\tif fileMeta, err := f.checkPipelineFile(c, fileOrFolder); err == nil {\n\t\t\tlog.Trace().Msgf(\"configFetcher[%s]: found file: '%s'\", f.repo.FullName, fileOrFolder)\n\t\t\treturn fileMeta, nil\n\t\t} else if !errors.Is(err, &types.ErrConfigNotFound{}) {\n\t\t\tforgeErr = append(forgeErr, err)\n\t\t}\n\t}\n\n\t// got unexpected errors\n\tif len(forgeErr) != 0 {\n\t\treturn nil, errors.Join(forgeErr...)\n\t}\n\n\t// nothing found\n\treturn nil, &types.ErrConfigNotFound{Configs: configs}\n}\n"
  },
  {
    "path": "server/services/config/forge_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config_test\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks\"\n\tforge_types \"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/config\"\n)\n\nfunc TestFetch(t *testing.T) {\n\tt.Parallel()\n\n\ttype file struct {\n\t\tname string\n\t\tdata []byte\n\t}\n\n\tdummyData := []byte(\"TEST\")\n\n\ttestTable := []struct {\n\t\tname              string\n\t\trepoConfig        string\n\t\tfiles             []file\n\t\texpectedFileNames []string\n\t\texpectedError     bool\n\t}{\n\t\t{\n\t\t\tname:       \"Default config - .woodpecker/\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/text.txt\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/release.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/image.png\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker/release.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config with .yaml - .woodpecker/\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/text.txt\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/release.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/image.png\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker/release.yaml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config with .yaml, .yml mix - .woodpecker/\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/text.txt\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/release.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/other.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/image.png\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker/release.yaml\",\n\t\t\t\t\".woodpecker/other.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config check .woodpecker.yaml before .woodpecker.yml\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker.yaml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Override via API with custom config\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Use old config on 204 response\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker.yaml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:              \"Default config - Empty repo\",\n\t\t\trepoConfig:        \"\",\n\t\t\tfiles:             []file{},\n\t\t\texpectedFileNames: []string{},\n\t\t\texpectedError:     true,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config - Additional sub-folders\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/sub-folder/config.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config - Additional none .yml files\",\n\t\t\trepoConfig: \"\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/notes.txt\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/image.png\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Default config - Empty Folder\",\n\t\t\trepoConfig: \" \",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/.keep\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: nil,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".woodpecker.yaml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Special config - folder (ignoring default files)\",\n\t\t\trepoConfig: \".my-ci-folder/\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".woodpecker/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".woodpecker.yaml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}, {\n\t\t\t\tname: \".my-ci-folder/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".my-ci-folder/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Special config - folder\",\n\t\t\trepoConfig: \".my-ci-folder/\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".my-ci-folder/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".my-ci-folder/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Special config - subfolder\",\n\t\t\trepoConfig: \".my-ci-folder/my-config/\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".my-ci-folder/my-config/test.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".my-ci-folder/my-config/test.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Special config - file\",\n\t\t\trepoConfig: \".config.yml\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".config.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".config.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:       \"Special config - file inside subfolder\",\n\t\t\trepoConfig: \".my-ci-folder/sub-folder/config.yml\",\n\t\t\tfiles: []file{{\n\t\t\t\tname: \".my-ci-folder/sub-folder/config.yml\",\n\t\t\t\tdata: dummyData,\n\t\t\t}},\n\t\t\texpectedFileNames: []string{\n\t\t\t\t\".my-ci-folder/sub-folder/config.yml\",\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:              \"Special config - empty repo\",\n\t\t\trepoConfig:        \".config.yml\",\n\t\t\tfiles:             []file{},\n\t\t\texpectedFileNames: []string{},\n\t\t\texpectedError:     true,\n\t\t},\n\t}\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trepo := &model.Repo{Owner: \"laszlocph\", Name: \"multipipeline\", Config: tt.repoConfig}\n\n\t\t\tf := new(mocks.MockForge)\n\t\t\tdirs := map[string][]*forge_types.FileMeta{}\n\t\t\tfor _, file := range tt.files {\n\t\t\t\tf.On(\"File\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, file.name).Once().Return(file.data, nil)\n\t\t\t\tpath := filepath.Dir(file.name)\n\t\t\t\tif path != \".\" {\n\t\t\t\t\tdirs[path] = append(dirs[path], &forge_types.FileMeta{\n\t\t\t\t\t\tName: file.name,\n\t\t\t\t\t\tData: file.data,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor path, files := range dirs {\n\t\t\t\tf.On(\"Dir\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, path).Once().Return(files, nil)\n\t\t\t}\n\n\t\t\t// if the previous mocks do not match return not found errors\n\t\t\tf.On(\"File\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf(\"file not found\"))\n\t\t\tf.On(\"Dir\", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf(\"directory not found\"))\n\n\t\t\tconfigFetcher := config.NewForge(\n\t\t\t\ttime.Second*3,\n\t\t\t\t3,\n\t\t\t)\n\t\t\tfiles, err := configFetcher.Fetch(\n\t\t\t\tt.Context(),\n\t\t\t\tf,\n\t\t\t\t&model.User{AccessToken: \"xxx\"},\n\t\t\t\trepo,\n\t\t\t\t&model.Pipeline{Commit: \"89ab7b2d6bfb347144ac7c557e638ab402848fee\"},\n\t\t\t\tnil,\n\t\t\t\tfalse,\n\t\t\t)\n\t\t\tif tt.expectedError && err == nil {\n\t\t\t\tt.Fatal(\"expected an error\")\n\t\t\t} else if !tt.expectedError && err != nil {\n\t\t\t\tt.Fatal(\"error fetching config:\", err)\n\t\t\t}\n\n\t\t\tmatchingFiles := make([]string, len(files))\n\t\t\tfor i := range files {\n\t\t\t\tmatchingFiles[i] = files[i].Name\n\t\t\t}\n\t\t\tassert.ElementsMatch(t, tt.expectedFileNames, matchingFiles, \"expected some other pipeline files\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/services/config/http.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n)\n\ntype httpService struct {\n\tendpoint     string\n\tclient       *utils.Client\n\tincludeNetrc bool\n}\n\n// configData same as forge.FileMeta but with json tags and string data.\ntype configData struct {\n\tName string `json:\"name\"`\n\tData string `json:\"data\"`\n}\n\ntype requestStructure struct {\n\tRepo          *model.Repo     `json:\"repo\"`\n\tPipeline      *model.Pipeline `json:\"pipeline\"`\n\tNetrc         *model.Netrc    `json:\"netrc\"`\n\tConfiguration []*configData   `json:\"configuration,omitempty\"`\n}\n\ntype responseStructure struct {\n\tConfigs []*configData `json:\"configs\"`\n}\n\nfunc NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) Service {\n\treturn &httpService{endpoint, client, includeNetrc}\n}\n\nfunc (h *httpService) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, _ bool) ([]*types.FileMeta, error) {\n\tconfiguration := make([]*configData, len(oldConfigData))\n\tfor i, oldConfig := range oldConfigData {\n\t\tconfiguration[i] = &configData{Name: oldConfig.Name, Data: string(oldConfig.Data)}\n\t}\n\n\tresponse := new(responseStructure)\n\tbody := requestStructure{\n\t\tRepo:          repo,\n\t\tPipeline:      pipeline,\n\t\tConfiguration: configuration,\n\t}\n\n\tif h.includeNetrc {\n\t\tnetrc, err := forge.Netrc(user, repo)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not get Netrc data from forge: %w\", err)\n\t\t}\n\t\tbody.Netrc = netrc\n\t}\n\n\tstatus, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response)\n\tif err != nil && status != http.StatusNoContent {\n\t\treturn nil, fmt.Errorf(\"failed to fetch config via http (status: %d): %w\", status, err)\n\t}\n\n\t// handle 204 - no new config available, return old config without error\n\tif status == http.StatusNoContent {\n\t\tlog.Debug().\n\t\t\tStr(\"endpoint\", h.endpoint).\n\t\t\tStr(\"repo\", repo.FullName).\n\t\t\tMsg(\"config endpoint returned 204 No Content, using fallback config\")\n\t\treturn oldConfigData, nil\n\t}\n\n\t// unexpected non-success status code\n\tif status != http.StatusOK {\n\t\treturn oldConfigData, fmt.Errorf(\"unexpected status code %d from config endpoint (expected 200 or 204)\", status)\n\t}\n\n\tfileMetaList := make([]*types.FileMeta, len(response.Configs))\n\tfor i, config := range response.Configs {\n\t\tfileMetaList[i] = &types.FileMeta{Name: config.Name, Data: []byte(config.Data)}\n\t}\n\n\treturn fileMetaList, nil\n}\n"
  },
  {
    "path": "server/services/config/mocks/mock_Service.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockService {\n\tmock := &MockService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockService is an autogenerated mock type for the Service type\ntype MockService struct {\n\tmock.Mock\n}\n\ntype MockService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockService) EXPECT() *MockService_Expecter {\n\treturn &MockService_Expecter{mock: &_m.Mock}\n}\n\n// Fetch provides a mock function for the type MockService\nfunc (_mock *MockService) Fetch(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) ([]*types.FileMeta, error) {\n\tret := _mock.Called(ctx, forge1, user, repo, pipeline, oldConfigData, restart)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Fetch\")\n\t}\n\n\tvar r0 []*types.FileMeta\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) ([]*types.FileMeta, error)); ok {\n\t\treturn returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) []*types.FileMeta); ok {\n\t\tr0 = returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*types.FileMeta)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) error); ok {\n\t\tr1 = returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_Fetch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fetch'\ntype MockService_Fetch_Call struct {\n\t*mock.Call\n}\n\n// Fetch is a helper method to define mock.On call\n//   - ctx context.Context\n//   - forge1 forge.Forge\n//   - user *model.User\n//   - repo *model.Repo\n//   - pipeline *model.Pipeline\n//   - oldConfigData []*types.FileMeta\n//   - restart bool\nfunc (_e *MockService_Expecter) Fetch(ctx interface{}, forge1 interface{}, user interface{}, repo interface{}, pipeline interface{}, oldConfigData interface{}, restart interface{}) *MockService_Fetch_Call {\n\treturn &MockService_Fetch_Call{Call: _e.mock.On(\"Fetch\", ctx, forge1, user, repo, pipeline, oldConfigData, restart)}\n}\n\nfunc (_c *MockService_Fetch_Call) Run(run func(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool)) *MockService_Fetch_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 forge.Forge\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(forge.Forge)\n\t\t}\n\t\tvar arg2 *model.User\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.User)\n\t\t}\n\t\tvar arg3 *model.Repo\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Repo)\n\t\t}\n\t\tvar arg4 *model.Pipeline\n\t\tif args[4] != nil {\n\t\t\targ4 = args[4].(*model.Pipeline)\n\t\t}\n\t\tvar arg5 []*types.FileMeta\n\t\tif args[5] != nil {\n\t\t\targ5 = args[5].([]*types.FileMeta)\n\t\t}\n\t\tvar arg6 bool\n\t\tif args[6] != nil {\n\t\t\targ6 = args[6].(bool)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t\targ4,\n\t\t\targ5,\n\t\t\targ6,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_Fetch_Call) Return(configData []*types.FileMeta, err error) *MockService_Fetch_Call {\n\t_c.Call.Return(configData, err)\n\treturn _c\n}\n\nfunc (_c *MockService_Fetch_Call) RunAndReturn(run func(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) ([]*types.FileMeta, error)) *MockService_Fetch_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/config/service.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype Service interface {\n\tFetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (configData []*types.FileMeta, err error)\n}\n"
  },
  {
    "path": "server/services/encryption/aes.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"crypto/cipher\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype aesEncryptionService struct {\n\tcipher  cipher.AEAD\n\tkeyID   string\n\tstore   store.Store\n\tclients []types.EncryptionClient\n}\n\nfunc (svc *aesEncryptionService) Encrypt(plaintext, associatedData string) (string, error) {\n\tmsg := []byte(plaintext)\n\taad := []byte(associatedData)\n\n\tnonce := random.GetRandomBytes(uint32(AES_GCM_SIV_NonceSize))\n\tciphertext := svc.cipher.Seal(nil, nonce, msg, aad)\n\n\tresult := make([]byte, 0, AES_GCM_SIV_NonceSize+len(ciphertext))\n\tresult = append(result, nonce...)\n\tresult = append(result, ciphertext...)\n\n\treturn base64.StdEncoding.EncodeToString(result), nil\n}\n\nfunc (svc *aesEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) {\n\tbytes, err := base64.StdEncoding.DecodeString(ciphertext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(errTemplateBase64DecryptionFailed, err)\n\t}\n\n\tnonce := bytes[:AES_GCM_SIV_NonceSize]\n\tmessage := bytes[AES_GCM_SIV_NonceSize:]\n\n\tplaintext, err := svc.cipher.Open(nil, nonce, message, []byte(associatedData))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(errTemplateDecryptionFailed, err)\n\t}\n\treturn string(plaintext), nil\n}\n\nfunc (svc *aesEncryptionService) Disable() error {\n\treturn svc.disable()\n}\n"
  },
  {
    "path": "server/services/encryption/aes_builder.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype aesConfiguration struct {\n\tpassword string\n\tstore    store.Store\n\tclients  []types.EncryptionClient\n}\n\nfunc newAES(c *cli.Command, s store.Store) types.EncryptionServiceBuilder {\n\tkey := c.String(rawKeyConfigFlag)\n\treturn &aesConfiguration{key, s, nil}\n}\n\nfunc (c aesConfiguration) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder {\n\tc.clients = clients\n\treturn c\n}\n\nfunc (c aesConfiguration) Build() (types.EncryptionService, error) {\n\tsvc := &aesEncryptionService{\n\t\tcipher:  nil,\n\t\tstore:   c.store,\n\t\tclients: c.clients,\n\t}\n\terr := svc.initClients()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateFailedInitializingClients, err)\n\t}\n\n\terr = svc.loadCipher(c.password)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateAesFailedLoadingCipher, err)\n\t}\n\n\terr = svc.validateKey()\n\tif errors.Is(err, errEncryptionNotEnabled) {\n\t\terr = svc.enable()\n\t}\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateFailedValidatingKey, err)\n\t}\n\treturn svc, nil\n}\n"
  },
  {
    "path": "server/services/encryption/aes_encryption.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"golang.org/x/crypto/sha3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (svc *aesEncryptionService) loadCipher(password string) error {\n\tkey, err := svc.hash([]byte(password))\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateAesFailedGeneratingKey, err)\n\t}\n\tkeyHash, err := bcrypt.GenerateFromPassword(key, bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateAesFailedGeneratingKeyID, err)\n\t}\n\tsvc.keyID = string(keyHash)\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateAesFailedLoadingCipher, err)\n\t}\n\n\taead, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateAesFailedLoadingCipher, err)\n\t}\n\tsvc.cipher = aead\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) validateKey() error {\n\tciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn errEncryptionNotEnabled\n\t} else if err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedLoadingServerConfig, err)\n\t}\n\n\tplaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData)\n\tif plaintext != svc.keyID {\n\t\treturn errEncryptionKeyInvalid\n\t} else if err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) hash(data []byte) ([]byte, error) {\n\tresult := make([]byte, 32)\n\tsha := sha3.NewShake256()\n\n\t_, err := sha.Write(data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err)\n\t}\n\t_, err = sha.Read(result)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "server/services/encryption/aes_state.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n)\n\nfunc (svc *aesEncryptionService) initClients() error {\n\tfor _, client := range svc.clients {\n\t\terr := client.SetEncryptionService(svc)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(errTemplateFailedInitializingClients, err)\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsInitialized)\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) enable() error {\n\terr := svc.callbackOnEnable()\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedEnablingEncryption, err)\n\t}\n\terr = svc.updateCiphertextSample()\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedEnablingEncryption, err)\n\t}\n\tlog.Warn().Msg(logMessageEncryptionEnabled)\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) disable() error {\n\terr := svc.callbackOnDisable()\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedDisablingEncryption, err)\n\t}\n\terr = svc.deleteCiphertextSample()\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedDisablingEncryption, err)\n\t}\n\tlog.Warn().Msg(logMessageEncryptionDisabled)\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) updateCiphertextSample() error {\n\tciphertext, err := svc.Encrypt(svc.keyID, keyIDAssociatedData)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\terr = svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\tlog.Info().Msg(logMessageEncryptionKeyRegistered)\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) deleteCiphertextSample() error {\n\terr := svc.store.ServerConfigDelete(ciphertextSampleConfigKey)\n\tif err != nil {\n\t\terr = fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\treturn err\n}\n\nfunc (svc *aesEncryptionService) callbackOnEnable() error {\n\tfor _, client := range svc.clients {\n\t\terr := client.EnableEncryption()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(errTemplateFailedEnablingEncryption, err)\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsEnabled)\n\treturn nil\n}\n\nfunc (svc *aesEncryptionService) callbackOnDisable() error {\n\tfor _, client := range svc.clients {\n\t\terr := client.MigrateEncryption(&noEncryption{})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(errTemplateFailedDisablingEncryption, err)\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageEncryptionDisabled)\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/encryption/aes_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"encoding/base64\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/tink-crypto/tink-go/v2/subtle/random\"\n)\n\nfunc TestShortMessageLongKey(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\tassert.NoError(t, err)\n\n\tinput := string(random.GetRandomBytes(4))\n\tcipher, err := aes.Encrypt(input, \"\")\n\tassert.NoError(t, err)\n\n\toutput, err := aes.Decrypt(cipher, \"\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, input, output)\n}\n\nfunc TestLongMessageShortKey(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(12)))\n\tassert.NoError(t, err)\n\n\tinput := string(random.GetRandomBytes(1024))\n\tcipher, err := aes.Encrypt(input, \"\")\n\tassert.NoError(t, err)\n\n\toutput, err := aes.Decrypt(cipher, \"\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, input, output)\n}\n\nfunc TestEncryptDecryptWithAssociatedData(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\trequire.NoError(t, err)\n\n\tplaintext := \"secret-value-12345\"\n\tassociatedData := \"repo:123\"\n\n\tciphertext, err := aes.Encrypt(plaintext, associatedData)\n\trequire.NoError(t, err)\n\n\t// Decrypt with correct associated data should succeed\n\tdecrypted, err := aes.Decrypt(ciphertext, associatedData)\n\trequire.NoError(t, err)\n\tassert.Equal(t, plaintext, decrypted)\n\n\t// Decrypt with wrong associated data should fail\n\t_, err = aes.Decrypt(ciphertext, \"repo:456\")\n\tassert.Error(t, err, \"decryption should fail with wrong associated data\")\n\n\t// Decrypt with empty associated data should fail\n\t_, err = aes.Decrypt(ciphertext, \"\")\n\tassert.Error(t, err, \"decryption should fail with missing associated data\")\n}\n\nfunc TestEncryptProducesUniqueCiphertexts(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\trequire.NoError(t, err)\n\n\tplaintext := \"same-message\"\n\tciphertexts := make(map[string]bool)\n\n\t// Encrypt the same message multiple times\n\tfor range 100 {\n\t\tct, err := aes.Encrypt(plaintext, \"\")\n\t\trequire.NoError(t, err)\n\t\tassert.False(t, ciphertexts[ct], \"ciphertext should be unique due to random nonce\")\n\t\tciphertexts[ct] = true\n\t}\n}\n\nfunc TestDecryptTamperedCiphertext(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\trequire.NoError(t, err)\n\n\tplaintext := \"sensitive-data\"\n\tciphertext, err := aes.Encrypt(plaintext, \"\")\n\trequire.NoError(t, err)\n\n\t// Decode, tamper, re-encode\n\tdecoded, err := base64.StdEncoding.DecodeString(ciphertext)\n\trequire.NoError(t, err)\n\n\t// Tamper with the ciphertext (flip a bit in the middle)\n\tif len(decoded) > AES_GCM_SIV_NonceSize+1 {\n\t\tdecoded[AES_GCM_SIV_NonceSize+1] ^= 0xFF\n\t}\n\ttampered := base64.StdEncoding.EncodeToString(decoded)\n\n\t_, err = aes.Decrypt(tampered, \"\")\n\tassert.Error(t, err, \"decryption of tampered ciphertext should fail\")\n}\n\nfunc TestDecryptInvalidBase64(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\trequire.NoError(t, err)\n\n\t_, err = aes.Decrypt(\"not-valid-base64!!!\", \"\")\n\tassert.Error(t, err, \"decryption of invalid base64 should fail\")\n}\n\nfunc TestDecryptTruncatedCiphertext(t *testing.T) {\n\taes := &aesEncryptionService{}\n\terr := aes.loadCipher(string(random.GetRandomBytes(32)))\n\trequire.NoError(t, err)\n\n\tplaintext := \"test-message\"\n\tciphertext, err := aes.Encrypt(plaintext, \"\")\n\trequire.NoError(t, err)\n\n\t// Truncate the ciphertext\n\tdecoded, err := base64.StdEncoding.DecodeString(ciphertext)\n\trequire.NoError(t, err)\n\n\ttruncated := base64.StdEncoding.EncodeToString(decoded[:len(decoded)/2])\n\t_, err = aes.Decrypt(truncated, \"\")\n\tassert.Error(t, err, \"decryption of truncated ciphertext should fail\")\n}\n\nfunc TestRandomBytesUniqueness(t *testing.T) {\n\tseen := make(map[string]bool)\n\n\tfor range 1000 {\n\t\tbytes := random.GetRandomBytes(32)\n\t\tkey := string(bytes)\n\t\tassert.False(t, seen[key], \"random bytes should be unique\")\n\t\tseen[key] = true\n\t}\n}\n\nfunc TestRandomBytesLength(t *testing.T) {\n\ttests := []uint32{1, 12, 16, 32, 64, 128, 256}\n\n\tfor _, length := range tests {\n\t\tbytes := random.GetRandomBytes(length)\n\t\tassert.Len(t, bytes, int(length), \"random bytes should have requested length\")\n\t}\n}\n"
  },
  {
    "path": "server/services/encryption/constants.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport \"errors\"\n\n// Common.\nconst (\n\trawKeyConfigFlag             = \"encryption-raw-key\"\n\ttinkKeysetFilepathConfigFlag = \"encryption-tink-keyset\"\n\tdisableEncryptionConfigFlag  = \"encryption-disable-flag\"\n\n\tciphertextSampleConfigKey = \"encryption-ciphertext-sample\"\n\n\tkeyTypeTink = \"tink\"\n\tkeyTypeRaw  = \"raw\"\n\tkeyTypeNone = \"none\"\n\n\tkeyIDAssociatedData   = \"Primary key id\"\n\tAES_GCM_SIV_NonceSize = 12 //nolint:revive\n)\n\nvar (\n\terrEncryptionNotEnabled = errors.New(\"encryption is not enabled\")\n\terrEncryptionKeyInvalid = errors.New(\"encryption key is invalid\")\n\terrEncryptionKeyRotated = errors.New(\"encryption key is being rotated\")\n)\n\nconst (\n\t// Error wrapping templates.\n\terrTemplateFailedInitializingUnencrypted = \"failed initializing server in unencrypted mode: %w\"\n\terrTemplateFailedInitializing            = \"failed initializing encryption service: %w\"\n\terrTemplateFailedEnablingEncryption      = \"failed enabling encryption: %w\"\n\terrTemplateFailedRotatingEncryption      = \"failed rotating encryption: %w\"\n\terrTemplateFailedDisablingEncryption     = \"failed disabling encryption: %w\"\n\terrTemplateFailedLoadingServerConfig     = \"failed to load server encryption config: %w\"\n\terrTemplateFailedUpdatingServerConfig    = \"failed updating server encryption configuration: %w\"\n\terrTemplateFailedInitializingClients     = \"failed initializing encryption clients: %w\"\n\terrTemplateFailedValidatingKey           = \"failed validating encryption key: %w\"\n\terrTemplateEncryptionFailed              = \"encryption error: %w\"\n\terrTemplateBase64DecryptionFailed        = \"decryption error: Base64 decryption failed. Cause: %w\"\n\terrTemplateDecryptionFailed              = \"decryption error: %w\"\n\n\t// Error messages.\n\terrMessageTemplateUnsupportedKeyType = \"unsupported encryption key type: %s\"\n\terrMessageCantUseBothServices        = \"cannot use raw encryption key and tink keyset at the same time\"\n\terrMessageNoKeysProvided             = \"encryption enabled but no keys provided\"\n\terrMessageFailedRotatingEncryption   = \"failed rotating encryption\"\n\n\t// Log messages.\n\tlogMessageEncryptionEnabled       = \"encryption enabled\"\n\tlogMessageEncryptionDisabled      = \"encryption disabled\"\n\tlogMessageEncryptionKeyRegistered = \"registered new encryption key\"\n\tlogMessageClientsInitialized      = \"initialized encryption on registered clients\"\n\tlogMessageClientsEnabled          = \"enabled encryption on registered service\"\n\tlogMessageClientsRotated          = \"updated encryption key on registered service\"\n\tlogMessageClientsDecrypted        = \"disabled encryption on registered service\"\n)\n\n// Tink.\nconst (\n\t// Error wrapping templates.\n\terrTemplateTinkFailedLoadingKeyset              = \"failed loading encryption keyset: %w\"\n\terrTemplateTinkFailedValidatingKeyset           = \"failed validating encryption keyset: %w\"\n\terrTemplateTinkFailedInitializeFileWatcher      = \"failed initializing keyset file watcher: %w\"\n\terrTemplateTinkFailedSubscribeKeysetFileChanges = \"failed subscribing on encryption keyset file changes: %w\"\n\terrTemplateTinkFailedOpeningKeyset              = \"failed opening encryption keyset file: %w\"\n\terrTemplateTinkFailedReadingKeyset              = \"failed reading encryption keyset from file: %w\"\n\terrTemplateTinkFailedInitializingAEAD           = \"failed initializing AEAD instance: %w\"\n\n\t// Error messages.\n\terrMessageTinkKeysetFileWatchFailed = \"failed watching encryption keyset file changes\"\n\n\t// Log message templates.\n\tlogTemplateTinkKeysetFileChanged       = \"changes detected in encryption keyset file: '%s'. Encryption service will be reloaded\"\n\tlogTemplateTinkLoadingKeyset           = \"loading encryption keyset from file: %s\"\n\tlogTemplateTinkFailedClosingKeysetFile = \"could not close keyset file: %s\"\n)\n\n// AES.\nconst (\n\t// Error wrapping templates.\n\terrTemplateAesFailedLoadingCipher   = \"failed loading encryption cipher: %w\"\n\terrTemplateAesFailedCalculatingHash = \"failed calculating hash: %w\"\n\terrTemplateAesFailedGeneratingKey   = \"failed generating key from passphrase: %w\"\n\terrTemplateAesFailedGeneratingKeyID = \"failed generating key id: %w\"\n)\n"
  },
  {
    "path": "server/services/encryption/encryption.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype builder struct {\n\tstore   store.Store\n\tc       *cli.Command\n\tclients []types.EncryptionClient\n}\n\nfunc Encryption(c *cli.Command, s store.Store) types.EncryptionBuilder {\n\treturn &builder{store: s, c: c}\n}\n\nfunc (b builder) WithClient(client types.EncryptionClient) types.EncryptionBuilder {\n\tb.clients = append(b.clients, client)\n\treturn b\n}\n\nfunc (b builder) Build() error {\n\tenabled, err := b.isEnabled()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdisableFlag := b.c.Bool(disableEncryptionConfigFlag)\n\n\tkeyType, err := b.detectKeyType()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !enabled && (disableFlag || keyType == keyTypeNone) {\n\t\t_, err := noEncryptionBuilder{}.WithClients(b.clients).Build()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(errTemplateFailedInitializingUnencrypted, err)\n\t\t}\n\t}\n\tsvc, err := b.getService(keyType)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedInitializing, err)\n\t}\n\n\tif disableFlag {\n\t\terr := svc.Disable()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/encryption/encryption_builder.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\tstore_types \"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (b builder) getService(keyType string) (types.EncryptionService, error) {\n\tif keyType == keyTypeNone {\n\t\treturn nil, errors.New(errMessageNoKeysProvided)\n\t}\n\n\tbuilder, err := b.serviceBuilder(keyType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsvc, err := builder.WithClients(b.clients).Build()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn svc, nil\n}\n\nfunc (b builder) isEnabled() (bool, error) {\n\t_, err := b.store.ServerConfigGet(ciphertextSampleConfigKey)\n\tif err != nil && !errors.Is(err, store_types.ErrRecordNotExist) {\n\t\treturn false, fmt.Errorf(errTemplateFailedLoadingServerConfig, err)\n\t}\n\treturn err == nil, nil\n}\n\nfunc (b builder) detectKeyType() (string, error) {\n\trawKeyPresent := b.c.IsSet(rawKeyConfigFlag)\n\ttinkKeysetPresent := b.c.IsSet(tinkKeysetFilepathConfigFlag)\n\tswitch {\n\tcase rawKeyPresent && tinkKeysetPresent:\n\t\treturn \"\", errors.New(errMessageCantUseBothServices)\n\tcase rawKeyPresent:\n\t\treturn keyTypeRaw, nil\n\tcase tinkKeysetPresent:\n\t\treturn keyTypeTink, nil\n\t}\n\treturn keyTypeNone, nil\n}\n\nfunc (b builder) serviceBuilder(keyType string) (types.EncryptionServiceBuilder, error) {\n\tswitch keyType {\n\tcase keyTypeTink:\n\t\treturn newTink(b.c, b.store), nil\n\tcase keyTypeRaw:\n\t\treturn newAES(b.c, b.store), nil\n\tcase keyTypeNone:\n\t\treturn &noEncryptionBuilder{}, nil\n\t}\n\treturn nil, fmt.Errorf(errMessageTemplateUnsupportedKeyType, keyType)\n}\n"
  },
  {
    "path": "server/services/encryption/no_encryption.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\ntype noEncryptionBuilder struct {\n\tclients []types.EncryptionClient\n}\n\nfunc (b noEncryptionBuilder) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder {\n\tb.clients = clients\n\treturn b\n}\n\nfunc (b noEncryptionBuilder) Build() (types.EncryptionService, error) {\n\tsvc := &noEncryption{}\n\tfor _, client := range b.clients {\n\t\terr := client.SetEncryptionService(svc)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn svc, nil\n}\n\ntype noEncryption struct{}\n\nfunc (svc *noEncryption) Encrypt(plaintext, _ string) (string, error) {\n\treturn plaintext, nil\n}\n\nfunc (svc *noEncryption) Decrypt(ciphertext, _ string) (string, error) {\n\treturn ciphertext, nil\n}\n\nfunc (svc *noEncryption) Disable() error {\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/encryption/tink.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"encoding/base64\"\n\t\"fmt\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/tink-crypto/tink-go/v2/tink\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype tinkEncryptionService struct {\n\tkeysetFilePath    string\n\tprimaryKeyID      string\n\tencryption        tink.AEAD\n\tstore             store.Store\n\tkeysetFileWatcher *fsnotify.Watcher\n\tclients           []types.EncryptionClient\n}\n\nfunc (svc *tinkEncryptionService) Encrypt(plaintext, associatedData string) (string, error) {\n\tmsg := []byte(plaintext)\n\taad := []byte(associatedData)\n\tciphertext, err := svc.encryption.Encrypt(msg, aad)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(errTemplateEncryptionFailed, err)\n\t}\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\nfunc (svc *tinkEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) {\n\tct, err := base64.StdEncoding.DecodeString(ciphertext)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(errTemplateBase64DecryptionFailed, err)\n\t}\n\n\tplaintext, err := svc.encryption.Decrypt(ct, []byte(associatedData))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(errTemplateDecryptionFailed, err)\n\t}\n\treturn string(plaintext), nil\n}\n\nfunc (svc *tinkEncryptionService) Disable() error {\n\treturn svc.disable()\n}\n"
  },
  {
    "path": "server/services/encryption/tink_builder.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype tinkConfiguration struct {\n\tkeysetFilePath string\n\tstore          store.Store\n\tclients        []types.EncryptionClient\n}\n\nfunc newTink(c *cli.Command, s store.Store) types.EncryptionServiceBuilder {\n\tfilepath := c.String(tinkKeysetFilepathConfigFlag)\n\treturn &tinkConfiguration{filepath, s, nil}\n}\n\nfunc (c tinkConfiguration) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder {\n\tc.clients = clients\n\treturn c\n}\n\nfunc (c tinkConfiguration) Build() (types.EncryptionService, error) {\n\tsvc := &tinkEncryptionService{\n\t\tkeysetFilePath:    c.keysetFilePath,\n\t\tprimaryKeyID:      \"\",\n\t\tencryption:        nil,\n\t\tstore:             c.store,\n\t\tkeysetFileWatcher: nil,\n\t\tclients:           c.clients,\n\t}\n\n\tif err := svc.initClients(); err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateFailedInitializingClients, err)\n\t}\n\n\tif err := svc.loadKeyset(); err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateTinkFailedLoadingKeyset, err)\n\t}\n\n\terr := svc.validateKeyset()\n\tif errors.Is(err, errEncryptionNotEnabled) {\n\t\terr = svc.enable()\n\t} else if errors.Is(err, errEncryptionKeyRotated) {\n\t\terr = svc.rotate()\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateTinkFailedValidatingKeyset, err)\n\t}\n\n\tif err := svc.initFileWatcher(); err != nil {\n\t\treturn nil, fmt.Errorf(errTemplateTinkFailedInitializeFileWatcher, err)\n\t}\n\treturn svc, nil\n}\n"
  },
  {
    "path": "server/services/encryption/tink_keyset.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/tink-crypto/tink-go/v2/aead\"\n\tinsecure_clear_text_keyset \"github.com/tink-crypto/tink-go/v2/insecurecleartextkeyset\"\n\t\"github.com/tink-crypto/tink-go/v2/keyset\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (svc *tinkEncryptionService) loadKeyset() error {\n\tlog.Warn().Msgf(logTemplateTinkLoadingKeyset, svc.keysetFilePath)\n\tfile, err := os.Open(svc.keysetFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateTinkFailedOpeningKeyset, err)\n\t}\n\tdefer func(file *os.File) {\n\t\terr = file.Close()\n\t\tif err != nil {\n\t\t\tlog.Error().Err(err).Msgf(logTemplateTinkFailedClosingKeysetFile, svc.keysetFilePath)\n\t\t}\n\t}(file)\n\n\tjsonKeyset := keyset.NewJSONReader(file)\n\tkeysetHandle, err := insecure_clear_text_keyset.Read(jsonKeyset)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateTinkFailedReadingKeyset, err)\n\t}\n\tsvc.primaryKeyID = strconv.FormatUint(uint64(keysetHandle.KeysetInfo().PrimaryKeyId), 10)\n\n\tencryptionInstance, err := aead.New(keysetHandle)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateTinkFailedInitializingAEAD, err)\n\t}\n\tsvc.encryption = encryptionInstance\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) validateKeyset() error {\n\tciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn errEncryptionNotEnabled\n\t} else if err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedLoadingServerConfig, err)\n\t}\n\n\tplaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData)\n\tif plaintext != svc.primaryKeyID {\n\t\treturn errEncryptionKeyRotated\n\t} else if err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedValidatingKey, err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/encryption/tink_keyset_watcher.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/rs/zerolog/log\"\n)\n\n// Watch keyset file events to detect key rotations and hot reload keys.\nfunc (svc *tinkEncryptionService) initFileWatcher() error {\n\twatcher, err := fsnotify.NewWatcher()\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err)\n\t}\n\terr = watcher.Add(svc.keysetFilePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err)\n\t}\n\n\tsvc.keysetFileWatcher = watcher\n\tgo svc.handleFileEvents()\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) handleFileEvents() {\n\tfor {\n\t\tselect {\n\t\tcase event, ok := <-svc.keysetFileWatcher.Events:\n\t\t\tif !ok {\n\t\t\t\tlog.Fatal().Msg(errMessageTinkKeysetFileWatchFailed) //nolint:forbidigo\n\t\t\t}\n\t\t\tif (event.Op == fsnotify.Write) || (event.Op == fsnotify.Create) {\n\t\t\t\tlog.Warn().Msgf(logTemplateTinkKeysetFileChanged, event.Name)\n\t\t\t\terr := svc.rotate()\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Fatal().Err(err).Msg(errMessageFailedRotatingEncryption) //nolint:forbidigo\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\tcase err, ok := <-svc.keysetFileWatcher.Errors:\n\t\t\tif !ok {\n\t\t\t\tlog.Fatal().Err(err).Msg(errMessageTinkKeysetFileWatchFailed) //nolint:forbidigo\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "server/services/encryption/tink_state.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage encryption\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n)\n\nfunc (svc *tinkEncryptionService) enable() error {\n\tif err := svc.callbackOnEnable(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedEnablingEncryption, err)\n\t}\n\n\tif err := svc.updateCiphertextSample(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedEnablingEncryption, err)\n\t}\n\n\tlog.Warn().Msg(logMessageEncryptionEnabled)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) disable() error {\n\tif err := svc.callbackOnDisable(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedDisablingEncryption, err)\n\t}\n\n\tif err := svc.deleteCiphertextSample(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedDisablingEncryption, err)\n\t}\n\n\tlog.Warn().Msg(logMessageEncryptionDisabled)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) rotate() error {\n\tnewSvc := &tinkEncryptionService{\n\t\tkeysetFilePath:    svc.keysetFilePath,\n\t\tprimaryKeyID:      \"\",\n\t\tencryption:        nil,\n\t\tstore:             svc.store,\n\t\tkeysetFileWatcher: nil,\n\t\tclients:           svc.clients,\n\t}\n\n\tif err := newSvc.loadKeyset(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedRotatingEncryption, err)\n\t}\n\n\terr := newSvc.validateKeyset()\n\tif errors.Is(err, errEncryptionKeyRotated) {\n\t\terr = newSvc.updateCiphertextSample()\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedRotatingEncryption, err)\n\t}\n\n\tif err := newSvc.callbackOnRotation(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedRotatingEncryption, err)\n\t}\n\n\tif err := newSvc.initFileWatcher(); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedRotatingEncryption, err)\n\t}\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) updateCiphertextSample() error {\n\tciphertext, err := svc.Encrypt(svc.primaryKeyID, keyIDAssociatedData)\n\tif err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\n\tif err := svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\n\tlog.Info().Msg(logMessageEncryptionKeyRegistered)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) deleteCiphertextSample() error {\n\tif err := svc.store.ServerConfigDelete(ciphertextSampleConfigKey); err != nil {\n\t\treturn fmt.Errorf(errTemplateFailedUpdatingServerConfig, err)\n\t}\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) initClients() error {\n\tfor _, client := range svc.clients {\n\t\tif err := client.SetEncryptionService(svc); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsInitialized)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) callbackOnEnable() error {\n\tfor _, client := range svc.clients {\n\t\tif err := client.EnableEncryption(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsEnabled)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) callbackOnRotation() error {\n\tfor _, client := range svc.clients {\n\t\tif err := client.MigrateEncryption(svc); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsRotated)\n\treturn nil\n}\n\nfunc (svc *tinkEncryptionService) callbackOnDisable() error {\n\tfor _, client := range svc.clients {\n\t\tif err := client.MigrateEncryption(&noEncryption{}); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Info().Msg(logMessageClientsDecrypted)\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/encryption/types/encryption.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\n// EncryptionBuilder is user API to obtain correctly configured encryption.\ntype EncryptionBuilder interface {\n\tWithClient(client EncryptionClient) EncryptionBuilder\n\tBuild() error\n}\n\ntype EncryptionServiceBuilder interface {\n\tWithClients(clients []EncryptionClient) EncryptionServiceBuilder\n\tBuild() (EncryptionService, error)\n}\n\ntype EncryptionService interface {\n\tEncrypt(plaintext, associatedData string) (string, error)\n\tDecrypt(ciphertext, associatedData string) (string, error)\n\tDisable() error\n}\n\ntype EncryptionClient interface {\n\t// SetEncryptionService should be used only by EncryptionServiceBuilder\n\tSetEncryptionService(encryption EncryptionService) error\n\t// EnableEncryption should encrypt all service data\n\tEnableEncryption() error\n\t// MigrateEncryption should decrypt all existing data and encrypt it with new encryption service\n\tMigrateEncryption(newEncryption EncryptionService) error\n}\n"
  },
  {
    "path": "server/services/encryption/wrapper/store/constants.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nconst (\n\terrMessageTemplateFailedToEnable        = \"failed enabling secret store encryption: %w\"\n\terrMessageTemplateFailedToMigrate       = \"failed migrating secret store encryption: %w\"\n\terrMessageTemplateFailedToEncryptSecret = \"failed to encrypt secret id=%d: %w\"\n\terrMessageTemplateFailedToDecryptSecret = \"failed to decrypt secret id=%d: %w\"\n\terrMessageTemplateStorageError          = \"Storage error: could not update secret in DB\"\n\n\terrMessageTemplateFailedToRollbackSecretCreation = \"failed creating secret: %w. Also failed deleting temporary secret record from store: %s\"\n\n\terrMessageInitSeveralTimes = \"attempt to init encrypted storage more than once\"\n\n\tlogMessageEnablingSecretsEncryption         = \"Encrypting all secrets in database\"\n\tlogMessageEnablingSecretsEncryptionSuccess  = \"All secrets are encrypted\"\n\tlogMessageMigratingSecretsEncryption        = \"Migrating encryption keys\"\n\tlogMessageMigratingSecretsEncryptionSuccess = \"Secrets encryption migrated successfully\"\n)\n"
  },
  {
    "path": "server/services/encryption/wrapper/store/secret_store.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (wrapper *EncryptedSecretStore) SecretFind(repo *model.Repo, s string) (*model.Secret, error) {\n\tresult, err := wrapper.store.SecretFind(repo, s)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = wrapper.decrypt(result)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) SecretList(repo *model.Repo, b bool, p *model.ListOptions) ([]*model.Secret, error) {\n\tresults, err := wrapper.store.SecretList(repo, b, p)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terr = wrapper.decryptList(results)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) SecretCreate(secret *model.Secret) error {\n\tnewSecret := &model.Secret{}\n\terr := wrapper.store.SecretCreate(newSecret)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsecret.ID = newSecret.ID\n\n\terr = wrapper.encrypt(secret)\n\tif err != nil {\n\t\tdeleteErr := wrapper.store.SecretDelete(newSecret)\n\t\tif deleteErr != nil {\n\t\t\treturn fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error())\n\t\t}\n\t\treturn err\n\t}\n\n\terr = wrapper.store.SecretUpdate(secret)\n\tif err != nil {\n\t\tdeleteErr := wrapper.store.SecretDelete(newSecret)\n\t\tif deleteErr != nil {\n\t\t\treturn fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error())\n\t\t}\n\t\treturn err\n\t}\n\n\terr = wrapper.decrypt(secret)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) SecretUpdate(secret *model.Secret) error {\n\terr := wrapper.encrypt(secret)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = wrapper.store.SecretUpdate(secret)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = wrapper.decrypt(secret)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) SecretDelete(secret *model.Secret) error {\n\treturn wrapper.store.SecretDelete(secret)\n}\n\nfunc (wrapper *EncryptedSecretStore) OrgSecretFind(s int64, s2 string) (*model.Secret, error) {\n\tresult, err := wrapper.store.OrgSecretFind(s, s2)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = wrapper.decrypt(result)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) OrgSecretList(s int64, p *model.ListOptions) ([]*model.Secret, error) {\n\tresults, err := wrapper.store.OrgSecretList(s, p)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = wrapper.decryptList(results)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) GlobalSecretFind(s string) (*model.Secret, error) {\n\tresult, err := wrapper.store.GlobalSecretFind(s)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = wrapper.decrypt(result)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {\n\tresults, err := wrapper.store.GlobalSecretList(p)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = wrapper.decryptList(results)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n\nfunc (wrapper *EncryptedSecretStore) SecretListAll() ([]*model.Secret, error) {\n\tresults, err := wrapper.store.SecretListAll()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = wrapper.decryptList(results)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn results, nil\n}\n"
  },
  {
    "path": "server/services/encryption/wrapper/store/secret_store_wrapper.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types\"\n)\n\ntype EncryptedSecretStore struct {\n\tstore      model.SecretStore\n\tencryption types.EncryptionService\n}\n\n// Ensure wrapper match interface.\nvar _ model.SecretStore = new(EncryptedSecretStore)\n\nfunc NewSecretStore(secretStore model.SecretStore) *EncryptedSecretStore {\n\twrapper := EncryptedSecretStore{secretStore, nil}\n\treturn &wrapper\n}\n\nfunc (wrapper *EncryptedSecretStore) SetEncryptionService(service types.EncryptionService) error {\n\tif wrapper.encryption != nil {\n\t\treturn errors.New(errMessageInitSeveralTimes)\n\t}\n\twrapper.encryption = service\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) EnableEncryption() error {\n\tlog.Warn().Msg(logMessageEnablingSecretsEncryption)\n\tsecrets, err := wrapper.store.SecretListAll()\n\tif err != nil {\n\t\treturn fmt.Errorf(errMessageTemplateFailedToEnable, err)\n\t}\n\tfor _, secret := range secrets {\n\t\tif err := wrapper.encrypt(secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := wrapper._save(secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Warn().Msg(logMessageEnablingSecretsEncryptionSuccess)\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) MigrateEncryption(newEncryptionService types.EncryptionService) error {\n\tlog.Warn().Msg(logMessageMigratingSecretsEncryption)\n\tsecrets, err := wrapper.store.SecretListAll()\n\tif err != nil {\n\t\treturn fmt.Errorf(errMessageTemplateFailedToMigrate, err)\n\t}\n\tif err := wrapper.decryptList(secrets); err != nil {\n\t\treturn err\n\t}\n\twrapper.encryption = newEncryptionService\n\tfor _, secret := range secrets {\n\t\tif err := wrapper.encrypt(secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := wrapper._save(secret); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Warn().Msg(logMessageMigratingSecretsEncryptionSuccess)\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) encrypt(secret *model.Secret) error {\n\tencryptedValue, err := wrapper.encryption.Encrypt(secret.Value, strconv.Itoa(int(secret.ID)))\n\tif err != nil {\n\t\treturn fmt.Errorf(errMessageTemplateFailedToEncryptSecret, secret.ID, err)\n\t}\n\tsecret.Value = encryptedValue\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) decrypt(secret *model.Secret) error {\n\tdecryptedValue, err := wrapper.encryption.Decrypt(secret.Value, strconv.Itoa(int(secret.ID)))\n\tif err != nil {\n\t\treturn fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err)\n\t}\n\tsecret.Value = decryptedValue\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) decryptList(secrets []*model.Secret) error {\n\tfor _, secret := range secrets {\n\t\terr := wrapper.decrypt(secret)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (wrapper *EncryptedSecretStore) _save(secret *model.Secret) error {\n\terr := wrapper.store.SecretUpdate(secret)\n\tif err != nil {\n\t\tlog.Err(err).Msg(errMessageTemplateStorageError)\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/environment/mocks/mock_Service.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockService {\n\tmock := &MockService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockService is an autogenerated mock type for the Service type\ntype MockService struct {\n\tmock.Mock\n}\n\ntype MockService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockService) EXPECT() *MockService_Expecter {\n\treturn &MockService_Expecter{mock: &_m.Mock}\n}\n\n// EnvironList provides a mock function for the type MockService\nfunc (_mock *MockService) EnvironList(repo *model.Repo) ([]*model.Environ, error) {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for EnvironList\")\n\t}\n\n\tvar r0 []*model.Environ\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) ([]*model.Environ, error)); ok {\n\t\treturn returnFunc(repo)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) []*model.Environ); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Environ)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok {\n\t\tr1 = returnFunc(repo)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_EnvironList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvironList'\ntype MockService_EnvironList_Call struct {\n\t*mock.Call\n}\n\n// EnvironList is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockService_Expecter) EnvironList(repo interface{}) *MockService_EnvironList_Call {\n\treturn &MockService_EnvironList_Call{Call: _e.mock.On(\"EnvironList\", repo)}\n}\n\nfunc (_c *MockService_EnvironList_Call) Run(run func(repo *model.Repo)) *MockService_EnvironList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_EnvironList_Call) Return(environs []*model.Environ, err error) *MockService_EnvironList_Call {\n\t_c.Call.Return(environs, err)\n\treturn _c\n}\n\nfunc (_c *MockService_EnvironList_Call) RunAndReturn(run func(repo *model.Repo) ([]*model.Environ, error)) *MockService_EnvironList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/environment/parse.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage environment\n\nimport (\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype builtin struct {\n\tglobals []*model.Environ\n}\n\n// Parse returns a Service based on a string slice where key and value are separated by a \":\" delimiter.\nfunc Parse(params []string) Service {\n\tvar globals []*model.Environ\n\n\tfor _, item := range params {\n\t\tbefore, after, _ := strings.Cut(item, \":\")\n\t\tif after == \"\" {\n\t\t\t// ignore items only containing a key and no value\n\t\t\tlog.Warn().Msgf(\"key '%s' has no value, will be ignored\", before)\n\t\t\tcontinue\n\t\t}\n\t\tglobals = append(globals, &model.Environ{Name: before, Value: after})\n\t}\n\treturn &builtin{globals}\n}\n\nfunc (b *builtin) EnvironList(_ *model.Repo) ([]*model.Environ, error) {\n\treturn b.globals, nil\n}\n"
  },
  {
    "path": "server/services/environment/parse_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage environment\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestParse(t *testing.T) {\n\tservice := Parse([]string{})\n\tenv, err := service.EnvironList(nil)\n\tassert.NoError(t, err)\n\tassert.Empty(t, env)\n\n\tservice = Parse([]string{\"ENV:value\"})\n\tenv, err = service.EnvironList(nil)\n\tassert.NoError(t, err)\n\tassert.Len(t, env, 1)\n\tassert.Equal(t, env[0].Name, \"ENV\")\n\tassert.Equal(t, env[0].Value, \"value\")\n\n\tservice = Parse([]string{\"ENV:value\", \"ENV2:value2\"})\n\tenv, err = service.EnvironList(nil)\n\tassert.NoError(t, err)\n\tassert.Len(t, env, 2)\n\n\tservice = Parse([]string{\"ENV:value\", \"ENV2:value2\", \"ENV3_WITHOUT_VALUE\"})\n\tenv, err = service.EnvironList(nil)\n\tassert.NoError(t, err)\n\tassert.Len(t, env, 2)\n}\n"
  },
  {
    "path": "server/services/environment/service.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage environment\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\n// Service defines a service for managing environment variables.\ntype Service interface {\n\tEnvironList(*model.Repo) ([]*model.Environ, error)\n}\n"
  },
  {
    "path": "server/services/log/addon/client.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"encoding/json\"\n\t\"net/rpc\"\n\t\"os/exec\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tservice_log \"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/logger\"\n)\n\n// make sure RPC implements service_log.Service.\nvar _ service_log.Service = new(RPC)\n\nfunc Load(file string) (service_log.Service, error) {\n\tclient := plugin.NewClient(&plugin.ClientConfig{\n\t\tHandshakeConfig: HandshakeConfig,\n\t\tPlugins: map[string]plugin.Plugin{\n\t\t\tpluginKey: &Plugin{},\n\t\t},\n\t\tCmd: exec.Command(file),\n\t\tLogger: &logger.AddonClientLogger{\n\t\t\tLogger: log.With().Str(\"addon\", file).Logger(),\n\t\t},\n\t})\n\t// TODO: defer client.Kill()\n\n\trpcClient, err := client.Client()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\traw, err := rpcClient.Dispense(pluginKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\textension, _ := raw.(service_log.Service)\n\treturn extension, nil\n}\n\ntype RPC struct {\n\tclient *rpc.Client\n}\n\nfunc (g *RPC) LogFind(step *model.Step) ([]*model.LogEntry, error) {\n\targs, err := json.Marshal(step)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.LogFind\", args, &jsonResp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar resp []*model.LogEntry\n\terr = json.Unmarshal(jsonResp, &resp)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn resp, nil\n}\n\nfunc (g *RPC) LogAppend(step *model.Step, logEntries []*model.LogEntry) error {\n\targs, err := json.Marshal(&argumentsAppend{\n\t\tStep:       step,\n\t\tLogEntries: logEntries,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar jsonResp []byte\n\treturn g.client.Call(\"Plugin.LogAppend\", args, &jsonResp)\n}\n\nfunc (g *RPC) LogDelete(step *model.Step) error {\n\targs, err := json.Marshal(step)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar jsonResp []byte\n\treturn g.client.Call(\"Plugin.LogDelete\", args, &jsonResp)\n}\n\nfunc (g *RPC) StepFinished(step *model.Step) {\n\targs, err := json.Marshal(step)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not marshal json for log addon\")\n\t\treturn\n\t}\n\tvar jsonResp []byte\n\terr = g.client.Call(\"Plugin.StepFinished\", args, &jsonResp)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msg(\"StepFinished via addon failed\")\n\t}\n}\n"
  },
  {
    "path": "server/services/log/addon/plugin.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"net/rpc\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n)\n\nconst pluginKey = \"log\"\n\nvar HandshakeConfig = plugin.HandshakeConfig{\n\tProtocolVersion:  1,\n\tMagicCookieKey:   \"WOODPECKER_LOG_ADDON_PLUGIN\",\n\tMagicCookieValue: \"woodpecker-plugin-magic-cookie-value\",\n}\n\ntype Plugin struct {\n\tImpl log.Service\n}\n\nfunc (p *Plugin) Server(*plugin.MuxBroker) (any, error) {\n\treturn &RPCServer{Impl: p.Impl}, nil\n}\n\nfunc (*Plugin) Client(_ *plugin.MuxBroker, c *rpc.Client) (any, error) {\n\treturn &RPC{client: c}, nil\n}\n"
  },
  {
    "path": "server/services/log/addon/server.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage addon\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/hashicorp/go-plugin\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n)\n\nfunc Serve(impl log.Service) {\n\tplugin.Serve(&plugin.ServeConfig{\n\t\tHandshakeConfig: HandshakeConfig,\n\t\tPlugins: map[string]plugin.Plugin{\n\t\t\tpluginKey: &Plugin{Impl: impl},\n\t\t},\n\t})\n}\n\ntype RPCServer struct {\n\tImpl log.Service\n}\n\ntype argumentsAppend struct {\n\tStep       *model.Step       `json:\"step\"`\n\tLogEntries []*model.LogEntry `json:\"log_entries\"`\n}\n\nfunc (s *RPCServer) LogFind(args []byte, resp *[]byte) error {\n\tvar a model.Step\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog, err := s.Impl.LogFind(&a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp, err = json.Marshal(log)\n\treturn err\n}\n\nfunc (s *RPCServer) LogAppend(args []byte, resp *[]byte) error {\n\tvar a argumentsAppend\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\treturn s.Impl.LogAppend(a.Step, a.LogEntries)\n}\n\nfunc (s *RPCServer) LogDelete(args []byte, resp *[]byte) error {\n\tvar a model.Step\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\treturn s.Impl.LogDelete(&a)\n}\n\nfunc (s *RPCServer) StepFinished(args []byte, resp *[]byte) error {\n\tvar a model.Step\n\terr := json.Unmarshal(args, &a)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*resp = []byte{}\n\ts.Impl.StepFinished(&a)\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/log/file/file.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage file\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/pipeline\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tservice_log \"go.woodpecker-ci.org/woodpecker/v3/server/services/log\"\n)\n\nconst (\n\t// Add base64 overhead and space for other JSON fields (just to be safe).\n\tmaxLineLength int = (pipeline.MaxLogLineLength/3)*4 + (64 * 1024) //nolint:mnd\n)\n\ntype logStore struct {\n\tbase string\n}\n\nfunc NewLogStore(base string) (service_log.Service, error) {\n\tif base == \"\" {\n\t\treturn nil, fmt.Errorf(\"file storage base path is required\")\n\t}\n\tif _, err := os.Stat(base); err != nil && os.IsNotExist(err) {\n\t\terr = os.MkdirAll(base, 0o700)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn logStore{base: base}, nil\n}\n\nfunc (l logStore) filePath(id int64) string {\n\treturn filepath.Join(l.base, fmt.Sprintf(\"%d.json\", id))\n}\n\nfunc (l logStore) LogFind(step *model.Step) ([]*model.LogEntry, error) {\n\tfilename := l.filePath(step.ID)\n\tfile, err := os.Open(filename)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tbuf := make([]byte, 0, bufio.MaxScanTokenSize)\n\ts := bufio.NewScanner(file)\n\ts.Buffer(buf, maxLineLength)\n\n\tvar entries []*model.LogEntry\n\tfor s.Scan() {\n\t\tj := s.Text()\n\t\tif len(strings.TrimSpace(j)) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tentry := &model.LogEntry{}\n\t\terr = json.Unmarshal([]byte(j), entry)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tentries = append(entries, entry)\n\t}\n\n\treturn entries, nil\n}\n\nfunc (l logStore) LogAppend(step *model.Step, logEntries []*model.LogEntry) error {\n\tpath := l.filePath(step.ID)\n\n\tfile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)\n\tif err != nil {\n\t\tlog.Error().Err(err).Msgf(\"could not open log file %s\", path)\n\t\treturn err\n\t}\n\n\tvar bytes []byte\n\n\tfor _, logEntry := range logEntries {\n\t\tif jsonLine, err := json.Marshal(logEntry); err == nil {\n\t\t\tbytes = append(bytes, jsonLine...)\n\t\t\tbytes = append(bytes, byte('\\n'))\n\t\t} else {\n\t\t\tlog.Error().Err(err).Msg(\"could not convert log entry to JSON\")\n\t\t}\n\t}\n\n\tif _, err = file.Write(bytes); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not write out log entries\")\n\t}\n\n\treturn file.Close()\n}\n\nfunc (l logStore) LogDelete(step *model.Step) error {\n\treturn os.Remove(l.filePath(step.ID))\n}\n\nfunc (l logStore) StepFinished(_ *model.Step) {}\n"
  },
  {
    "path": "server/services/log/mocks/mock_Service.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockService {\n\tmock := &MockService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockService is an autogenerated mock type for the Service type\ntype MockService struct {\n\tmock.Mock\n}\n\ntype MockService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockService) EXPECT() *MockService_Expecter {\n\treturn &MockService_Expecter{mock: &_m.Mock}\n}\n\n// LogAppend provides a mock function for the type MockService\nfunc (_mock *MockService) LogAppend(step *model.Step, logEntries []*model.LogEntry) error {\n\tret := _mock.Called(step, logEntries)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogAppend\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step, []*model.LogEntry) error); ok {\n\t\tr0 = returnFunc(step, logEntries)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_LogAppend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogAppend'\ntype MockService_LogAppend_Call struct {\n\t*mock.Call\n}\n\n// LogAppend is a helper method to define mock.On call\n//   - step *model.Step\n//   - logEntries []*model.LogEntry\nfunc (_e *MockService_Expecter) LogAppend(step interface{}, logEntries interface{}) *MockService_LogAppend_Call {\n\treturn &MockService_LogAppend_Call{Call: _e.mock.On(\"LogAppend\", step, logEntries)}\n}\n\nfunc (_c *MockService_LogAppend_Call) Run(run func(step *model.Step, logEntries []*model.LogEntry)) *MockService_LogAppend_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\tvar arg1 []*model.LogEntry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]*model.LogEntry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_LogAppend_Call) Return(err error) *MockService_LogAppend_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_LogAppend_Call) RunAndReturn(run func(step *model.Step, logEntries []*model.LogEntry) error) *MockService_LogAppend_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogDelete provides a mock function for the type MockService\nfunc (_mock *MockService) LogDelete(step *model.Step) error {\n\tret := _mock.Called(step)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok {\n\t\tr0 = returnFunc(step)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_LogDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogDelete'\ntype MockService_LogDelete_Call struct {\n\t*mock.Call\n}\n\n// LogDelete is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockService_Expecter) LogDelete(step interface{}) *MockService_LogDelete_Call {\n\treturn &MockService_LogDelete_Call{Call: _e.mock.On(\"LogDelete\", step)}\n}\n\nfunc (_c *MockService_LogDelete_Call) Run(run func(step *model.Step)) *MockService_LogDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_LogDelete_Call) Return(err error) *MockService_LogDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_LogDelete_Call) RunAndReturn(run func(step *model.Step) error) *MockService_LogDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogFind provides a mock function for the type MockService\nfunc (_mock *MockService) LogFind(step *model.Step) ([]*model.LogEntry, error) {\n\tret := _mock.Called(step)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogFind\")\n\t}\n\n\tvar r0 []*model.LogEntry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) ([]*model.LogEntry, error)); ok {\n\t\treturn returnFunc(step)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) []*model.LogEntry); ok {\n\t\tr0 = returnFunc(step)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.LogEntry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Step) error); ok {\n\t\tr1 = returnFunc(step)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_LogFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogFind'\ntype MockService_LogFind_Call struct {\n\t*mock.Call\n}\n\n// LogFind is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockService_Expecter) LogFind(step interface{}) *MockService_LogFind_Call {\n\treturn &MockService_LogFind_Call{Call: _e.mock.On(\"LogFind\", step)}\n}\n\nfunc (_c *MockService_LogFind_Call) Run(run func(step *model.Step)) *MockService_LogFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_LogFind_Call) Return(logEntrys []*model.LogEntry, err error) *MockService_LogFind_Call {\n\t_c.Call.Return(logEntrys, err)\n\treturn _c\n}\n\nfunc (_c *MockService_LogFind_Call) RunAndReturn(run func(step *model.Step) ([]*model.LogEntry, error)) *MockService_LogFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepFinished provides a mock function for the type MockService\nfunc (_mock *MockService) StepFinished(step *model.Step) {\n\t_mock.Called(step)\n\treturn\n}\n\n// MockService_StepFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepFinished'\ntype MockService_StepFinished_Call struct {\n\t*mock.Call\n}\n\n// StepFinished is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockService_Expecter) StepFinished(step interface{}) *MockService_StepFinished_Call {\n\treturn &MockService_StepFinished_Call{Call: _e.mock.On(\"StepFinished\", step)}\n}\n\nfunc (_c *MockService_StepFinished_Call) Run(run func(step *model.Step)) *MockService_StepFinished_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_StepFinished_Call) Return() *MockService_StepFinished_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockService_StepFinished_Call) RunAndReturn(run func(step *model.Step)) *MockService_StepFinished_Call {\n\t_c.Run(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/log/service.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\ntype Service interface {\n\tLogFind(step *model.Step) ([]*model.LogEntry, error)\n\tLogAppend(step *model.Step, logEntries []*model.LogEntry) error\n\tLogDelete(step *model.Step) error\n\tStepFinished(step *model.Step)\n}\n"
  },
  {
    "path": "server/services/manager.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//\thttp://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage services\n\nimport (\n\t\"crypto\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jellydator/ttlcache/v3\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/environment\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/secret\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\nconst forgeCacheTTL = 10 * time.Minute\n\ntype SetupForge func(forge *model.Forge) (forge.Forge, error)\n\ntype Manager interface {\n\tSignaturePublicKey() crypto.PublicKey\n\tSecretServiceFromRepo(repo *model.Repo) secret.Service\n\tSecretService() secret.Service\n\tRegistryServiceFromRepo(repo *model.Repo) registry.Service\n\tRegistryService() registry.Service\n\tConfigServiceFromRepo(repo *model.Repo) config.Service\n\tEnvironmentService() environment.Service\n\tForgeFromRepo(repo *model.Repo) (forge.Forge, error)\n\tForgeFromUser(user *model.User) (forge.Forge, error)\n\tForgeByID(forgeID int64) (forge.Forge, error)\n}\n\ntype manager struct {\n\tsignaturePrivateKey crypto.PrivateKey\n\tsignaturePublicKey  crypto.PublicKey\n\tstore               store.Store\n\tsecret              secret.Service\n\tregistry            registry.Service\n\tconfig              config.Service\n\tenvironment         environment.Service\n\tforgeCache          *ttlcache.Cache[int64, forge.Forge]\n\tsetupForge          SetupForge\n\tclient              *utils.Client\n}\n\nfunc NewManager(c *cli.Command, store store.Store, setupForge SetupForge) (Manager, error) {\n\tsignaturePrivateKey, signaturePublicKey, err := setupSignatureKeys(store)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = setupForgeService(c, store)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient, err := utils.NewHTTPClient(signaturePrivateKey, c.String(\"extensions-allowed-hosts\"))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfigService, err := setupConfigService(c, client)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &manager{\n\t\tsignaturePrivateKey: signaturePrivateKey,\n\t\tsignaturePublicKey:  signaturePublicKey,\n\t\tstore:               store,\n\t\tsecret:              setupSecretService(store, c.String(\"secret-extension-endpoint\"), client, c.Bool(\"secret-extension-netrc\")),\n\t\tregistry:            setupRegistryService(store, c.String(\"docker-config\"), c.String(\"registry-extension-endpoint\"), c.Bool(\"registry-extension-netrc\"), client),\n\t\tconfig:              configService,\n\t\tenvironment:         environment.Parse(c.StringSlice(\"environment\")),\n\t\tforgeCache:          ttlcache.New(ttlcache.WithDisableTouchOnHit[int64, forge.Forge]()),\n\t\tsetupForge:          setupForge,\n\t\tclient:              client,\n\t}, nil\n}\n\nfunc (m *manager) SignaturePublicKey() crypto.PublicKey {\n\treturn m.signaturePublicKey\n}\n\nfunc (m *manager) SecretServiceFromRepo(repo *model.Repo) secret.Service {\n\tif repo.SecretExtensionEndpoint != \"\" {\n\t\treturn secret.NewCombined(m.secret, secret.NewHTTP(strings.TrimRight(repo.SecretExtensionEndpoint, \"/\"), m.client, repo.SecretExtensionNetrc))\n\t}\n\n\treturn m.SecretService()\n}\n\nfunc (m *manager) SecretService() secret.Service {\n\treturn m.secret\n}\n\nfunc (m *manager) RegistryServiceFromRepo(repo *model.Repo) registry.Service {\n\tif repo.RegistryExtensionEndpoint != \"\" {\n\t\treturn registry.NewWithExtension(m.registry, registry.NewHTTP(strings.TrimRight(repo.RegistryExtensionEndpoint, \"/\"), m.client, repo.RegistryExtensionNetrc))\n\t}\n\treturn m.RegistryService()\n}\n\nfunc (m *manager) RegistryService() registry.Service {\n\treturn m.registry\n}\n\nfunc (m *manager) ConfigServiceFromRepo(repo *model.Repo) config.Service {\n\tif repo.ConfigExtensionEndpoint != \"\" {\n\t\tif repo.ConfigExtensionExclusive {\n\t\t\treturn config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, \"/\"), m.client, repo.ConfigExtensionNetrc)\n\t\t}\n\t\treturn config.NewCombined(m.config, config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, \"/\"), m.client, repo.ConfigExtensionNetrc))\n\t}\n\n\treturn m.config\n}\n\nfunc (m *manager) EnvironmentService() environment.Service {\n\treturn m.environment\n}\n\nfunc (m *manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) {\n\treturn m.ForgeByID(repo.ForgeID)\n}\n\nfunc (m *manager) ForgeFromUser(user *model.User) (forge.Forge, error) {\n\treturn m.ForgeByID(user.ForgeID)\n}\n\nfunc (m *manager) ForgeByID(id int64) (forge.Forge, error) {\n\titem := m.forgeCache.Get(id)\n\tif item != nil && !item.IsExpired() {\n\t\treturn item.Value(), nil\n\t}\n\n\tforgeModel, err := m.store.ForgeGet(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tforge, err := m.setupForge(forgeModel)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tm.forgeCache.Set(id, forge, forgeCacheTTL)\n\n\treturn forge, nil\n}\n"
  },
  {
    "path": "server/services/mocks/mock_Manager.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"crypto\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/forge\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/environment\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/secret\"\n)\n\n// NewMockManager creates a new instance of MockManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockManager(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockManager {\n\tmock := &MockManager{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockManager is an autogenerated mock type for the Manager type\ntype MockManager struct {\n\tmock.Mock\n}\n\ntype MockManager_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockManager) EXPECT() *MockManager_Expecter {\n\treturn &MockManager_Expecter{mock: &_m.Mock}\n}\n\n// ConfigServiceFromRepo provides a mock function for the type MockManager\nfunc (_mock *MockManager) ConfigServiceFromRepo(repo *model.Repo) config.Service {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ConfigServiceFromRepo\")\n\t}\n\n\tvar r0 config.Service\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) config.Service); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(config.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_ConfigServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigServiceFromRepo'\ntype MockManager_ConfigServiceFromRepo_Call struct {\n\t*mock.Call\n}\n\n// ConfigServiceFromRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockManager_Expecter) ConfigServiceFromRepo(repo interface{}) *MockManager_ConfigServiceFromRepo_Call {\n\treturn &MockManager_ConfigServiceFromRepo_Call{Call: _e.mock.On(\"ConfigServiceFromRepo\", repo)}\n}\n\nfunc (_c *MockManager_ConfigServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_ConfigServiceFromRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_ConfigServiceFromRepo_Call) Return(service config.Service) *MockManager_ConfigServiceFromRepo_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_ConfigServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) config.Service) *MockManager_ConfigServiceFromRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// EnvironmentService provides a mock function for the type MockManager\nfunc (_mock *MockManager) EnvironmentService() environment.Service {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for EnvironmentService\")\n\t}\n\n\tvar r0 environment.Service\n\tif returnFunc, ok := ret.Get(0).(func() environment.Service); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(environment.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_EnvironmentService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvironmentService'\ntype MockManager_EnvironmentService_Call struct {\n\t*mock.Call\n}\n\n// EnvironmentService is a helper method to define mock.On call\nfunc (_e *MockManager_Expecter) EnvironmentService() *MockManager_EnvironmentService_Call {\n\treturn &MockManager_EnvironmentService_Call{Call: _e.mock.On(\"EnvironmentService\")}\n}\n\nfunc (_c *MockManager_EnvironmentService_Call) Run(run func()) *MockManager_EnvironmentService_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_EnvironmentService_Call) Return(service environment.Service) *MockManager_EnvironmentService_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_EnvironmentService_Call) RunAndReturn(run func() environment.Service) *MockManager_EnvironmentService_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeByID provides a mock function for the type MockManager\nfunc (_mock *MockManager) ForgeByID(forgeID int64) (forge.Forge, error) {\n\tret := _mock.Called(forgeID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeByID\")\n\t}\n\n\tvar r0 forge.Forge\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (forge.Forge, error)); ok {\n\t\treturn returnFunc(forgeID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) forge.Forge); ok {\n\t\tr0 = returnFunc(forgeID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(forge.Forge)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(forgeID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockManager_ForgeByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeByID'\ntype MockManager_ForgeByID_Call struct {\n\t*mock.Call\n}\n\n// ForgeByID is a helper method to define mock.On call\n//   - forgeID int64\nfunc (_e *MockManager_Expecter) ForgeByID(forgeID interface{}) *MockManager_ForgeByID_Call {\n\treturn &MockManager_ForgeByID_Call{Call: _e.mock.On(\"ForgeByID\", forgeID)}\n}\n\nfunc (_c *MockManager_ForgeByID_Call) Run(run func(forgeID int64)) *MockManager_ForgeByID_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeByID_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeByID_Call {\n\t_c.Call.Return(forge1, err)\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeByID_Call) RunAndReturn(run func(forgeID int64) (forge.Forge, error)) *MockManager_ForgeByID_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeFromRepo provides a mock function for the type MockManager\nfunc (_mock *MockManager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeFromRepo\")\n\t}\n\n\tvar r0 forge.Forge\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) (forge.Forge, error)); ok {\n\t\treturn returnFunc(repo)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) forge.Forge); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(forge.Forge)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok {\n\t\tr1 = returnFunc(repo)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockManager_ForgeFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeFromRepo'\ntype MockManager_ForgeFromRepo_Call struct {\n\t*mock.Call\n}\n\n// ForgeFromRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockManager_Expecter) ForgeFromRepo(repo interface{}) *MockManager_ForgeFromRepo_Call {\n\treturn &MockManager_ForgeFromRepo_Call{Call: _e.mock.On(\"ForgeFromRepo\", repo)}\n}\n\nfunc (_c *MockManager_ForgeFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_ForgeFromRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeFromRepo_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeFromRepo_Call {\n\t_c.Call.Return(forge1, err)\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeFromRepo_Call) RunAndReturn(run func(repo *model.Repo) (forge.Forge, error)) *MockManager_ForgeFromRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeFromUser provides a mock function for the type MockManager\nfunc (_mock *MockManager) ForgeFromUser(user *model.User) (forge.Forge, error) {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeFromUser\")\n\t}\n\n\tvar r0 forge.Forge\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) (forge.Forge, error)); ok {\n\t\treturn returnFunc(user)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) forge.Forge); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(forge.Forge)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User) error); ok {\n\t\tr1 = returnFunc(user)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockManager_ForgeFromUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeFromUser'\ntype MockManager_ForgeFromUser_Call struct {\n\t*mock.Call\n}\n\n// ForgeFromUser is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockManager_Expecter) ForgeFromUser(user interface{}) *MockManager_ForgeFromUser_Call {\n\treturn &MockManager_ForgeFromUser_Call{Call: _e.mock.On(\"ForgeFromUser\", user)}\n}\n\nfunc (_c *MockManager_ForgeFromUser_Call) Run(run func(user *model.User)) *MockManager_ForgeFromUser_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeFromUser_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeFromUser_Call {\n\t_c.Call.Return(forge1, err)\n\treturn _c\n}\n\nfunc (_c *MockManager_ForgeFromUser_Call) RunAndReturn(run func(user *model.User) (forge.Forge, error)) *MockManager_ForgeFromUser_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryService provides a mock function for the type MockManager\nfunc (_mock *MockManager) RegistryService() registry.Service {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryService\")\n\t}\n\n\tvar r0 registry.Service\n\tif returnFunc, ok := ret.Get(0).(func() registry.Service); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(registry.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_RegistryService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryService'\ntype MockManager_RegistryService_Call struct {\n\t*mock.Call\n}\n\n// RegistryService is a helper method to define mock.On call\nfunc (_e *MockManager_Expecter) RegistryService() *MockManager_RegistryService_Call {\n\treturn &MockManager_RegistryService_Call{Call: _e.mock.On(\"RegistryService\")}\n}\n\nfunc (_c *MockManager_RegistryService_Call) Run(run func()) *MockManager_RegistryService_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_RegistryService_Call) Return(service registry.Service) *MockManager_RegistryService_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_RegistryService_Call) RunAndReturn(run func() registry.Service) *MockManager_RegistryService_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryServiceFromRepo provides a mock function for the type MockManager\nfunc (_mock *MockManager) RegistryServiceFromRepo(repo *model.Repo) registry.Service {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryServiceFromRepo\")\n\t}\n\n\tvar r0 registry.Service\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) registry.Service); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(registry.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_RegistryServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryServiceFromRepo'\ntype MockManager_RegistryServiceFromRepo_Call struct {\n\t*mock.Call\n}\n\n// RegistryServiceFromRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockManager_Expecter) RegistryServiceFromRepo(repo interface{}) *MockManager_RegistryServiceFromRepo_Call {\n\treturn &MockManager_RegistryServiceFromRepo_Call{Call: _e.mock.On(\"RegistryServiceFromRepo\", repo)}\n}\n\nfunc (_c *MockManager_RegistryServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_RegistryServiceFromRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_RegistryServiceFromRepo_Call) Return(service registry.Service) *MockManager_RegistryServiceFromRepo_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_RegistryServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) registry.Service) *MockManager_RegistryServiceFromRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretService provides a mock function for the type MockManager\nfunc (_mock *MockManager) SecretService() secret.Service {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretService\")\n\t}\n\n\tvar r0 secret.Service\n\tif returnFunc, ok := ret.Get(0).(func() secret.Service); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(secret.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_SecretService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretService'\ntype MockManager_SecretService_Call struct {\n\t*mock.Call\n}\n\n// SecretService is a helper method to define mock.On call\nfunc (_e *MockManager_Expecter) SecretService() *MockManager_SecretService_Call {\n\treturn &MockManager_SecretService_Call{Call: _e.mock.On(\"SecretService\")}\n}\n\nfunc (_c *MockManager_SecretService_Call) Run(run func()) *MockManager_SecretService_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_SecretService_Call) Return(service secret.Service) *MockManager_SecretService_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_SecretService_Call) RunAndReturn(run func() secret.Service) *MockManager_SecretService_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretServiceFromRepo provides a mock function for the type MockManager\nfunc (_mock *MockManager) SecretServiceFromRepo(repo *model.Repo) secret.Service {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretServiceFromRepo\")\n\t}\n\n\tvar r0 secret.Service\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) secret.Service); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(secret.Service)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_SecretServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretServiceFromRepo'\ntype MockManager_SecretServiceFromRepo_Call struct {\n\t*mock.Call\n}\n\n// SecretServiceFromRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockManager_Expecter) SecretServiceFromRepo(repo interface{}) *MockManager_SecretServiceFromRepo_Call {\n\treturn &MockManager_SecretServiceFromRepo_Call{Call: _e.mock.On(\"SecretServiceFromRepo\", repo)}\n}\n\nfunc (_c *MockManager_SecretServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_SecretServiceFromRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_SecretServiceFromRepo_Call) Return(service secret.Service) *MockManager_SecretServiceFromRepo_Call {\n\t_c.Call.Return(service)\n\treturn _c\n}\n\nfunc (_c *MockManager_SecretServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) secret.Service) *MockManager_SecretServiceFromRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SignaturePublicKey provides a mock function for the type MockManager\nfunc (_mock *MockManager) SignaturePublicKey() crypto.PublicKey {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SignaturePublicKey\")\n\t}\n\n\tvar r0 crypto.PublicKey\n\tif returnFunc, ok := ret.Get(0).(func() crypto.PublicKey); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(crypto.PublicKey)\n\t\t}\n\t}\n\treturn r0\n}\n\n// MockManager_SignaturePublicKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignaturePublicKey'\ntype MockManager_SignaturePublicKey_Call struct {\n\t*mock.Call\n}\n\n// SignaturePublicKey is a helper method to define mock.On call\nfunc (_e *MockManager_Expecter) SignaturePublicKey() *MockManager_SignaturePublicKey_Call {\n\treturn &MockManager_SignaturePublicKey_Call{Call: _e.mock.On(\"SignaturePublicKey\")}\n}\n\nfunc (_c *MockManager_SignaturePublicKey_Call) Run(run func()) *MockManager_SignaturePublicKey_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockManager_SignaturePublicKey_Call) Return(publicKey crypto.PublicKey) *MockManager_SignaturePublicKey_Call {\n\t_c.Call.Return(publicKey)\n\treturn _c\n}\n\nfunc (_c *MockManager_SignaturePublicKey_Call) RunAndReturn(run func() crypto.PublicKey) *MockManager_SignaturePublicKey_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/permissions/admins.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nfunc NewAdmins(admins []string) *Admins {\n\tadminsLowercase := make([]string, len(admins))\n\tfor i, a := range admins {\n\t\tadminsLowercase[i] = strings.ToLower(a)\n\t}\n\treturn &Admins{admins: utils.SliceToBoolMap(adminsLowercase)}\n}\n\ntype Admins struct {\n\tadmins map[string]bool\n}\n\nfunc (a *Admins) IsAdmin(user *model.User) bool {\n\treturn a.admins[strings.ToLower(user.Login)]\n}\n"
  },
  {
    "path": "server/services/permissions/admins_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestAdmins(t *testing.T) {\n\ta := NewAdmins([]string{\"woodpecker-ci\"})\n\tassert.True(t, a.IsAdmin(&model.User{Login: \"woodpecker-ci\"}))\n\tassert.False(t, a.IsAdmin(&model.User{Login: \"not-woodpecker-ci\"}))\n\tempty := NewAdmins([]string{})\n\tassert.False(t, empty.IsAdmin(&model.User{Login: \"woodpecker-ci\"}))\n\tassert.False(t, empty.IsAdmin(&model.User{Login: \"not-woodpecker-ci\"}))\n}\n"
  },
  {
    "path": "server/services/permissions/orgs.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nfunc NewOrgs(orgs []string) *Orgs {\n\torgsLowercase := make([]string, len(orgs))\n\tfor i, a := range orgs {\n\t\torgsLowercase[i] = strings.ToLower(a)\n\t}\n\treturn &Orgs{\n\t\tIsConfigured: len(orgs) > 0,\n\t\torgs:         utils.SliceToBoolMap(orgsLowercase),\n\t}\n}\n\ntype Orgs struct {\n\tIsConfigured bool\n\torgs         map[string]bool\n}\n\nfunc (o *Orgs) IsMember(teams []*model.Team) bool {\n\tfor _, team := range teams {\n\t\tif o.orgs[strings.ToLower(team.Login)] {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "server/services/permissions/orgs_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestOrgs(t *testing.T) {\n\to := NewOrgs([]string{\"woodpecker-ci\"})\n\tassert.True(t, o.IsConfigured)\n\tassert.True(t, o.IsMember([]*model.Team{{Login: \"woodpecker-ci\"}}))\n\tassert.False(t, o.IsMember([]*model.Team{{Login: \"not-woodpecker-ci\"}}))\n\tempty := NewOrgs([]string{})\n\tassert.False(t, empty.IsConfigured)\n\tassert.False(t, empty.IsMember([]*model.Team{{Login: \"woodpecker-ci\"}}))\n\tassert.False(t, empty.IsMember([]*model.Team{{Login: \"not-woodpecker-ci\"}}))\n}\n"
  },
  {
    "path": "server/services/permissions/repo_owners.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/utils\"\n)\n\nfunc NewOwnersAllowlist(owners []string) *OwnersAllowlist {\n\townersLowercase := make([]string, len(owners))\n\tfor i, a := range owners {\n\t\townersLowercase[i] = strings.ToLower(a)\n\t}\n\treturn &OwnersAllowlist{owners: utils.SliceToBoolMap(ownersLowercase)}\n}\n\ntype OwnersAllowlist struct {\n\towners map[string]bool\n}\n\nfunc (o *OwnersAllowlist) IsAllowed(repo *model.Repo) bool {\n\treturn len(o.owners) < 1 || o.owners[strings.ToLower(repo.Owner)]\n}\n"
  },
  {
    "path": "server/services/permissions/repo_owners_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage permissions\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestOwnersAllowlist(t *testing.T) {\n\tol := NewOwnersAllowlist([]string{\"woodpecker-ci\"})\n\tassert.True(t, ol.IsAllowed(&model.Repo{Owner: \"woodpecker-ci\"}))\n\tassert.False(t, ol.IsAllowed(&model.Repo{Owner: \"not-woodpecker-ci\"}))\n\tempty := NewOwnersAllowlist([]string{})\n\tassert.True(t, empty.IsAllowed(&model.Repo{Owner: \"woodpecker-ci\"}))\n\tassert.True(t, empty.IsAllowed(&model.Repo{Owner: \"not-woodpecker-ci\"}))\n}\n"
  },
  {
    "path": "server/services/registry/combined.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\ntype combined struct {\n\tregistries []ReadOnlyService\n\tdbRegistry Service\n}\n\nfunc NewCombined(dbRegistry Service, registries ...ReadOnlyService) Service {\n\tregistries = append(registries, dbRegistry)\n\treturn &combined{\n\t\tregistries: registries,\n\t\tdbRegistry: dbRegistry,\n\t}\n}\n\nfunc (c *combined) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) {\n\treturn c.dbRegistry.RegistryFind(repo, addr)\n}\n\nfunc (c *combined) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn c.dbRegistry.RegistryList(repo, p)\n}\n\nfunc (c *combined) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) {\n\tdbRegistries, err := c.dbRegistry.RegistryListPipeline(ctx, repo, pipeline, netrc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tregistries := make([]*model.Registry, 0, len(dbRegistries))\n\texists := make(map[string]struct{}, len(dbRegistries))\n\n\t// Assign database stored registries to the map to avoid duplicates\n\t// from the combined registries so to prioritize ones in database.\n\tfor _, reg := range dbRegistries {\n\t\texists[reg.Address] = struct{}{}\n\t}\n\n\tfor _, registry := range c.registries {\n\t\tlist, err := registry.GlobalRegistryList(&model.ListOptions{All: true})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, reg := range list {\n\t\t\tif _, ok := exists[reg.Address]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\texists[reg.Address] = struct{}{}\n\t\t\tregistries = append(registries, reg)\n\t\t}\n\t}\n\n\treturn append(registries, dbRegistries...), nil\n}\n\nfunc (c *combined) RegistryCreate(repo *model.Repo, registry *model.Registry) error {\n\treturn c.dbRegistry.RegistryCreate(repo, registry)\n}\n\nfunc (c *combined) RegistryUpdate(repo *model.Repo, registry *model.Registry) error {\n\treturn c.dbRegistry.RegistryUpdate(repo, registry)\n}\n\nfunc (c *combined) RegistryDelete(repo *model.Repo, addr string) error {\n\treturn c.dbRegistry.RegistryDelete(repo, addr)\n}\n\nfunc (c *combined) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) {\n\treturn c.dbRegistry.OrgRegistryFind(owner, addr)\n}\n\nfunc (c *combined) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn c.dbRegistry.OrgRegistryList(owner, p)\n}\n\nfunc (c *combined) OrgRegistryCreate(owner int64, registry *model.Registry) error {\n\treturn c.dbRegistry.OrgRegistryCreate(owner, registry)\n}\n\nfunc (c *combined) OrgRegistryUpdate(owner int64, registry *model.Registry) error {\n\treturn c.dbRegistry.OrgRegistryUpdate(owner, registry)\n}\n\nfunc (c *combined) OrgRegistryDelete(owner int64, addr string) error {\n\treturn c.dbRegistry.OrgRegistryDelete(owner, addr)\n}\n\nfunc (c *combined) GlobalRegistryFind(addr string) (*model.Registry, error) {\n\tregistry, err := c.dbRegistry.GlobalRegistryFind(addr)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn nil, err\n\t}\n\tif registry != nil {\n\t\treturn registry, nil\n\t}\n\tfor _, reg := range c.registries {\n\t\tif registry, err := reg.GlobalRegistryFind(addr); err == nil {\n\t\t\treturn registry, nil\n\t\t}\n\t}\n\treturn nil, types.ErrRecordNotExist\n}\n\nfunc (c *combined) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) {\n\tdbRegistries, err := c.dbRegistry.GlobalRegistryList(&model.ListOptions{All: true})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tregistries := make([]*model.Registry, 0, len(dbRegistries))\n\texists := make(map[string]struct{}, len(dbRegistries))\n\n\t// Assign database stored registries to the map to avoid duplicates\n\t// from the combined registries so to prioritize ones in database.\n\tfor _, reg := range dbRegistries {\n\t\texists[reg.Address] = struct{}{}\n\t}\n\n\tfor _, registry := range c.registries {\n\t\tlist, err := registry.GlobalRegistryList(&model.ListOptions{All: true})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, reg := range list {\n\t\t\tif _, ok := exists[reg.Address]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\texists[reg.Address] = struct{}{}\n\t\t\tregistries = append(registries, reg)\n\t\t}\n\t}\n\n\treturn model.ApplyPagination(p, append(registries, dbRegistries...)), nil\n}\n\nfunc (c *combined) GlobalRegistryCreate(registry *model.Registry) error {\n\treturn c.dbRegistry.GlobalRegistryCreate(registry)\n}\n\nfunc (c *combined) GlobalRegistryUpdate(registry *model.Registry) error {\n\treturn c.dbRegistry.GlobalRegistryUpdate(registry)\n}\n\nfunc (c *combined) GlobalRegistryDelete(addr string) error {\n\treturn c.dbRegistry.GlobalRegistryDelete(addr)\n}\n"
  },
  {
    "path": "server/services/registry/combined_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestCombinedRegistryListPipeline(t *testing.T) {\n\tt.Parallel()\n\n\ttestTable := []struct {\n\t\tname          string\n\t\trepoName      string\n\t\tdbRegs        []*model.Registry\n\t\texpected      []*model.Registry\n\t\texpectedError bool\n\t}{\n\t\t{\n\t\t\tname:     \"DB registries override file registry\",\n\t\t\trepoName: \"override-test\",\n\t\t\tdbRegs: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"docker.io\", Username: \"shared\", Password: \"db-value\"},\n\t\t\t\t{ID: 2, RepoID: 1, Address: \"quay.io\", Username: \"db-only\", Password: \"only-in-db\"},\n\t\t\t},\n\t\t\texpected: []*model.Registry{\n\t\t\t\t{Address: \"example.com\", Username: \"user\", Password: \"password-encoded\", ReadOnly: true},\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"docker.io\", Username: \"shared\", Password: \"db-value\"},\n\t\t\t\t{ID: 2, RepoID: 1, Address: \"quay.io\", Username: \"db-only\", Password: \"only-in-db\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"No overriding, but merged\",\n\t\t\trepoName: \"no-content\",\n\t\t\tdbRegs: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpected: []*model.Registry{\n\t\t\t\t{Address: \"docker.io\", Username: \"user\", Password: \"your-pw\", ReadOnly: true},\n\t\t\t\t{Address: \"example.com\", Username: \"user\", Password: \"password-encoded\", ReadOnly: true},\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t}\n\n\ttmpFile, err := os.CreateTemp(t.TempDir(), \"registry-test-combined-*.json\")\n\trequire.NoError(t, err)\n\n\t_, err = tmpFile.WriteString(`{\"auths\": {\"docker.io\": {\"username\": \"user\", \"password\": \"your-pw\"}, \"example.com\": {\"auth\": \"dXNlcjpwYXNzd29yZC1lbmNvZGVk\"}}}`)\n\trequire.NoError(t, err)\n\n\tfsService := NewFilesystem(tmpFile.Name())\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockStore := store_mocks.NewMockStore(t)\n\t\t\tmockStore.On(\"RegistryList\", mock.Anything, true, mock.Anything).Return(tt.dbRegs, nil)\n\t\t\tmockStore.On(\"GlobalRegistryList\", mock.Anything).Return(nil, nil)\n\n\t\t\tcombined := NewCombined(NewDB(mockStore), fsService)\n\n\t\t\tregistries, err := combined.RegistryListPipeline(\n\t\t\t\tt.Context(),\n\t\t\t\t&model.Repo{ID: 1, Name: tt.repoName},\n\t\t\t\t&model.Pipeline{},\n\t\t\t\tnil,\n\t\t\t)\n\t\t\tif tt.expectedError {\n\t\t\t\trequire.Error(t, err, \"expected an error\")\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err, \"error fetching registries\")\n\t\t\t}\n\n\t\t\tassert.ElementsMatch(t, tt.expected, registries, \"expected some other registries\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/services/registry/db.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype db struct {\n\tstore store.Store\n}\n\n// New returns a new local registry service.\nfunc NewDB(store store.Store) Service {\n\treturn &db{store}\n}\n\nfunc (d *db) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) {\n\treturn d.store.RegistryFind(repo, addr)\n}\n\nfunc (d *db) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn d.store.RegistryList(repo, false, p)\n}\n\nfunc (d *db) RegistryListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline, _ *model.Netrc) ([]*model.Registry, error) {\n\tr, err := d.store.RegistryList(repo, true, &model.ListOptions{All: true})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Return only registries with unique address\n\t// Priority order in case of duplicate addresses are repository, user/organization, global\n\tregistries := make([]*model.Registry, 0, len(r))\n\tuniq := make(map[string]struct{})\n\tfor _, condition := range []struct {\n\t\tIsRepository   bool\n\t\tIsOrganization bool\n\t\tIsGlobal       bool\n\t}{\n\t\t{IsRepository: true},\n\t\t{IsOrganization: true},\n\t\t{IsGlobal: true},\n\t} {\n\t\tfor _, registry := range r {\n\t\t\tif registry.IsRepository() != condition.IsRepository || registry.IsOrganization() != condition.IsOrganization || registry.IsGlobal() != condition.IsGlobal {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, ok := uniq[registry.Address]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tuniq[registry.Address] = struct{}{}\n\t\t\tregistries = append(registries, registry)\n\t\t}\n\t}\n\treturn registries, nil\n}\n\nfunc (d *db) RegistryCreate(_ *model.Repo, in *model.Registry) error {\n\treturn d.store.RegistryCreate(in)\n}\n\nfunc (d *db) RegistryUpdate(_ *model.Repo, in *model.Registry) error {\n\treturn d.store.RegistryUpdate(in)\n}\n\nfunc (d *db) RegistryDelete(repo *model.Repo, addr string) error {\n\tregistry, err := d.store.RegistryFind(repo, addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.RegistryDelete(registry)\n}\n\nfunc (d *db) OrgRegistryFind(owner int64, name string) (*model.Registry, error) {\n\treturn d.store.OrgRegistryFind(owner, name)\n}\n\nfunc (d *db) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn d.store.OrgRegistryList(owner, p)\n}\n\nfunc (d *db) OrgRegistryCreate(_ int64, in *model.Registry) error {\n\treturn d.store.RegistryCreate(in)\n}\n\nfunc (d *db) OrgRegistryUpdate(_ int64, in *model.Registry) error {\n\treturn d.store.RegistryUpdate(in)\n}\n\nfunc (d *db) OrgRegistryDelete(owner int64, addr string) error {\n\tregistry, err := d.store.OrgRegistryFind(owner, addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.RegistryDelete(registry)\n}\n\nfunc (d *db) GlobalRegistryFind(addr string) (*model.Registry, error) {\n\treturn d.store.GlobalRegistryFind(addr)\n}\n\nfunc (d *db) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) {\n\treturn d.store.GlobalRegistryList(p)\n}\n\nfunc (d *db) GlobalRegistryCreate(in *model.Registry) error {\n\treturn d.store.RegistryCreate(in)\n}\n\nfunc (d *db) GlobalRegistryUpdate(in *model.Registry) error {\n\treturn d.store.RegistryUpdate(in)\n}\n\nfunc (d *db) GlobalRegistryDelete(addr string) error {\n\tregistry, err := d.store.GlobalRegistryFind(addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.RegistryDelete(registry)\n}\n"
  },
  {
    "path": "server/services/registry/filesystem.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/docker/cli/cli/config/configfile\"\n\t\"github.com/docker/cli/cli/config/types\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\tstore_types \"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\ntype filesystem struct {\n\tpath string\n}\n\nfunc NewFilesystem(path string) ReadOnlyService {\n\treturn &filesystem{path}\n}\n\nfunc parseDockerConfig(path string) ([]*model.Registry, error) {\n\tif path == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer f.Close()\n\n\tconfigFile := configfile.ConfigFile{\n\t\tAuthConfigs: make(map[string]types.AuthConfig),\n\t}\n\n\tif err := json.NewDecoder(f).Decode(&configFile); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor registryHostname := range configFile.CredentialHelpers {\n\t\tnewAuth, err := configFile.GetAuthConfig(registryHostname)\n\t\tif err == nil {\n\t\t\tconfigFile.AuthConfigs[registryHostname] = newAuth\n\t\t}\n\t}\n\n\tfor addr, ac := range configFile.AuthConfigs {\n\t\tif ac.Auth != \"\" {\n\t\t\tac.Username, ac.Password, err = decodeAuth(ac.Auth)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tac.Auth = \"\"\n\t\t\tac.ServerAddress = addr\n\t\t\tconfigFile.AuthConfigs[addr] = ac\n\t\t}\n\t}\n\n\tvar registries []*model.Registry\n\tfor key, auth := range configFile.AuthConfigs {\n\t\tregistries = append(registries, &model.Registry{\n\t\t\tAddress:  key,\n\t\t\tUsername: auth.Username,\n\t\t\tPassword: auth.Password,\n\t\t\tReadOnly: true,\n\t\t})\n\t}\n\n\treturn registries, nil\n}\n\nfunc (f *filesystem) GlobalRegistryFind(addr string) (*model.Registry, error) {\n\tregistries, err := f.GlobalRegistryList(&model.ListOptions{All: true})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, reg := range registries {\n\t\tif reg.Address == addr {\n\t\t\treturn reg, nil\n\t\t}\n\t}\n\n\treturn nil, store_types.ErrRecordNotExist\n}\n\nfunc (f *filesystem) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) {\n\tregs, err := parseDockerConfig(f.path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn model.ApplyPagination(p, regs), nil\n}\n\n// decodeAuth decodes a base64 encoded string and returns username and password.\nfunc decodeAuth(authStr string) (string, string, error) {\n\tif authStr == \"\" {\n\t\treturn \"\", \"\", nil\n\t}\n\n\tdecLen := base64.StdEncoding.DecodedLen(len(authStr))\n\tdecoded := make([]byte, decLen)\n\tauthByte := []byte(authStr)\n\tn, err := base64.StdEncoding.Decode(decoded, authByte)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tif n > decLen {\n\t\treturn \"\", \"\", fmt.Errorf(\"something went wrong decoding auth config\")\n\t}\n\tbefore, after, _ := strings.Cut(string(decoded), \":\")\n\tif before == \"\" || after == \"\" {\n\t\treturn \"\", \"\", fmt.Errorf(\"invalid auth configuration file\")\n\t}\n\tpassword := strings.Trim(after, \"\\x00\")\n\treturn before, password, nil\n}\n"
  },
  {
    "path": "server/services/registry/http.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n)\n\ntype httpExtension struct {\n\tendpoint     string\n\tclient       *utils.Client\n\tincludeNetrc bool\n}\n\ntype requestStructure struct {\n\tRepo     *model.Repo     `json:\"repo\"`\n\tPipeline *model.Pipeline `json:\"pipeline\"`\n\tNetrc    *model.Netrc    `json:\"netrc,omitempty\"`\n}\n\ntype responseStructure struct {\n\tRegistries []*registryData `json:\"registries\"`\n}\n\ntype registryData struct {\n\tAddress  string `json:\"address\"`\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\n// NewHTTP returns a new HTTP registry extension client.\nfunc NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) *httpExtension {\n\treturn &httpExtension{endpoint, client, includeNetrc}\n}\n\n// RegistryListPipeline fetches registry credentials from an external HTTP extension.\nfunc (h *httpExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) {\n\tresponse := new(responseStructure)\n\tbody := requestStructure{\n\t\tRepo:     repo,\n\t\tPipeline: pipeline,\n\t}\n\tif h.includeNetrc {\n\t\tbody.Netrc = netrc\n\t}\n\n\tstatus, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response)\n\tif err != nil && status != http.StatusNoContent {\n\t\treturn nil, fmt.Errorf(\"failed to fetch registries via http (%d) %w\", status, err)\n\t}\n\n\tif status != http.StatusOK {\n\t\t// 204 No Content means no additional registries\n\t\treturn nil, nil\n\t}\n\n\tregistries := make([]*model.Registry, len(response.Registries))\n\tfor i, reg := range response.Registries {\n\t\tregistries[i] = &model.Registry{\n\t\t\tAddress:  reg.Address,\n\t\t\tUsername: reg.Username,\n\t\t\tPassword: reg.Password,\n\t\t}\n\t}\n\n\treturn registries, nil\n}\n"
  },
  {
    "path": "server/services/registry/mocks/mock_ReadOnlyService.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockReadOnlyService creates a new instance of MockReadOnlyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockReadOnlyService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockReadOnlyService {\n\tmock := &MockReadOnlyService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockReadOnlyService is an autogenerated mock type for the ReadOnlyService type\ntype MockReadOnlyService struct {\n\tmock.Mock\n}\n\ntype MockReadOnlyService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockReadOnlyService) EXPECT() *MockReadOnlyService_Expecter {\n\treturn &MockReadOnlyService_Expecter{mock: &_m.Mock}\n}\n\n// GlobalRegistryFind provides a mock function for the type MockReadOnlyService\nfunc (_mock *MockReadOnlyService) GlobalRegistryFind(s string) (*model.Registry, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockReadOnlyService_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind'\ntype MockReadOnlyService_GlobalRegistryFind_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryFind is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockReadOnlyService_Expecter) GlobalRegistryFind(s interface{}) *MockReadOnlyService_GlobalRegistryFind_Call {\n\treturn &MockReadOnlyService_GlobalRegistryFind_Call{Call: _e.mock.On(\"GlobalRegistryFind\", s)}\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryFind_Call) Run(run func(s string)) *MockReadOnlyService_GlobalRegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockReadOnlyService_GlobalRegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockReadOnlyService_GlobalRegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryList provides a mock function for the type MockReadOnlyService\nfunc (_mock *MockReadOnlyService) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockReadOnlyService_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList'\ntype MockReadOnlyService_GlobalRegistryList_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockReadOnlyService_Expecter) GlobalRegistryList(listOptions interface{}) *MockReadOnlyService_GlobalRegistryList_Call {\n\treturn &MockReadOnlyService_GlobalRegistryList_Call{Call: _e.mock.On(\"GlobalRegistryList\", listOptions)}\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockReadOnlyService_GlobalRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockReadOnlyService_GlobalRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockReadOnlyService_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockReadOnlyService_GlobalRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/registry/mocks/mock_Service.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockService {\n\tmock := &MockService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockService is an autogenerated mock type for the Service type\ntype MockService struct {\n\tmock.Mock\n}\n\ntype MockService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockService) EXPECT() *MockService_Expecter {\n\treturn &MockService_Expecter{mock: &_m.Mock}\n}\n\n// GlobalRegistryCreate provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalRegistryCreate(registry *model.Registry) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryCreate'\ntype MockService_GlobalRegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryCreate is a helper method to define mock.On call\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) GlobalRegistryCreate(registry interface{}) *MockService_GlobalRegistryCreate_Call {\n\treturn &MockService_GlobalRegistryCreate_Call{Call: _e.mock.On(\"GlobalRegistryCreate\", registry)}\n}\n\nfunc (_c *MockService_GlobalRegistryCreate_Call) Run(run func(registry *model.Registry)) *MockService_GlobalRegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryCreate_Call) Return(err error) *MockService_GlobalRegistryCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryCreate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockService_GlobalRegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryDelete provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalRegistryDelete(s string) error {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryDelete'\ntype MockService_GlobalRegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryDelete is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockService_Expecter) GlobalRegistryDelete(s interface{}) *MockService_GlobalRegistryDelete_Call {\n\treturn &MockService_GlobalRegistryDelete_Call{Call: _e.mock.On(\"GlobalRegistryDelete\", s)}\n}\n\nfunc (_c *MockService_GlobalRegistryDelete_Call) Run(run func(s string)) *MockService_GlobalRegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryDelete_Call) Return(err error) *MockService_GlobalRegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryDelete_Call) RunAndReturn(run func(s string) error) *MockService_GlobalRegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryFind provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalRegistryFind(s string) (*model.Registry, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind'\ntype MockService_GlobalRegistryFind_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryFind is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockService_Expecter) GlobalRegistryFind(s interface{}) *MockService_GlobalRegistryFind_Call {\n\treturn &MockService_GlobalRegistryFind_Call{Call: _e.mock.On(\"GlobalRegistryFind\", s)}\n}\n\nfunc (_c *MockService_GlobalRegistryFind_Call) Run(run func(s string)) *MockService_GlobalRegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockService_GlobalRegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockService_GlobalRegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryList provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList'\ntype MockService_GlobalRegistryList_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) GlobalRegistryList(listOptions interface{}) *MockService_GlobalRegistryList_Call {\n\treturn &MockService_GlobalRegistryList_Call{Call: _e.mock.On(\"GlobalRegistryList\", listOptions)}\n}\n\nfunc (_c *MockService_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockService_GlobalRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_GlobalRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_GlobalRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalRegistryUpdate(registry *model.Registry) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryUpdate'\ntype MockService_GlobalRegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryUpdate is a helper method to define mock.On call\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) GlobalRegistryUpdate(registry interface{}) *MockService_GlobalRegistryUpdate_Call {\n\treturn &MockService_GlobalRegistryUpdate_Call{Call: _e.mock.On(\"GlobalRegistryUpdate\", registry)}\n}\n\nfunc (_c *MockService_GlobalRegistryUpdate_Call) Run(run func(registry *model.Registry)) *MockService_GlobalRegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryUpdate_Call) Return(err error) *MockService_GlobalRegistryUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalRegistryUpdate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockService_GlobalRegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryCreate provides a mock function for the type MockService\nfunc (_mock *MockService) OrgRegistryCreate(n int64, registry *model.Registry) error {\n\tret := _mock.Called(n, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.Registry) error); ok {\n\t\tr0 = returnFunc(n, registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryCreate'\ntype MockService_OrgRegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryCreate is a helper method to define mock.On call\n//   - n int64\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) OrgRegistryCreate(n interface{}, registry interface{}) *MockService_OrgRegistryCreate_Call {\n\treturn &MockService_OrgRegistryCreate_Call{Call: _e.mock.On(\"OrgRegistryCreate\", n, registry)}\n}\n\nfunc (_c *MockService_OrgRegistryCreate_Call) Run(run func(n int64, registry *model.Registry)) *MockService_OrgRegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryCreate_Call) Return(err error) *MockService_OrgRegistryCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryCreate_Call) RunAndReturn(run func(n int64, registry *model.Registry) error) *MockService_OrgRegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryDelete provides a mock function for the type MockService\nfunc (_mock *MockService) OrgRegistryDelete(n int64, s string) error {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryDelete'\ntype MockService_OrgRegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryDelete is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockService_Expecter) OrgRegistryDelete(n interface{}, s interface{}) *MockService_OrgRegistryDelete_Call {\n\treturn &MockService_OrgRegistryDelete_Call{Call: _e.mock.On(\"OrgRegistryDelete\", n, s)}\n}\n\nfunc (_c *MockService_OrgRegistryDelete_Call) Run(run func(n int64, s string)) *MockService_OrgRegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryDelete_Call) Return(err error) *MockService_OrgRegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryDelete_Call) RunAndReturn(run func(n int64, s string) error) *MockService_OrgRegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryFind provides a mock function for the type MockService\nfunc (_mock *MockService) OrgRegistryFind(n int64, s string) (*model.Registry, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *model.Registry); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_OrgRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryFind'\ntype MockService_OrgRegistryFind_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryFind is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockService_Expecter) OrgRegistryFind(n interface{}, s interface{}) *MockService_OrgRegistryFind_Call {\n\treturn &MockService_OrgRegistryFind_Call{Call: _e.mock.On(\"OrgRegistryFind\", n, s)}\n}\n\nfunc (_c *MockService_OrgRegistryFind_Call) Run(run func(n int64, s string)) *MockService_OrgRegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryFind_Call) Return(registry *model.Registry, err error) *MockService_OrgRegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryFind_Call) RunAndReturn(run func(n int64, s string) (*model.Registry, error)) *MockService_OrgRegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryList provides a mock function for the type MockService\nfunc (_mock *MockService) OrgRegistryList(n int64, listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(n, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(n, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(n, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(n, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_OrgRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryList'\ntype MockService_OrgRegistryList_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryList is a helper method to define mock.On call\n//   - n int64\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) OrgRegistryList(n interface{}, listOptions interface{}) *MockService_OrgRegistryList_Call {\n\treturn &MockService_OrgRegistryList_Call{Call: _e.mock.On(\"OrgRegistryList\", n, listOptions)}\n}\n\nfunc (_c *MockService_OrgRegistryList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockService_OrgRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_OrgRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_OrgRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) OrgRegistryUpdate(n int64, registry *model.Registry) error {\n\tret := _mock.Called(n, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.Registry) error); ok {\n\t\tr0 = returnFunc(n, registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryUpdate'\ntype MockService_OrgRegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryUpdate is a helper method to define mock.On call\n//   - n int64\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) OrgRegistryUpdate(n interface{}, registry interface{}) *MockService_OrgRegistryUpdate_Call {\n\treturn &MockService_OrgRegistryUpdate_Call{Call: _e.mock.On(\"OrgRegistryUpdate\", n, registry)}\n}\n\nfunc (_c *MockService_OrgRegistryUpdate_Call) Run(run func(n int64, registry *model.Registry)) *MockService_OrgRegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryUpdate_Call) Return(err error) *MockService_OrgRegistryUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgRegistryUpdate_Call) RunAndReturn(run func(n int64, registry *model.Registry) error) *MockService_OrgRegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryCreate provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryCreate(repo *model.Repo, registry *model.Registry) error {\n\tret := _mock.Called(repo, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Registry) error); ok {\n\t\tr0 = returnFunc(repo, registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_RegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryCreate'\ntype MockService_RegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// RegistryCreate is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) RegistryCreate(repo interface{}, registry interface{}) *MockService_RegistryCreate_Call {\n\treturn &MockService_RegistryCreate_Call{Call: _e.mock.On(\"RegistryCreate\", repo, registry)}\n}\n\nfunc (_c *MockService_RegistryCreate_Call) Run(run func(repo *model.Repo, registry *model.Registry)) *MockService_RegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryCreate_Call) Return(err error) *MockService_RegistryCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryCreate_Call) RunAndReturn(run func(repo *model.Repo, registry *model.Registry) error) *MockService_RegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryDelete provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryDelete(repo *model.Repo, s string) error {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) error); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_RegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryDelete'\ntype MockService_RegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// RegistryDelete is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockService_Expecter) RegistryDelete(repo interface{}, s interface{}) *MockService_RegistryDelete_Call {\n\treturn &MockService_RegistryDelete_Call{Call: _e.mock.On(\"RegistryDelete\", repo, s)}\n}\n\nfunc (_c *MockService_RegistryDelete_Call) Run(run func(repo *model.Repo, s string)) *MockService_RegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryDelete_Call) Return(err error) *MockService_RegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryDelete_Call) RunAndReturn(run func(repo *model.Repo, s string) error) *MockService_RegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryFind provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryFind(repo *model.Repo, s string) (*model.Registry, error) {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(repo, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Registry); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok {\n\t\tr1 = returnFunc(repo, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_RegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryFind'\ntype MockService_RegistryFind_Call struct {\n\t*mock.Call\n}\n\n// RegistryFind is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockService_Expecter) RegistryFind(repo interface{}, s interface{}) *MockService_RegistryFind_Call {\n\treturn &MockService_RegistryFind_Call{Call: _e.mock.On(\"RegistryFind\", repo, s)}\n}\n\nfunc (_c *MockService_RegistryFind_Call) Run(run func(repo *model.Repo, s string)) *MockService_RegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryFind_Call) Return(registry *model.Registry, err error) *MockService_RegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Registry, error)) *MockService_RegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryList provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(repo, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(repo, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(repo, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(repo, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_RegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryList'\ntype MockService_RegistryList_Call struct {\n\t*mock.Call\n}\n\n// RegistryList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) RegistryList(repo interface{}, listOptions interface{}) *MockService_RegistryList_Call {\n\treturn &MockService_RegistryList_Call{Call: _e.mock.On(\"RegistryList\", repo, listOptions)}\n}\n\nfunc (_c *MockService_RegistryList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockService_RegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_RegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_RegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryListPipeline provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) {\n\tret := _mock.Called(context1, repo, pipeline, netrc)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryListPipeline\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(context1, repo, pipeline, netrc)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) []*model.Registry); ok {\n\t\tr0 = returnFunc(context1, repo, pipeline, netrc)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) error); ok {\n\t\tr1 = returnFunc(context1, repo, pipeline, netrc)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_RegistryListPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryListPipeline'\ntype MockService_RegistryListPipeline_Call struct {\n\t*mock.Call\n}\n\n// RegistryListPipeline is a helper method to define mock.On call\n//   - context1 context.Context\n//   - repo *model.Repo\n//   - pipeline *model.Pipeline\n//   - netrc *model.Netrc\nfunc (_e *MockService_Expecter) RegistryListPipeline(context1 interface{}, repo interface{}, pipeline interface{}, netrc interface{}) *MockService_RegistryListPipeline_Call {\n\treturn &MockService_RegistryListPipeline_Call{Call: _e.mock.On(\"RegistryListPipeline\", context1, repo, pipeline, netrc)}\n}\n\nfunc (_c *MockService_RegistryListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc)) *MockService_RegistryListPipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.Repo\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Repo)\n\t\t}\n\t\tvar arg2 *model.Pipeline\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Pipeline)\n\t\t}\n\t\tvar arg3 *model.Netrc\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Netrc)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryListPipeline_Call) Return(registrys []*model.Registry, err error) *MockService_RegistryListPipeline_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error)) *MockService_RegistryListPipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) RegistryUpdate(repo *model.Repo, registry *model.Registry) error {\n\tret := _mock.Called(repo, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Registry) error); ok {\n\t\tr0 = returnFunc(repo, registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_RegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryUpdate'\ntype MockService_RegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// RegistryUpdate is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - registry *model.Registry\nfunc (_e *MockService_Expecter) RegistryUpdate(repo interface{}, registry interface{}) *MockService_RegistryUpdate_Call {\n\treturn &MockService_RegistryUpdate_Call{Call: _e.mock.On(\"RegistryUpdate\", repo, registry)}\n}\n\nfunc (_c *MockService_RegistryUpdate_Call) Run(run func(repo *model.Repo, registry *model.Registry)) *MockService_RegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryUpdate_Call) Return(err error) *MockService_RegistryUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_RegistryUpdate_Call) RunAndReturn(run func(repo *model.Repo, registry *model.Registry) error) *MockService_RegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/registry/service.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// Service defines a service for managing registries.\ntype Service interface {\n\tRegistryListPipeline(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Registry, error)\n\t// Repository registries\n\tRegistryFind(*model.Repo, string) (*model.Registry, error)\n\tRegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error)\n\tRegistryCreate(*model.Repo, *model.Registry) error\n\tRegistryUpdate(*model.Repo, *model.Registry) error\n\tRegistryDelete(*model.Repo, string) error\n\t// Organization registries\n\tOrgRegistryFind(int64, string) (*model.Registry, error)\n\tOrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error)\n\tOrgRegistryCreate(int64, *model.Registry) error\n\tOrgRegistryUpdate(int64, *model.Registry) error\n\tOrgRegistryDelete(int64, string) error\n\t// Global registries\n\tGlobalRegistryFind(string) (*model.Registry, error)\n\tGlobalRegistryList(*model.ListOptions) ([]*model.Registry, error)\n\tGlobalRegistryCreate(*model.Registry) error\n\tGlobalRegistryUpdate(*model.Registry) error\n\tGlobalRegistryDelete(string) error\n}\n\n// ReadOnlyService defines a service for managing registries.\ntype ReadOnlyService interface {\n\tGlobalRegistryFind(string) (*model.Registry, error)\n\tGlobalRegistryList(*model.ListOptions) ([]*model.Registry, error)\n}\n"
  },
  {
    "path": "server/services/registry/with_extension.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype withExtension struct {\n\tbase      Service\n\textension *httpExtension\n}\n\n// NewWithExtension returns a registry service that combines a base service with an HTTP extension.\n// The extension is called during RegistryListPipeline to fetch additional registry credentials and\n// the extension registries taking priority.\nfunc NewWithExtension(base Service, extension *httpExtension) Service {\n\treturn &withExtension{base, extension}\n}\n\nfunc (w *withExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) {\n\t// Get registries from base service\n\tbaseRegistries, err := w.base.RegistryListPipeline(ctx, repo, pipeline, netrc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get registries from HTTP extension\n\textensionRegistries, err := w.extension.RegistryListPipeline(ctx, repo, pipeline, netrc)\n\tif err != nil {\n\t\t// Log the error but don't fail - use base registries only\n\t\tlog.Warn().Err(err).Msg(\"failed to fetch registries from extension\")\n\t\treturn baseRegistries, nil\n\t}\n\n\tif len(extensionRegistries) == 0 {\n\t\treturn baseRegistries, nil\n\t}\n\n\t// Merge registries, with extension registries taking priority (no duplicates by address)\n\texists := make(map[string]struct{}, len(extensionRegistries))\n\tfor _, reg := range extensionRegistries {\n\t\texists[reg.Address] = struct{}{}\n\t}\n\n\tmerged := make([]*model.Registry, 0, len(baseRegistries)+len(extensionRegistries))\n\tmerged = append(merged, extensionRegistries...)\n\n\tfor _, reg := range baseRegistries {\n\t\tif _, ok := exists[reg.Address]; ok {\n\t\t\tcontinue\n\t\t}\n\t\texists[reg.Address] = struct{}{}\n\t\tmerged = append(merged, reg)\n\t}\n\n\treturn merged, nil\n}\n\n// All other methods delegate to the base service.\n\nfunc (w *withExtension) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) {\n\treturn w.base.RegistryFind(repo, addr)\n}\n\nfunc (w *withExtension) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn w.base.RegistryList(repo, p)\n}\n\nfunc (w *withExtension) RegistryCreate(repo *model.Repo, registry *model.Registry) error {\n\treturn w.base.RegistryCreate(repo, registry)\n}\n\nfunc (w *withExtension) RegistryUpdate(repo *model.Repo, registry *model.Registry) error {\n\treturn w.base.RegistryUpdate(repo, registry)\n}\n\nfunc (w *withExtension) RegistryDelete(repo *model.Repo, addr string) error {\n\treturn w.base.RegistryDelete(repo, addr)\n}\n\nfunc (w *withExtension) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) {\n\treturn w.base.OrgRegistryFind(owner, addr)\n}\n\nfunc (w *withExtension) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) {\n\treturn w.base.OrgRegistryList(owner, p)\n}\n\nfunc (w *withExtension) OrgRegistryCreate(owner int64, registry *model.Registry) error {\n\treturn w.base.OrgRegistryCreate(owner, registry)\n}\n\nfunc (w *withExtension) OrgRegistryUpdate(owner int64, registry *model.Registry) error {\n\treturn w.base.OrgRegistryUpdate(owner, registry)\n}\n\nfunc (w *withExtension) OrgRegistryDelete(owner int64, addr string) error {\n\treturn w.base.OrgRegistryDelete(owner, addr)\n}\n\nfunc (w *withExtension) GlobalRegistryFind(addr string) (*model.Registry, error) {\n\treturn w.base.GlobalRegistryFind(addr)\n}\n\nfunc (w *withExtension) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) {\n\treturn w.base.GlobalRegistryList(p)\n}\n\nfunc (w *withExtension) GlobalRegistryCreate(registry *model.Registry) error {\n\treturn w.base.GlobalRegistryCreate(registry)\n}\n\nfunc (w *withExtension) GlobalRegistryUpdate(registry *model.Registry) error {\n\treturn w.base.GlobalRegistryUpdate(registry)\n}\n\nfunc (w *withExtension) GlobalRegistryDelete(addr string) error {\n\treturn w.base.GlobalRegistryDelete(addr)\n}\n"
  },
  {
    "path": "server/services/registry/with_extension_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage registry\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/yaronf/httpsign\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestWithExtensionRegistryListPipeline(t *testing.T) {\n\tt.Parallel()\n\n\ttestTable := []struct {\n\t\tname          string\n\t\trepoName      string\n\t\tdbRegs        []*model.Registry\n\t\texpected      []*model.Registry\n\t\texpectedError bool\n\t}{\n\t\t{\n\t\t\tname:     \"Extension overrides base registry by name\",\n\t\t\trepoName: \"override-test\",\n\t\t\tdbRegs: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"docker.io\", Username: \"shared\", Password: \"db-value\"},\n\t\t\t\t{ID: 2, RepoID: 1, Address: \"quay.io\", Username: \"db-only\", Password: \"only-in-db\"},\n\t\t\t},\n\t\t\texpected: []*model.Registry{\n\t\t\t\t{ID: 2, RepoID: 1, Address: \"quay.io\", Username: \"db-only\", Password: \"only-in-db\"},\n\t\t\t\t{Address: \"docker.io\", Username: \"shared\", Password: \"external-value\"},\n\t\t\t\t{Address: \"codeberg.org\", Username: \"ext-only\", Password: \"only-in-ext\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Extension returns 204 no registries\",\n\t\t\trepoName: \"no-content\",\n\t\t\tdbRegs: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpected: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Extension error falls back to base registries\",\n\t\t\trepoName: \"server-error\",\n\t\t\tdbRegs: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpected: []*model.Registry{\n\t\t\t\t{ID: 1, RepoID: 1, Address: \"quay.io\", Username: \"db-secret\", Password: \"db-value\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t}\n\n\tpubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)\n\trequire.NoError(t, err, \"can't generate ed25519 keypair\")\n\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\t// check signature\n\t\tpubKeyID := \"woodpecker-ci-extensions\"\n\n\t\tverifier, err := httpsign.NewEd25519Verifier(pubEd25519Key,\n\t\t\thttpsign.NewVerifyConfig(),\n\t\t\thttpsign.Headers(\"@request-target\", \"content-digest\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"can't create verifier\", http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\terr = httpsign.VerifyRequest(pubKeyID, *verifier, r)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Invalid signature\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\ttype incoming struct {\n\t\t\tRepo     *model.Repo     `json:\"repo\"`\n\t\t\tPipeline *model.Pipeline `json:\"pipeline\"`\n\t\t\tNetrc    *model.Netrc    `json:\"netrc\"`\n\t\t}\n\n\t\tvar req incoming\n\t\tbody, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"can't read body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\terr = json.Unmarshal(body, &req)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Failed to parse JSON\"+err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tswitch req.Repo.Name {\n\t\tcase \"no-content\":\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t\treturn\n\t\tcase \"server-error\":\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tassert.NoError(t, json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"registries\": []*model.Registry{\n\t\t\t\t{Address: \"docker.io\", Username: \"shared\", Password: \"external-value\"},\n\t\t\t\t{Address: \"codeberg.org\", Username: \"ext-only\", Password: \"only-in-ext\"},\n\t\t\t},\n\t\t}))\n\t}\n\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tclient, err := utils.NewHTTPClient(privEd25519Key, \"loopback\")\n\trequire.NoError(t, err)\n\n\thttpExtension := NewHTTP(ts.URL, client, true)\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockStore := store_mocks.NewMockStore(t)\n\t\t\tmockStore.On(\"RegistryList\", mock.Anything, true, mock.Anything).Return(tt.dbRegs, nil)\n\n\t\t\tcombined := NewWithExtension(NewDB(mockStore), httpExtension)\n\n\t\t\tregistries, err := combined.RegistryListPipeline(\n\t\t\t\tt.Context(),\n\t\t\t\t&model.Repo{ID: 1, Name: tt.repoName},\n\t\t\t\t&model.Pipeline{},\n\t\t\t\tnil,\n\t\t\t)\n\t\t\tif tt.expectedError {\n\t\t\t\trequire.Error(t, err, \"expected an error\")\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err, \"error fetching registries\")\n\t\t\t}\n\n\t\t\tassert.ElementsMatch(t, tt.expected, registries, \"expected some other registries\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/services/secret/combined.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype combined struct {\n\tbase      Service\n\textension *httpExtension\n}\n\n// NewCombined returns a secret service that combines a base service with an HTTP extension.\n// The extension is called during SecretListPipeline to fetch additional secrets and\n// the extension secrets taking priority.\nfunc NewCombined(base Service, extension *httpExtension) Service {\n\treturn &combined{base, extension}\n}\n\nfunc (c *combined) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {\n\t// Get secrets from base service\n\tbaseSecrets, err := c.base.SecretListPipeline(ctx, repo, pipeline, netrc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get secrets from HTTP extension\n\textensionSecrets, err := c.extension.SecretListPipeline(ctx, repo, pipeline, netrc)\n\tif err != nil {\n\t\t// Log the error but don't fail - use base secrets only\n\t\tlog.Warn().Err(err).Msg(\"failed to fetch secrets from extension\")\n\t\treturn baseSecrets, nil\n\t}\n\n\tif len(extensionSecrets) == 0 {\n\t\treturn baseSecrets, nil\n\t}\n\n\t// Merge secrets, with extension secrets taking priority (no duplicates by name)\n\texists := make(map[string]struct{}, len(extensionSecrets))\n\tfor _, s := range extensionSecrets {\n\t\texists[s.Name] = struct{}{}\n\t}\n\n\tmerged := make([]*model.Secret, 0, len(baseSecrets)+len(extensionSecrets))\n\tmerged = append(merged, extensionSecrets...)\n\n\tfor _, s := range baseSecrets {\n\t\tif _, ok := exists[s.Name]; ok {\n\t\t\tcontinue\n\t\t}\n\t\texists[s.Name] = struct{}{}\n\t\tmerged = append(merged, s)\n\t}\n\n\treturn merged, nil\n}\n\n// All other methods delegate to the base service.\n\nfunc (c *combined) SecretFind(repo *model.Repo, name string) (*model.Secret, error) {\n\treturn c.base.SecretFind(repo, name)\n}\n\nfunc (c *combined) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret, error) {\n\treturn c.base.SecretList(repo, p)\n}\n\nfunc (c *combined) SecretCreate(repo *model.Repo, secret *model.Secret) error {\n\treturn c.base.SecretCreate(repo, secret)\n}\n\nfunc (c *combined) SecretUpdate(repo *model.Repo, secret *model.Secret) error {\n\treturn c.base.SecretUpdate(repo, secret)\n}\n\nfunc (c *combined) SecretDelete(repo *model.Repo, name string) error {\n\treturn c.base.SecretDelete(repo, name)\n}\n\nfunc (c *combined) OrgSecretFind(orgID int64, name string) (*model.Secret, error) {\n\treturn c.base.OrgSecretFind(orgID, name)\n}\n\nfunc (c *combined) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) {\n\treturn c.base.OrgSecretList(orgID, p)\n}\n\nfunc (c *combined) OrgSecretCreate(orgID int64, secret *model.Secret) error {\n\treturn c.base.OrgSecretCreate(orgID, secret)\n}\n\nfunc (c *combined) OrgSecretUpdate(orgID int64, secret *model.Secret) error {\n\treturn c.base.OrgSecretUpdate(orgID, secret)\n}\n\nfunc (c *combined) OrgSecretDelete(orgID int64, name string) error {\n\treturn c.base.OrgSecretDelete(orgID, name)\n}\n\nfunc (c *combined) GlobalSecretFind(name string) (*model.Secret, error) {\n\treturn c.base.GlobalSecretFind(name)\n}\n\nfunc (c *combined) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {\n\treturn c.base.GlobalSecretList(p)\n}\n\nfunc (c *combined) GlobalSecretCreate(secret *model.Secret) error {\n\treturn c.base.GlobalSecretCreate(secret)\n}\n\nfunc (c *combined) GlobalSecretUpdate(secret *model.Secret) error {\n\treturn c.base.GlobalSecretUpdate(secret)\n}\n\nfunc (c *combined) GlobalSecretDelete(name string) error {\n\treturn c.base.GlobalSecretDelete(name)\n}\n"
  },
  {
    "path": "server/services/secret/combined_test.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret_test\n\nimport (\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/yaronf/httpsign\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/secret\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nfunc TestCombinedSecretListPipeline(t *testing.T) {\n\tt.Parallel()\n\n\ttestTable := []struct {\n\t\tname          string\n\t\trepoName      string\n\t\tdbSecrets     []*model.Secret\n\t\texpected      []*model.Secret\n\t\texpectedError bool\n\t}{\n\t\t{\n\t\t\tname:     \"Extension overrides base secret by name\",\n\t\t\trepoName: \"override-test\",\n\t\t\tdbSecrets: []*model.Secret{\n\t\t\t\t{ID: 1, RepoID: 1, Name: \"shared\", Value: \"db-value\"},\n\t\t\t\t{ID: 2, RepoID: 1, Name: \"db-only\", Value: \"only-in-db\"},\n\t\t\t},\n\t\t\texpected: []*model.Secret{\n\t\t\t\t{Name: \"shared\", Value: \"external-value\"},\n\t\t\t\t{Name: \"ext-only\", Value: \"only-in-ext\"},\n\t\t\t\t{ID: 2, RepoID: 1, Name: \"db-only\", Value: \"only-in-db\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Extension returns 204 no secrets\",\n\t\t\trepoName: \"no-content\",\n\t\t\tdbSecrets: []*model.Secret{\n\t\t\t\t{ID: 1, RepoID: 1, Name: \"db-secret\", Value: \"db-value\"},\n\t\t\t},\n\t\t\texpected: []*model.Secret{\n\t\t\t\t{ID: 1, RepoID: 1, Name: \"db-secret\", Value: \"db-value\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"Extension error falls back to base secrets\",\n\t\t\trepoName: \"server-error\",\n\t\t\tdbSecrets: []*model.Secret{\n\t\t\t\t{ID: 1, RepoID: 1, Name: \"db-secret\", Value: \"db-value\"},\n\t\t\t},\n\t\t\texpected: []*model.Secret{\n\t\t\t\t{ID: 1, RepoID: 1, Name: \"db-secret\", Value: \"db-value\"},\n\t\t\t},\n\t\t\texpectedError: false,\n\t\t},\n\t}\n\n\tpubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)\n\trequire.NoError(t, err, \"can't generate ed25519 keypair\")\n\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\t// check signature\n\t\tpubKeyID := \"woodpecker-ci-extensions\"\n\n\t\tverifier, err := httpsign.NewEd25519Verifier(pubEd25519Key,\n\t\t\thttpsign.NewVerifyConfig(),\n\t\t\thttpsign.Headers(\"@request-target\", \"content-digest\"))\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"can't create verifier\", http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\terr = httpsign.VerifyRequest(pubKeyID, *verifier, r)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Invalid signature\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\ttype incoming struct {\n\t\t\tRepo     *model.Repo     `json:\"repo\"`\n\t\t\tPipeline *model.Pipeline `json:\"pipeline\"`\n\t\t\tNetrc    *model.Netrc    `json:\"netrc\"`\n\t\t}\n\n\t\tvar req incoming\n\t\tbody, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"can't read body\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\terr = json.Unmarshal(body, &req)\n\t\tif err != nil {\n\t\t\thttp.Error(w, \"Failed to parse JSON\"+err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tswitch req.Repo.Name {\n\t\tcase \"no-content\":\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t\treturn\n\t\tcase \"server-error\":\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tassert.NoError(t, json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"secrets\": []*model.Secret{\n\t\t\t\t{Name: \"shared\", Value: \"external-value\"},\n\t\t\t\t{Name: \"ext-only\", Value: \"only-in-ext\"},\n\t\t\t},\n\t\t}))\n\t}\n\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tclient, err := utils.NewHTTPClient(privEd25519Key, \"loopback\")\n\trequire.NoError(t, err)\n\n\thttpExtension := secret.NewHTTP(ts.URL, client, true)\n\n\tfor _, tt := range testTable {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockStore := store_mocks.NewMockStore(t)\n\t\t\tmockStore.On(\"SecretList\", mock.Anything, true, mock.Anything).Return(tt.dbSecrets, nil)\n\n\t\t\tcombined := secret.NewCombined(secret.NewDB(mockStore), httpExtension)\n\n\t\t\tsecrets, err := combined.SecretListPipeline(\n\t\t\t\tt.Context(),\n\t\t\t\t&model.Repo{ID: 1, Name: tt.repoName},\n\t\t\t\t&model.Pipeline{},\n\t\t\t\tnil,\n\t\t\t)\n\t\t\tif tt.expectedError {\n\t\t\t\trequire.Error(t, err, \"expected an error\")\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err, \"error fetching secrets\")\n\t\t\t}\n\n\t\t\tassert.ElementsMatch(t, tt.expected, secrets, \"expected some other secrets\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "server/services/secret/db.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n)\n\ntype db struct {\n\tstore store.Store\n}\n\n// NewDB returns a new local secret service.\nfunc NewDB(store store.Store) Service {\n\treturn &db{store: store}\n}\n\nfunc (d *db) SecretFind(repo *model.Repo, name string) (*model.Secret, error) {\n\treturn d.store.SecretFind(repo, name)\n}\n\nfunc (d *db) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret, error) {\n\treturn d.store.SecretList(repo, false, p)\n}\n\nfunc (d *db) SecretListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline, _ *model.Netrc) ([]*model.Secret, error) {\n\ts, err := d.store.SecretList(repo, true, &model.ListOptions{All: true})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Return only secrets with unique name\n\t// Priority order in case of duplicate names are repository, user/organization, global\n\tsecrets := make([]*model.Secret, 0, len(s))\n\tuniq := make(map[string]struct{})\n\tfor _, condition := range []struct {\n\t\tIsRepository   bool\n\t\tIsOrganization bool\n\t\tIsGlobal       bool\n\t}{\n\t\t{IsRepository: true},\n\t\t{IsOrganization: true},\n\t\t{IsGlobal: true},\n\t} {\n\t\tfor _, secret := range s {\n\t\t\tif secret.IsRepository() != condition.IsRepository || secret.IsOrganization() != condition.IsOrganization || secret.IsGlobal() != condition.IsGlobal {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif _, ok := uniq[secret.Name]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tuniq[secret.Name] = struct{}{}\n\t\t\tsecrets = append(secrets, secret)\n\t\t}\n\t}\n\treturn secrets, nil\n}\n\nfunc (d *db) SecretCreate(_ *model.Repo, in *model.Secret) error {\n\treturn d.store.SecretCreate(in)\n}\n\nfunc (d *db) SecretUpdate(_ *model.Repo, in *model.Secret) error {\n\treturn d.store.SecretUpdate(in)\n}\n\nfunc (d *db) SecretDelete(repo *model.Repo, name string) error {\n\tsecret, err := d.store.SecretFind(repo, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.SecretDelete(secret)\n}\n\nfunc (d *db) OrgSecretFind(owner int64, name string) (*model.Secret, error) {\n\treturn d.store.OrgSecretFind(owner, name)\n}\n\nfunc (d *db) OrgSecretList(owner int64, p *model.ListOptions) ([]*model.Secret, error) {\n\treturn d.store.OrgSecretList(owner, p)\n}\n\nfunc (d *db) OrgSecretCreate(_ int64, in *model.Secret) error {\n\treturn d.store.SecretCreate(in)\n}\n\nfunc (d *db) OrgSecretUpdate(_ int64, in *model.Secret) error {\n\treturn d.store.SecretUpdate(in)\n}\n\nfunc (d *db) OrgSecretDelete(owner int64, name string) error {\n\tsecret, err := d.store.OrgSecretFind(owner, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.SecretDelete(secret)\n}\n\nfunc (d *db) GlobalSecretFind(owner string) (*model.Secret, error) {\n\treturn d.store.GlobalSecretFind(owner)\n}\n\nfunc (d *db) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {\n\treturn d.store.GlobalSecretList(p)\n}\n\nfunc (d *db) GlobalSecretCreate(in *model.Secret) error {\n\treturn d.store.SecretCreate(in)\n}\n\nfunc (d *db) GlobalSecretUpdate(in *model.Secret) error {\n\treturn d.store.SecretUpdate(in)\n}\n\nfunc (d *db) GlobalSecretDelete(name string) error {\n\tsecret, err := d.store.GlobalSecretFind(name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn d.store.SecretDelete(secret)\n}\n"
  },
  {
    "path": "server/services/secret/db_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/secret\"\n\tstore_mocks \"go.woodpecker-ci.org/woodpecker/v3/server/store/mocks\"\n)\n\nvar (\n\tglobalSecret = &model.Secret{\n\t\tID:     1,\n\t\tOrgID:  0,\n\t\tRepoID: 0,\n\t\tName:   \"secret\",\n\t\tValue:  \"value-global\",\n\t}\n\n\torgSecret = &model.Secret{\n\t\tID:     2,\n\t\tOrgID:  1,\n\t\tRepoID: 0,\n\t\tName:   \"secret\",\n\t\tValue:  \"value-org\",\n\t}\n\n\trepoSecret = &model.Secret{\n\t\tID:     3,\n\t\tOrgID:  0,\n\t\tRepoID: 1,\n\t\tName:   \"secret\",\n\t\tValue:  \"value-repo\",\n\t}\n)\n\nfunc TestSecretListPipeline(t *testing.T) {\n\tmockStore := store_mocks.NewMockStore(t)\n\n\tmockStore.On(\"SecretList\", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{\n\t\tglobalSecret,\n\t\torgSecret,\n\t\trepoSecret,\n\t}, nil)\n\n\ts, err := secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, s, 1)\n\tassert.Equal(t, \"value-repo\", s[0].Value)\n\n\tmockStore.On(\"SecretList\", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{\n\t\tglobalSecret,\n\t\torgSecret,\n\t}, nil)\n\n\ts, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, s, 1)\n\tassert.Equal(t, \"value-org\", s[0].Value)\n\n\tmockStore.On(\"SecretList\", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{\n\t\tglobalSecret,\n\t}, nil)\n\n\ts, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, s, 1)\n\tassert.Equal(t, \"value-global\", s[0].Value)\n}\n"
  },
  {
    "path": "server/services/secret/http.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n)\n\ntype httpExtension struct {\n\tendpoint     string\n\tclient       *utils.Client\n\tincludeNetrc bool\n}\n\ntype secretRequestStructure struct {\n\tRepo     *model.Repo     `json:\"repo\"`\n\tPipeline *model.Pipeline `json:\"pipeline\"`\n\tNetrc    *model.Netrc    `json:\"netrc,omitempty\"`\n}\n\ntype secretResponseStructure struct {\n\tSecrets []*model.Secret `json:\"secrets\"`\n}\n\n// NewHTTP returns a new HTTP secret extension client.\nfunc NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) *httpExtension {\n\treturn &httpExtension{endpoint: endpoint, client: client, includeNetrc: includeNetrc}\n}\n\n// SecretListPipeline fetches secrets from an external HTTP extension.\nfunc (h *httpExtension) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {\n\tbody := secretRequestStructure{\n\t\tRepo:     repo,\n\t\tPipeline: pipeline,\n\t}\n\tif h.includeNetrc {\n\t\tbody.Netrc = netrc\n\t}\n\n\tresponse := new(secretResponseStructure)\n\tstatus, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response)\n\tif err != nil && status != http.StatusNoContent {\n\t\treturn nil, fmt.Errorf(\"failed to fetch secrets via http (%d) %w\", status, err)\n\t}\n\n\tif status != http.StatusOK {\n\t\t// 204 No Content means no additional secrets\n\t\treturn nil, nil\n\t}\n\n\treturn response.Secrets, nil\n}\n"
  },
  {
    "path": "server/services/secret/mocks/mock_Service.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockService(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockService {\n\tmock := &MockService{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockService is an autogenerated mock type for the Service type\ntype MockService struct {\n\tmock.Mock\n}\n\ntype MockService_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockService) EXPECT() *MockService_Expecter {\n\treturn &MockService_Expecter{mock: &_m.Mock}\n}\n\n// GlobalSecretCreate provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalSecretCreate(secret *model.Secret) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretCreate'\ntype MockService_GlobalSecretCreate_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretCreate is a helper method to define mock.On call\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) GlobalSecretCreate(secret interface{}) *MockService_GlobalSecretCreate_Call {\n\treturn &MockService_GlobalSecretCreate_Call{Call: _e.mock.On(\"GlobalSecretCreate\", secret)}\n}\n\nfunc (_c *MockService_GlobalSecretCreate_Call) Run(run func(secret *model.Secret)) *MockService_GlobalSecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretCreate_Call) Return(err error) *MockService_GlobalSecretCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretCreate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockService_GlobalSecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretDelete provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalSecretDelete(s string) error {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretDelete'\ntype MockService_GlobalSecretDelete_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretDelete is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockService_Expecter) GlobalSecretDelete(s interface{}) *MockService_GlobalSecretDelete_Call {\n\treturn &MockService_GlobalSecretDelete_Call{Call: _e.mock.On(\"GlobalSecretDelete\", s)}\n}\n\nfunc (_c *MockService_GlobalSecretDelete_Call) Run(run func(s string)) *MockService_GlobalSecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretDelete_Call) Return(err error) *MockService_GlobalSecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretDelete_Call) RunAndReturn(run func(s string) error) *MockService_GlobalSecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretFind provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalSecretFind(s string) (*model.Secret, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Secret); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_GlobalSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretFind'\ntype MockService_GlobalSecretFind_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretFind is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockService_Expecter) GlobalSecretFind(s interface{}) *MockService_GlobalSecretFind_Call {\n\treturn &MockService_GlobalSecretFind_Call{Call: _e.mock.On(\"GlobalSecretFind\", s)}\n}\n\nfunc (_c *MockService_GlobalSecretFind_Call) Run(run func(s string)) *MockService_GlobalSecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretFind_Call) Return(secret *model.Secret, err error) *MockService_GlobalSecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretFind_Call) RunAndReturn(run func(s string) (*model.Secret, error)) *MockService_GlobalSecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretList provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalSecretList(listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_GlobalSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretList'\ntype MockService_GlobalSecretList_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) GlobalSecretList(listOptions interface{}) *MockService_GlobalSecretList_Call {\n\treturn &MockService_GlobalSecretList_Call{Call: _e.mock.On(\"GlobalSecretList\", listOptions)}\n}\n\nfunc (_c *MockService_GlobalSecretList_Call) Run(run func(listOptions *model.ListOptions)) *MockService_GlobalSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretList_Call) Return(secrets []*model.Secret, err error) *MockService_GlobalSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_GlobalSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) GlobalSecretUpdate(secret *model.Secret) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_GlobalSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretUpdate'\ntype MockService_GlobalSecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretUpdate is a helper method to define mock.On call\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) GlobalSecretUpdate(secret interface{}) *MockService_GlobalSecretUpdate_Call {\n\treturn &MockService_GlobalSecretUpdate_Call{Call: _e.mock.On(\"GlobalSecretUpdate\", secret)}\n}\n\nfunc (_c *MockService_GlobalSecretUpdate_Call) Run(run func(secret *model.Secret)) *MockService_GlobalSecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretUpdate_Call) Return(err error) *MockService_GlobalSecretUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_GlobalSecretUpdate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockService_GlobalSecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretCreate provides a mock function for the type MockService\nfunc (_mock *MockService) OrgSecretCreate(n int64, secret *model.Secret) error {\n\tret := _mock.Called(n, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.Secret) error); ok {\n\t\tr0 = returnFunc(n, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretCreate'\ntype MockService_OrgSecretCreate_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretCreate is a helper method to define mock.On call\n//   - n int64\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) OrgSecretCreate(n interface{}, secret interface{}) *MockService_OrgSecretCreate_Call {\n\treturn &MockService_OrgSecretCreate_Call{Call: _e.mock.On(\"OrgSecretCreate\", n, secret)}\n}\n\nfunc (_c *MockService_OrgSecretCreate_Call) Run(run func(n int64, secret *model.Secret)) *MockService_OrgSecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretCreate_Call) Return(err error) *MockService_OrgSecretCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretCreate_Call) RunAndReturn(run func(n int64, secret *model.Secret) error) *MockService_OrgSecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretDelete provides a mock function for the type MockService\nfunc (_mock *MockService) OrgSecretDelete(n int64, s string) error {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretDelete'\ntype MockService_OrgSecretDelete_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretDelete is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockService_Expecter) OrgSecretDelete(n interface{}, s interface{}) *MockService_OrgSecretDelete_Call {\n\treturn &MockService_OrgSecretDelete_Call{Call: _e.mock.On(\"OrgSecretDelete\", n, s)}\n}\n\nfunc (_c *MockService_OrgSecretDelete_Call) Run(run func(n int64, s string)) *MockService_OrgSecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretDelete_Call) Return(err error) *MockService_OrgSecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretDelete_Call) RunAndReturn(run func(n int64, s string) error) *MockService_OrgSecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretFind provides a mock function for the type MockService\nfunc (_mock *MockService) OrgSecretFind(n int64, s string) (*model.Secret, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *model.Secret); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_OrgSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretFind'\ntype MockService_OrgSecretFind_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretFind is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockService_Expecter) OrgSecretFind(n interface{}, s interface{}) *MockService_OrgSecretFind_Call {\n\treturn &MockService_OrgSecretFind_Call{Call: _e.mock.On(\"OrgSecretFind\", n, s)}\n}\n\nfunc (_c *MockService_OrgSecretFind_Call) Run(run func(n int64, s string)) *MockService_OrgSecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretFind_Call) Return(secret *model.Secret, err error) *MockService_OrgSecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretFind_Call) RunAndReturn(run func(n int64, s string) (*model.Secret, error)) *MockService_OrgSecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretList provides a mock function for the type MockService\nfunc (_mock *MockService) OrgSecretList(n int64, listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(n, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(n, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(n, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(n, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_OrgSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretList'\ntype MockService_OrgSecretList_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretList is a helper method to define mock.On call\n//   - n int64\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) OrgSecretList(n interface{}, listOptions interface{}) *MockService_OrgSecretList_Call {\n\treturn &MockService_OrgSecretList_Call{Call: _e.mock.On(\"OrgSecretList\", n, listOptions)}\n}\n\nfunc (_c *MockService_OrgSecretList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockService_OrgSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretList_Call) Return(secrets []*model.Secret, err error) *MockService_OrgSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_OrgSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) OrgSecretUpdate(n int64, secret *model.Secret) error {\n\tret := _mock.Called(n, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.Secret) error); ok {\n\t\tr0 = returnFunc(n, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_OrgSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretUpdate'\ntype MockService_OrgSecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretUpdate is a helper method to define mock.On call\n//   - n int64\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) OrgSecretUpdate(n interface{}, secret interface{}) *MockService_OrgSecretUpdate_Call {\n\treturn &MockService_OrgSecretUpdate_Call{Call: _e.mock.On(\"OrgSecretUpdate\", n, secret)}\n}\n\nfunc (_c *MockService_OrgSecretUpdate_Call) Run(run func(n int64, secret *model.Secret)) *MockService_OrgSecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretUpdate_Call) Return(err error) *MockService_OrgSecretUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_OrgSecretUpdate_Call) RunAndReturn(run func(n int64, secret *model.Secret) error) *MockService_OrgSecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretCreate provides a mock function for the type MockService\nfunc (_mock *MockService) SecretCreate(repo *model.Repo, secret *model.Secret) error {\n\tret := _mock.Called(repo, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Secret) error); ok {\n\t\tr0 = returnFunc(repo, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_SecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretCreate'\ntype MockService_SecretCreate_Call struct {\n\t*mock.Call\n}\n\n// SecretCreate is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) SecretCreate(repo interface{}, secret interface{}) *MockService_SecretCreate_Call {\n\treturn &MockService_SecretCreate_Call{Call: _e.mock.On(\"SecretCreate\", repo, secret)}\n}\n\nfunc (_c *MockService_SecretCreate_Call) Run(run func(repo *model.Repo, secret *model.Secret)) *MockService_SecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretCreate_Call) Return(err error) *MockService_SecretCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretCreate_Call) RunAndReturn(run func(repo *model.Repo, secret *model.Secret) error) *MockService_SecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretDelete provides a mock function for the type MockService\nfunc (_mock *MockService) SecretDelete(repo *model.Repo, s string) error {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) error); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_SecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretDelete'\ntype MockService_SecretDelete_Call struct {\n\t*mock.Call\n}\n\n// SecretDelete is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockService_Expecter) SecretDelete(repo interface{}, s interface{}) *MockService_SecretDelete_Call {\n\treturn &MockService_SecretDelete_Call{Call: _e.mock.On(\"SecretDelete\", repo, s)}\n}\n\nfunc (_c *MockService_SecretDelete_Call) Run(run func(repo *model.Repo, s string)) *MockService_SecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretDelete_Call) Return(err error) *MockService_SecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretDelete_Call) RunAndReturn(run func(repo *model.Repo, s string) error) *MockService_SecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretFind provides a mock function for the type MockService\nfunc (_mock *MockService) SecretFind(repo *model.Repo, s string) (*model.Secret, error) {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(repo, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Secret); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok {\n\t\tr1 = returnFunc(repo, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_SecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretFind'\ntype MockService_SecretFind_Call struct {\n\t*mock.Call\n}\n\n// SecretFind is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockService_Expecter) SecretFind(repo interface{}, s interface{}) *MockService_SecretFind_Call {\n\treturn &MockService_SecretFind_Call{Call: _e.mock.On(\"SecretFind\", repo, s)}\n}\n\nfunc (_c *MockService_SecretFind_Call) Run(run func(repo *model.Repo, s string)) *MockService_SecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretFind_Call) Return(secret *model.Secret, err error) *MockService_SecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Secret, error)) *MockService_SecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretList provides a mock function for the type MockService\nfunc (_mock *MockService) SecretList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(repo, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(repo, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(repo, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(repo, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_SecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretList'\ntype MockService_SecretList_Call struct {\n\t*mock.Call\n}\n\n// SecretList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - listOptions *model.ListOptions\nfunc (_e *MockService_Expecter) SecretList(repo interface{}, listOptions interface{}) *MockService_SecretList_Call {\n\treturn &MockService_SecretList_Call{Call: _e.mock.On(\"SecretList\", repo, listOptions)}\n}\n\nfunc (_c *MockService_SecretList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockService_SecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretList_Call) Return(secrets []*model.Secret, err error) *MockService_SecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_SecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretListPipeline provides a mock function for the type MockService\nfunc (_mock *MockService) SecretListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {\n\tret := _mock.Called(context1, repo, pipeline, netrc)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretListPipeline\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(context1, repo, pipeline, netrc)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) []*model.Secret); ok {\n\t\tr0 = returnFunc(context1, repo, pipeline, netrc)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) error); ok {\n\t\tr1 = returnFunc(context1, repo, pipeline, netrc)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockService_SecretListPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretListPipeline'\ntype MockService_SecretListPipeline_Call struct {\n\t*mock.Call\n}\n\n// SecretListPipeline is a helper method to define mock.On call\n//   - context1 context.Context\n//   - repo *model.Repo\n//   - pipeline *model.Pipeline\n//   - netrc *model.Netrc\nfunc (_e *MockService_Expecter) SecretListPipeline(context1 interface{}, repo interface{}, pipeline interface{}, netrc interface{}) *MockService_SecretListPipeline_Call {\n\treturn &MockService_SecretListPipeline_Call{Call: _e.mock.On(\"SecretListPipeline\", context1, repo, pipeline, netrc)}\n}\n\nfunc (_c *MockService_SecretListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc)) *MockService_SecretListPipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 *model.Repo\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Repo)\n\t\t}\n\t\tvar arg2 *model.Pipeline\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.Pipeline)\n\t\t}\n\t\tvar arg3 *model.Netrc\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.Netrc)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretListPipeline_Call) Return(secrets []*model.Secret, err error) *MockService_SecretListPipeline_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error)) *MockService_SecretListPipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretUpdate provides a mock function for the type MockService\nfunc (_mock *MockService) SecretUpdate(repo *model.Repo, secret *model.Secret) error {\n\tret := _mock.Called(repo, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Secret) error); ok {\n\t\tr0 = returnFunc(repo, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockService_SecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretUpdate'\ntype MockService_SecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// SecretUpdate is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - secret *model.Secret\nfunc (_e *MockService_Expecter) SecretUpdate(repo interface{}, secret interface{}) *MockService_SecretUpdate_Call {\n\treturn &MockService_SecretUpdate_Call{Call: _e.mock.On(\"SecretUpdate\", repo, secret)}\n}\n\nfunc (_c *MockService_SecretUpdate_Call) Run(run func(repo *model.Repo, secret *model.Secret)) *MockService_SecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockService_SecretUpdate_Call) Return(err error) *MockService_SecretUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockService_SecretUpdate_Call) RunAndReturn(run func(repo *model.Repo, secret *model.Secret) error) *MockService_SecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/services/secret/service.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage secret\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// Service defines a service for managing secrets.\ntype Service interface {\n\tSecretListPipeline(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error)\n\t// Repository secrets\n\tSecretFind(*model.Repo, string) (*model.Secret, error)\n\tSecretList(*model.Repo, *model.ListOptions) ([]*model.Secret, error)\n\tSecretCreate(*model.Repo, *model.Secret) error\n\tSecretUpdate(*model.Repo, *model.Secret) error\n\tSecretDelete(*model.Repo, string) error\n\t// Organization secrets\n\tOrgSecretFind(int64, string) (*model.Secret, error)\n\tOrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error)\n\tOrgSecretCreate(int64, *model.Secret) error\n\tOrgSecretUpdate(int64, *model.Secret) error\n\tOrgSecretDelete(int64, string) error\n\t// Global secrets\n\tGlobalSecretFind(string) (*model.Secret, error)\n\tGlobalSecretList(*model.ListOptions) ([]*model.Secret, error)\n\tGlobalSecretCreate(*model.Secret) error\n\tGlobalSecretUpdate(*model.Secret) error\n\tGlobalSecretDelete(string) error\n}\n"
  },
  {
    "path": "server/services/setup.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage services\n\nimport (\n\t\"crypto\"\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/config\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/registry\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/secret\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc setupRegistryService(store store.Store, dockerConfig, endpoint string, includeNetrc bool, client *utils.Client) registry.Service {\n\tvar service registry.Service\n\tif dockerConfig != \"\" {\n\t\tservice = registry.NewCombined(\n\t\t\tregistry.NewDB(store),\n\t\t\tregistry.NewFilesystem(dockerConfig),\n\t\t)\n\t} else {\n\t\tservice = registry.NewDB(store)\n\t}\n\n\t// Wrap with global HTTP extension if configured\n\tif endpoint != \"\" {\n\t\tservice = registry.NewWithExtension(service, registry.NewHTTP(endpoint, client, includeNetrc))\n\t}\n\n\treturn service\n}\n\nfunc setupSecretService(store store.Store, endpoint string, client *utils.Client, includeNetrc bool) secret.Service {\n\t// TODO(1544): fix encrypted store\n\t// // encryption\n\t// encryptedSecretStore := encryptedStore.NewSecretStore(v)\n\t// err := encryption.Encryption(c, v).WithClient(encryptedSecretStore).Build()\n\t// if err != nil {\n\t// \tlog.Fatal().Err(err).Msg(\"could not create encryption service\")\n\t// }\n\n\tif endpoint != \"\" {\n\t\treturn secret.NewCombined(secret.NewDB(store), secret.NewHTTP(endpoint, client, includeNetrc))\n\t}\n\n\treturn secret.NewDB(store)\n}\n\nfunc setupConfigService(c *cli.Command, client *utils.Client) (config.Service, error) {\n\ttimeout := c.Duration(\"forge-timeout\")\n\tretries := c.Uint(\"forge-retry\")\n\tif retries == 0 {\n\t\treturn nil, fmt.Errorf(\"WOODPECKER_FORGE_RETRY can not be 0\")\n\t}\n\tconfigFetcher := config.NewForge(timeout, retries)\n\n\tif endpoint := c.String(\"config-extension-endpoint\"); endpoint != \"\" {\n\t\thttpFetcher := config.NewHTTP(endpoint, client, c.Bool(\"config-extension-netrc\"))\n\t\tif c.Bool(\"config-extension-exclusive\") {\n\t\t\treturn httpFetcher, nil\n\t\t}\n\t\treturn config.NewCombined(configFetcher, httpFetcher), nil\n\t}\n\n\treturn configFetcher, nil\n}\n\n// setupSignatureKeys generate or load key pair to sign webhooks requests (i.e. used for service extensions).\nfunc setupSignatureKeys(_store store.Store) (ed25519.PrivateKey, crypto.PublicKey, error) {\n\tprivKeyID := \"signature-private-key\"\n\n\tprivKey, err := _store.ServerConfigGet(privKeyID)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\t_, privKey, err := ed25519.GenerateKey(rand.Reader)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to generate private key: %w\", err)\n\t\t}\n\t\terr = _store.ServerConfigSet(privKeyID, hex.EncodeToString(privKey))\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to store private key: %w\", err)\n\t\t}\n\t\tlog.Debug().Msg(\"created private key\")\n\t\treturn privKey, privKey.Public(), nil\n\t} else if err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to load private key: %w\", err)\n\t}\n\tprivKeyStr, err := hex.DecodeString(privKey)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to decode private key: %w\", err)\n\t}\n\tprivateKey := ed25519.PrivateKey(privKeyStr)\n\treturn privateKey, privateKey.Public(), nil\n}\n\nfunc setupForgeService(c *cli.Command, _store store.Store) error {\n\t_forge, err := _store.ForgeGet(1)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn err\n\t}\n\tforgeExists := err == nil\n\tif _forge == nil {\n\t\t_forge = &model.Forge{\n\t\t\tID: 0,\n\t\t}\n\t}\n\tif _forge.AdditionalOptions == nil {\n\t\t_forge.AdditionalOptions = make(map[string]any)\n\t}\n\n\t_forge.OAuthClientID = strings.TrimSpace(c.String(\"forge-oauth-client\"))\n\t_forge.OAuthClientSecret = strings.TrimSpace(c.String(\"forge-oauth-secret\"))\n\t_forge.URL = c.String(\"forge-url\")\n\t_forge.SkipVerify = c.Bool(\"forge-skip-verify\")\n\t_forge.OAuthHost = c.String(\"forge-oauth-host\")\n\n\tswitch {\n\tcase c.String(\"addon-forge\") != \"\":\n\t\t_forge.Type = model.ForgeTypeAddon\n\t\t_forge.AdditionalOptions[\"executable\"] = c.String(\"addon-forge\")\n\tcase c.Bool(\"github\"):\n\t\t_forge.Type = model.ForgeTypeGithub\n\t\t_forge.AdditionalOptions[\"merge-ref\"] = c.Bool(\"github-merge-ref\")\n\t\t_forge.AdditionalOptions[\"public-only\"] = c.Bool(\"github-public-only\")\n\t\tif _forge.URL == \"\" {\n\t\t\t_forge.URL = \"https://github.com\"\n\t\t}\n\tcase c.Bool(\"gitlab\"):\n\t\t_forge.Type = model.ForgeTypeGitlab\n\t\tif _forge.URL == \"\" {\n\t\t\t_forge.URL = \"https://gitlab.com\"\n\t\t}\n\tcase c.Bool(\"gitea\"):\n\t\t_forge.Type = model.ForgeTypeGitea\n\t\tif _forge.URL == \"\" {\n\t\t\t_forge.URL = \"https://try.gitea.com\"\n\t\t}\n\tcase c.Bool(\"forgejo\"):\n\t\t_forge.Type = model.ForgeTypeForgejo\n\t\t// TODO enable oauth URL with generic config option\n\t\tif _forge.URL == \"\" {\n\t\t\t_forge.URL = \"https://next.forgejo.org\"\n\t\t}\n\tcase c.Bool(\"bitbucket\"):\n\t\t_forge.Type = model.ForgeTypeBitbucket\n\tcase c.Bool(\"bitbucket-dc\"):\n\t\t_forge.Type = model.ForgeTypeBitbucketDatacenter\n\t\t_forge.AdditionalOptions[\"git-username\"] = c.String(\"bitbucket-dc-git-username\")\n\t\t_forge.AdditionalOptions[\"git-password\"] = c.String(\"bitbucket-dc-git-password\")\n\t\t_forge.AdditionalOptions[\"oauth-enable-project-admin-scope\"] = c.Bool(\"bitbucket-dc-oauth-enable-oauth2-scope-project-admin\")\n\tdefault:\n\t\treturn errors.New(\"forge not configured\")\n\t}\n\n\tif forgeExists {\n\t\terr := _store.ForgeUpdate(_forge)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\terr := _store.ForgeCreate(_forge)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "server/services/utils/hostmatcher/hostmatcher.go",
    "content": "// Copyright 2021 The Gitea Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\n// cSpell:words hostmatcher\npackage hostmatcher\n\nimport (\n\t\"net\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// HostMatchList is used to check if a host or IP is in a list.\ntype HostMatchList struct {\n\tSettingKeyHint string\n\tSettingValue   string\n\n\t// builtins networks\n\tbuiltins []string\n\t// patterns for host names (with wildcard support)\n\tpatterns []string\n\t// ipNets is the CIDR network list\n\tipNets []*net.IPNet\n}\n\n// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched.\nconst MatchBuiltinExternal = \"external\"\n\n// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.\nconst MatchBuiltinPrivate = \"private\"\n\n// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.\nconst MatchBuiltinLoopback = \"loopback\"\n\nfunc isBuiltin(s string) bool {\n\treturn s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback\n}\n\n// ParseHostMatchList parses the host list HostMatchList.\nfunc ParseHostMatchList(settingKeyHint, hostList string) *HostMatchList {\n\thl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList}\n\tfor _, s := range strings.Split(hostList, \",\") {\n\t\ts = strings.ToLower(strings.TrimSpace(s))\n\t\tif s == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t_, ipNet, err := net.ParseCIDR(s)\n\t\tswitch {\n\t\tcase err == nil:\n\t\t\thl.ipNets = append(hl.ipNets, ipNet)\n\t\tcase isBuiltin(s):\n\t\t\thl.builtins = append(hl.builtins, s)\n\t\tdefault:\n\t\t\thl.patterns = append(hl.patterns, s)\n\t\t}\n\t}\n\treturn hl\n}\n\n// ParseSimpleMatchList parse a simple match-list (no built-in networks, no CIDR support, only wildcard pattern match).\nfunc ParseSimpleMatchList(settingKeyHint, matchList string) *HostMatchList {\n\thl := &HostMatchList{\n\t\tSettingKeyHint: settingKeyHint,\n\t\tSettingValue:   matchList,\n\t}\n\tfor _, s := range strings.Split(matchList, \",\") {\n\t\ts = strings.ToLower(strings.TrimSpace(s))\n\t\tif s == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// we keep the same result as old `match-list`, so no builtin/CIDR support here, we only match wildcard patterns\n\t\thl.patterns = append(hl.patterns, s)\n\t}\n\treturn hl\n}\n\n// AppendBuiltin appends more builtins to match.\nfunc (hl *HostMatchList) AppendBuiltin(builtin string) {\n\thl.builtins = append(hl.builtins, builtin)\n}\n\n// AppendPattern appends more pattern to match.\nfunc (hl *HostMatchList) AppendPattern(pattern string) {\n\thl.patterns = append(hl.patterns, pattern)\n}\n\n// IsEmpty checks if the checklist is empty.\nfunc (hl *HostMatchList) IsEmpty() bool {\n\treturn hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0)\n}\n\nfunc (hl *HostMatchList) checkPattern(host string) bool {\n\thost = strings.ToLower(strings.TrimSpace(host))\n\tfor _, pattern := range hl.patterns {\n\t\tif matched, _ := filepath.Match(pattern, host); matched {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (hl *HostMatchList) checkIP(ip net.IP) bool {\n\tfor _, pattern := range hl.patterns {\n\t\tif pattern == \"*\" {\n\t\t\treturn true\n\t\t}\n\t}\n\tfor _, builtin := range hl.builtins {\n\t\tswitch builtin {\n\t\tcase MatchBuiltinExternal:\n\t\t\tif ip.IsGlobalUnicast() && !ip.IsPrivate() {\n\t\t\t\treturn true\n\t\t\t}\n\t\tcase MatchBuiltinPrivate:\n\t\t\tif ip.IsPrivate() {\n\t\t\t\treturn true\n\t\t\t}\n\t\tcase MatchBuiltinLoopback:\n\t\t\tif ip.IsLoopback() {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\tfor _, ipNet := range hl.ipNets {\n\t\tif ipNet.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// MatchHostName checks if the host matches an allow/deny(block) list.\nfunc (hl *HostMatchList) MatchHostName(host string) bool {\n\tif hl == nil {\n\t\treturn false\n\t}\n\n\thostname, _, err := net.SplitHostPort(host)\n\tif err != nil {\n\t\thostname = host\n\t}\n\tif hl.checkPattern(hostname) {\n\t\treturn true\n\t}\n\tif ip := net.ParseIP(hostname); ip != nil {\n\t\treturn hl.checkIP(ip)\n\t}\n\treturn false\n}\n\n// MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`.\nfunc (hl *HostMatchList) MatchIPAddr(ip net.IP) bool {\n\tif hl == nil {\n\t\treturn false\n\t}\n\thost := ip.String() // nil-safe, we will get \"<nil>\" if ip is nil\n\treturn hl.checkPattern(host) || hl.checkIP(ip)\n}\n\n// MatchHostOrIP checks if the host or IP matches an allow/deny(block) list.\nfunc (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool {\n\treturn hl.MatchHostName(host) || hl.MatchIPAddr(ip)\n}\n"
  },
  {
    "path": "server/services/utils/hostmatcher/hostmatcher_test.go",
    "content": "// Copyright 2021 The Gitea Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\npackage hostmatcher\n\nimport (\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestHostOrIPMatchesList(t *testing.T) {\n\ttype tc struct {\n\t\thost     string\n\t\tip       net.IP\n\t\texpected bool\n\t}\n\n\t// for IPv6: \"::1\" is loopback, \"fd00::/8\" is private\n\n\thl := ParseHostMatchList(\"\", \"private, External, *.myDomain.com, 169.254.1.0/24\")\n\n\ttest := func(cases []tc) {\n\t\tfor _, c := range cases {\n\t\t\tassert.Equalf(t, c.expected, hl.MatchHostOrIP(c.host, c.ip), \"case domain=%s, ip=%v, expected=%v\", c.host, c.ip, c.expected)\n\t\t}\n\t}\n\n\tcases := []tc{\n\t\t{\"\", net.IPv4zero, false},\n\t\t{\"\", net.IPv6zero, false},\n\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), false},\n\t\t{\"127.0.0.1\", nil, false},\n\t\t{\"\", net.ParseIP(\"::1\"), false},\n\n\t\t{\"\", net.ParseIP(\"10.0.1.1\"), true},\n\t\t{\"10.0.1.1\", nil, true},\n\t\t{\"10.0.1.1:8080\", nil, true},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), true},\n\t\t{\"192.168.1.1\", nil, true},\n\t\t{\"\", net.ParseIP(\"fd00::1\"), true},\n\t\t{\"fd00::1\", nil, true},\n\n\t\t{\"\", net.ParseIP(\"8.8.8.8\"), true},\n\t\t{\"\", net.ParseIP(\"1001::1\"), true},\n\n\t\t{\"mydomain.com\", net.IPv4zero, false},\n\t\t{\"sub.mydomain.com\", net.IPv4zero, true},\n\t\t{\"sub.mydomain.com:8080\", net.IPv4zero, true},\n\n\t\t{\"\", net.ParseIP(\"169.254.1.1\"), true},\n\t\t{\"169.254.1.1\", nil, true},\n\t\t{\"\", net.ParseIP(\"169.254.2.2\"), false},\n\t\t{\"169.254.2.2\", nil, false},\n\t}\n\ttest(cases)\n\n\thl = ParseHostMatchList(\"\", \"loopback\")\n\tcases = []tc{\n\t\t{\"\", net.IPv4zero, false},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), true},\n\t\t{\"\", net.ParseIP(\"10.0.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"8.8.8.8\"), false},\n\n\t\t{\"\", net.ParseIP(\"::1\"), true},\n\t\t{\"\", net.ParseIP(\"fd00::1\"), false},\n\t\t{\"\", net.ParseIP(\"1000::1\"), false},\n\n\t\t{\"mydomain.com\", net.IPv4zero, false},\n\t}\n\ttest(cases)\n\n\thl = ParseHostMatchList(\"\", \"private\")\n\tcases = []tc{\n\t\t{\"\", net.IPv4zero, false},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), false},\n\t\t{\"\", net.ParseIP(\"10.0.1.1\"), true},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), true},\n\t\t{\"\", net.ParseIP(\"8.8.8.8\"), false},\n\n\t\t{\"\", net.ParseIP(\"::1\"), false},\n\t\t{\"\", net.ParseIP(\"fd00::1\"), true},\n\t\t{\"\", net.ParseIP(\"1000::1\"), false},\n\n\t\t{\"mydomain.com\", net.IPv4zero, false},\n\t}\n\ttest(cases)\n\n\thl = ParseHostMatchList(\"\", \"external\")\n\tcases = []tc{\n\t\t{\"\", net.IPv4zero, false},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), false},\n\t\t{\"\", net.ParseIP(\"10.0.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"8.8.8.8\"), true},\n\n\t\t{\"\", net.ParseIP(\"::1\"), false},\n\t\t{\"\", net.ParseIP(\"fd00::1\"), false},\n\t\t{\"\", net.ParseIP(\"1000::1\"), true},\n\n\t\t{\"mydomain.com\", net.IPv4zero, false},\n\t}\n\ttest(cases)\n\n\thl = ParseHostMatchList(\"\", \"*\")\n\tcases = []tc{\n\t\t{\"\", net.IPv4zero, true},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), true},\n\t\t{\"\", net.ParseIP(\"10.0.1.1\"), true},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), true},\n\t\t{\"\", net.ParseIP(\"8.8.8.8\"), true},\n\n\t\t{\"\", net.ParseIP(\"::1\"), true},\n\t\t{\"\", net.ParseIP(\"fd00::1\"), true},\n\t\t{\"\", net.ParseIP(\"1000::1\"), true},\n\n\t\t{\"mydomain.com\", net.IPv4zero, true},\n\t}\n\ttest(cases)\n\n\t// built-in network names can be escaped (warping the first char with `[]`) to be used as a real host name\n\t// this mechanism is reversed for internal usage only (maybe for some rare cases), it's not supposed to be used by end users\n\t// a real user should never use loopback/private/external as their host names\n\thl = ParseHostMatchList(\"\", \"loopback, [p]rivate\")\n\tcases = []tc{\n\t\t{\"loopback\", nil, false},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), true},\n\t\t{\"private\", nil, true},\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), false},\n\t}\n\ttest(cases)\n\n\thl = ParseSimpleMatchList(\"\", \"loopback, *.domain.com\")\n\tcases = []tc{\n\t\t{\"loopback\", nil, true},\n\t\t{\"\", net.ParseIP(\"127.0.0.1\"), false},\n\t\t{\"sub.domain.com\", nil, true},\n\t\t{\"other.com\", nil, false},\n\t\t{\"\", net.ParseIP(\"1.1.1.1\"), false},\n\t}\n\ttest(cases)\n\n\thl = ParseSimpleMatchList(\"\", \"external\")\n\tcases = []tc{\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"1.1.1.1\"), false},\n\t\t{\"external\", nil, true},\n\t}\n\ttest(cases)\n\n\thl = ParseSimpleMatchList(\"\", \"\")\n\tcases = []tc{\n\t\t{\"\", net.ParseIP(\"192.168.1.1\"), false},\n\t\t{\"\", net.ParseIP(\"1.1.1.1\"), false},\n\t\t{\"external\", nil, false},\n\t}\n\ttest(cases)\n}\n"
  },
  {
    "path": "server/services/utils/hostmatcher/http.go",
    "content": "// Copyright 2021 The Gitea Authors. All rights reserved.\n// SPDX-License-Identifier: MIT.\n\n// cSpell:words hostmatcher\npackage hostmatcher\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"syscall\"\n\t\"time\"\n)\n\n// NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check.\nfunc NewDialContext(usage string, allowList *HostMatchList) func(ctx context.Context, network, addr string) (net.Conn, error) {\n\treturn NewDialContextWithProxy(usage, allowList, nil)\n}\n\nfunc NewDialContextWithProxy(usage string, allowList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t// How Go HTTP Client works with redirection:\n\t//   transport.RoundTrip URL=http://domain.com, Host=domain.com\n\t//   transport.DialContext addrOrHost=domain.com:80\n\t//   dialer.Control tcp4:11.22.33.44:80\n\t//   transport.RoundTrip URL=http://www.domain.com/, Host=(empty here, in the direction, HTTP client doesn't fill the Host field)\n\t//   transport.DialContext addrOrHost=domain.com:80\n\t//   dialer.Control tcp4:11.22.33.44:80\n\treturn func(ctx context.Context, network, addrOrHost string) (net.Conn, error) {\n\t\t// default values are from http.DefaultTransport\n\t\tconst dialTimeout = 30 * time.Second\n\t\tconst dialKeepAlive = 30 * time.Second\n\n\t\tdialer := net.Dialer{\n\t\t\tTimeout:   dialTimeout,\n\t\t\tKeepAlive: dialKeepAlive,\n\n\t\t\tControl: func(network, ipAddr string, _ syscall.RawConn) error {\n\t\t\t\thost, port, err := net.SplitHostPort(addrOrHost)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif proxy != nil {\n\t\t\t\t\t// Always allow the host of the proxy, but only on the specified port.\n\t\t\t\t\tif host == proxy.Hostname() && port == proxy.Port() {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here\n\t\t\t\ttcpAddr, err := net.ResolveTCPAddr(network, ipAddr)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"%s can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%w\", usage, host, network, ipAddr, err)\n\t\t\t\t}\n\n\t\t\t\t// if we have an allow-list, check the allow-list first\n\t\t\t\tif !allowList.IsEmpty() {\n\t\t\t\t\tif !allowList.MatchHostOrIP(host, tcpAddr.IP) {\n\t\t\t\t\t\treturn fmt.Errorf(\"%s can only call allowed HTTP servers (check your %s setting), deny '%s(%s)'\", usage, allowList.SettingKeyHint, host, ipAddr)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\t\treturn dialer.DialContext(ctx, network, addrOrHost)\n\t}\n}\n"
  },
  {
    "path": "server/services/utils/http.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto\"\n\t\"crypto/ed25519\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/yaronf/httpsign\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/httputil\"\n)\n\ntype Client struct {\n\t*httpsign.Client\n}\n\nfunc getHTTPClient(privateKey crypto.PrivateKey, allowedHostListValue string) (*httpsign.Client, error) {\n\ttimeout := 10 * time.Second //nolint:mnd\n\n\tif allowedHostListValue == \"\" {\n\t\tallowedHostListValue = hostmatcher.MatchBuiltinExternal\n\t}\n\tallowedHostMatcher := hostmatcher.ParseHostMatchList(\"WOODPECKER_EXTENSIONS_ALLOWED_HOSTS\", allowedHostListValue)\n\n\tpubKeyID := \"woodpecker-ci-extensions\"\n\n\ted25519Key, ok := privateKey.(ed25519.PrivateKey)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"invalid private key type\")\n\t}\n\n\tsigner, err := httpsign.NewEd25519Signer(ed25519Key,\n\t\thttpsign.NewSignConfig(),\n\t\thttpsign.Headers(\"@request-target\", \"content-digest\")) // The Content-Digest header will be auto-generated\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Create base transport with custom User-Agent\n\tbaseTransport := httputil.NewUserAgentRoundTripper(\n\t\t&http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: false},\n\t\t\tDialContext:     hostmatcher.NewDialContext(\"extensions\", allowedHostMatcher),\n\t\t},\n\t\t\"server-extensions\",\n\t)\n\n\tclient := http.Client{\n\t\tTimeout:   timeout,\n\t\tTransport: baseTransport,\n\t}\n\n\tconfig := httpsign.NewClientConfig().SetSignatureName(pubKeyID).SetSigner(signer)\n\n\treturn httpsign.NewClient(client, config), nil\n}\n\nfunc NewHTTPClient(privateKey crypto.PrivateKey, allowedHostList string) (*Client, error) {\n\tclient, err := getHTTPClient(privateKey, allowedHostList)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &Client{\n\t\tClient: client,\n\t}, nil\n}\n\n// Send makes an http request with retry logic.\nfunc (e *Client) Send(ctx context.Context, method, path string, in, out any) (int, error) {\n\t// Maximum number of retries\n\tconst maxRetries = 3\n\n\tlog.Debug().Msgf(\"HTTP request: %s %s, retries enabled (max: %d)\", method, path, maxRetries)\n\n\t// Prepare request body bytes for possible retries\n\tvar bodyBytes []byte\n\tif in != nil {\n\t\tbuf := new(bytes.Buffer)\n\t\tif err := json.NewEncoder(buf).Encode(in); err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tbodyBytes = buf.Bytes()\n\t}\n\n\t// Parse URI once\n\turi, err := url.Parse(path)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\t// Create backoff configuration\n\texponentialBackoff := backoff.NewExponentialBackOff()\n\n\t// Execute with backoff retry\n\treturn backoff.Retry(ctx, func() (int, error) {\n\t\t// Check if context is already canceled\n\t\tif ctx.Err() != nil {\n\t\t\treturn 0, ctx.Err()\n\t\t}\n\n\t\t// Create request body for this attempt\n\t\tvar body io.Reader\n\t\tif len(bodyBytes) > 0 {\n\t\t\tbody = bytes.NewReader(bodyBytes)\n\t\t}\n\n\t\t// Create new request for each attempt\n\t\treq, err := http.NewRequestWithContext(ctx, method, uri.String(), body)\n\t\tif err != nil {\n\t\t\treturn 0, httputil.EnhanceHTTPError(err, method, path)\n\t\t}\n\t\tif in != nil {\n\t\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\t}\n\n\t\t// Send request\n\t\tresp, err := e.Do(req)\n\t\tif err != nil {\n\t\t\t// Check if this is a retryable error\n\t\t\tif !isRetryableError(err) {\n\t\t\t\tlog.Error().Err(err).Msgf(\"HTTP request failed (not retryable): %s %s\", method, path)\n\t\t\t\treturn 0, backoff.Permanent(err)\n\t\t\t}\n\t\t\treturn 0, err\n\t\t}\n\n\t\tstatusCode := resp.StatusCode\n\t\t// Read body immediately to ensure proper resource cleanup for retries\n\t\trespBody, readErr := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif readErr != nil {\n\t\t\t// Check if this is a retryable error\n\t\t\tif !isRetryableError(readErr) {\n\t\t\t\tlog.Error().Err(readErr).Msgf(\"HTTP response read failed (not retryable): %s %s\", method, path)\n\t\t\t\treturn statusCode, backoff.Permanent(readErr)\n\t\t\t}\n\t\t\treturn statusCode, readErr\n\t\t}\n\n\t\t// Check if status code is retryable\n\t\tif isRetryableStatusCode(statusCode) {\n\t\t\treturn statusCode, fmt.Errorf(\"response: %d\", statusCode)\n\t\t}\n\n\t\t// If status code is client error (4xx), don't retry\n\t\tif statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {\n\t\t\tlog.Debug().Int(\"status\", statusCode).Msgf(\"HTTP request returned client error (not retryable): %s %s\", method, path)\n\t\t\treturn statusCode, backoff.Permanent(fmt.Errorf(\"response: %s\", string(respBody)))\n\t\t}\n\n\t\t// If status code is OK (2xx), parse and return response\n\t\tif statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {\n\t\t\tif out != nil {\n\t\t\t\terr = json.NewDecoder(bytes.NewReader(respBody)).Decode(out)\n\t\t\t\t// Check for EOF error during response body parsing\n\t\t\t\tif err != nil && (errors.Is(err, io.EOF) || strings.Contains(err.Error(), \"unexpected EOF\")) {\n\t\t\t\t\treturn statusCode, err\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error().Err(err).Msgf(\"HTTP response parsing failed (not retryable): %s %s\", method, path)\n\t\t\t\t\treturn statusCode, backoff.Permanent(err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tlog.Debug().Int(\"status\", statusCode).Msgf(\"HTTP request succeeded: %s %s\", method, path)\n\t\t\treturn statusCode, nil\n\t\t}\n\n\t\t// For any other status code, don't retry\n\t\tlog.Error().Int(\"status\", statusCode).Msgf(\"HTTP request returned unexpected status code (not retryable): %s %s\", method, path)\n\t\treturn statusCode, backoff.Permanent(fmt.Errorf(\"response: %s\", string(respBody)))\n\t}, backoff.WithBackOff(exponentialBackoff), backoff.WithMaxTries(maxRetries),\n\t\tbackoff.WithNotify(func(err error, delay time.Duration) {\n\t\t\t// Log retry attempts\n\t\t\tlog.Debug().Err(err).Msgf(\"HTTP request failed, retrying in %v: %s %s\", delay, method, path)\n\t\t}),\n\t)\n}\n\n// isRetryableError checks if an error is transient and suitable for retry.\nfunc isRetryableError(err error) bool {\n\t// Check for network-related errors\n\tvar netErr net.Error\n\tif errors.As(err, &netErr) {\n\t\t// Retry on timeout errors\n\t\tif netErr.Timeout() {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Check for specific error types\n\tswitch {\n\tcase errors.Is(err, net.ErrClosed),\n\t\terrors.Is(err, io.EOF),\n\t\terrors.Is(err, io.ErrUnexpectedEOF):\n\t\treturn true\n\t}\n\n\t// Check for error strings that indicate retryable conditions\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"connection refused\") ||\n\t\tstrings.Contains(errStr, \"connection reset by peer\") ||\n\t\tstrings.Contains(errStr, \"no such host\") ||\n\t\tstrings.Contains(errStr, \"TLS handshake timeout\")\n}\n\n// isRetryableStatusCode checks if an HTTP status code is suitable for retry.\nfunc isRetryableStatusCode(statusCode int) bool {\n\t// Retry on server errors (5xx)\n\treturn statusCode >= http.StatusInternalServerError && statusCode < http.StatusNetworkAuthenticationRequired\n}\n"
  },
  {
    "path": "server/services/utils/http_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils_test\n\nimport (\n\t\"bytes\"\n\t\"crypto/ed25519\"\n\t\"crypto/rand\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/yaronf/httpsign\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/services/utils\"\n)\n\nfunc TestSignClient(t *testing.T) {\n\tpubKeyID := \"woodpecker-ci-extensions\"\n\n\tpubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)\n\trequire.NoError(t, err)\n\n\tbody := []byte(\"{\\\"foo\\\":\\\"bar\\\"}\")\n\n\tverifyHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tverifier, err := httpsign.NewEd25519Verifier(pubEd25519Key,\n\t\t\thttpsign.NewVerifyConfig(),\n\t\t\thttpsign.Headers(\"@request-target\", \"content-digest\")) // The Content-Digest header will be auto-generated\n\t\tassert.NoError(t, err)\n\n\t\terr = httpsign.VerifyRequest(pubKeyID, *verifier, r)\n\t\tassert.NoError(t, err)\n\n\t\tw.WriteHeader(http.StatusOK)\n\t}\n\n\tserver := httptest.NewServer(http.HandlerFunc(verifyHandler))\n\n\treq, err := http.NewRequest(\"GET\", server.URL+\"/\", bytes.NewBuffer(body))\n\trequire.NoError(t, err)\n\n\treq.Header.Set(\"Date\", time.Now().Format(time.RFC3339))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient, err := utils.NewHTTPClient(privEd25519Key, \"loopback\")\n\trequire.NoError(t, err)\n\n\trr, err := client.Do(req)\n\tassert.NoError(t, err)\n\tdefer rr.Body.Close()\n\n\tassert.Equal(t, http.StatusOK, rr.StatusCode)\n}\n\nfunc TestRetry(t *testing.T) {\n\t_, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)\n\trequire.NoError(t, err)\n\n\tnumRetry := 0\n\tbody := []byte(\"{\\\"foo\\\":\\\"bar\\\"}\")\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tnumRetry++\n\t\tif numRetry >= 6 {\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t} else {\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t}\n\t}))\n\n\tclient, err := utils.NewHTTPClient(privEd25519Key, \"loopback\")\n\trequire.NoError(t, err)\n\n\t// first time: retry fails all the times\n\t_, err = client.Send(t.Context(), http.MethodGet, server.URL+\"/\", bytes.NewBuffer(body), nil)\n\tassert.Error(t, err)\n\tassert.Equal(t, 3, numRetry)\n\n\t// second time: retry succeeds after two failed times\n\trr, err := client.Send(t.Context(), http.MethodGet, server.URL+\"/\", bytes.NewBuffer(body), nil)\n\tassert.NoError(t, err)\n\n\tassert.Equal(t, http.StatusNoContent, rr)\n\tassert.Equal(t, 6, numRetry)\n}\n"
  },
  {
    "path": "server/store/common.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport \"time\"\n\ntype XORM struct {\n\tLog             bool\n\tShowSQL         bool\n\tMaxIdleConns    int\n\tMaxOpenConns    int\n\tConnMaxLifetime time.Duration\n}\n\n// Opts are options for a new database connection.\ntype Opts struct {\n\tDriver string\n\tConfig string\n\tXORM   XORM\n}\n"
  },
  {
    "path": "server/store/context.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"context\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nconst key = \"store\"\n\n// FromContext returns the Store associated with this context.\nfunc FromContext(c context.Context) Store {\n\tstore, _ := c.Value(key).(Store)\n\treturn store\n}\n\n// TryFromContext try to return the Store associated with this context.\nfunc TryFromContext(c context.Context) (Store, bool) {\n\tstore, ok := c.Value(key).(Store)\n\treturn store, ok\n}\n\n// ToContext adds the Store to this context.\nfunc ToContext(c *gin.Context, store Store) {\n\tc.Set(key, store)\n}\n\nfunc InjectToContext(ctx context.Context, store Store) context.Context {\n\treturn context.WithValue(ctx, key, store) //nolint:staticcheck\n}\n"
  },
  {
    "path": "server/store/datastore/agent.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar ErrNoTokenProvided = errors.New(\"please provide a token\")\n\nfunc (s storage) AgentList(p *model.ListOptions) (agents []*model.Agent, _ error) {\n\treturn agents, s.paginate(p).OrderBy(\"id\").Find(&agents)\n}\n\nfunc (s storage) AgentFind(id int64) (*model.Agent, error) {\n\tagent := new(model.Agent)\n\treturn agent, wrapGet(s.engine.ID(id).Get(agent))\n}\n\nfunc (s storage) AgentFindByToken(token string) (*model.Agent, error) {\n\t// Searching with an empty token would result in an empty where clause and therefore returning first item\n\tif token == \"\" {\n\t\treturn nil, ErrNoTokenProvided\n\t}\n\tagent := new(model.Agent)\n\treturn agent, wrapGet(s.engine.Where(\"token = ?\", token).Get(agent))\n}\n\nfunc (s storage) AgentCreate(agent *model.Agent) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(agent))\n}\n\nfunc (s storage) AgentUpdate(agent *model.Agent) error {\n\t_, err := s.engine.ID(agent.ID).AllCols().Update(agent)\n\treturn err\n}\n\nfunc (s storage) AgentDelete(agent *model.Agent) error {\n\treturn wrapDelete(s.engine.ID(agent.ID).Delete(new(model.Agent)))\n}\n\nfunc (s storage) AgentListForOrg(orgID int64, p *model.ListOptions) (agents []*model.Agent, _ error) {\n\treturn agents, s.paginate(p).Where(\"org_id = ?\", orgID).OrderBy(\"id\").Find(&agents)\n}\n"
  },
  {
    "path": "server/store/datastore/agent_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestAgentFindByToken(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Agent))\n\tdefer closer()\n\n\tagent := &model.Agent{\n\t\tID:    int64(1),\n\t\tName:  \"test\",\n\t\tToken: \"secret-token\",\n\t}\n\terr := store.AgentCreate(agent)\n\tassert.NoError(t, err)\n\n\t_agent, err := store.AgentFindByToken(agent.Token)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, _agent.ID)\n\n\t_agent, err = store.AgentFindByToken(\"\")\n\tassert.ErrorIs(t, err, ErrNoTokenProvided)\n\tassert.Nil(t, _agent)\n}\n\nfunc TestAgentFindByID(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Agent))\n\tdefer closer()\n\n\tagent := &model.Agent{\n\t\tID:    int64(1),\n\t\tName:  \"test\",\n\t\tToken: \"secret-token\",\n\t}\n\terr := store.AgentCreate(agent)\n\tassert.NoError(t, err)\n\n\t_agent, err := store.AgentFind(agent.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"secret-token\", _agent.Token)\n}\n\nfunc TestAgentList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Agent))\n\tdefer closer()\n\n\tagent1 := &model.Agent{\n\t\tID:   int64(1),\n\t\tName: \"test-1\",\n\t}\n\tagent2 := &model.Agent{\n\t\tID:   int64(2),\n\t\tName: \"test-2\",\n\t}\n\terr := store.AgentCreate(agent1)\n\tassert.NoError(t, err)\n\terr = store.AgentCreate(agent2)\n\tassert.NoError(t, err)\n\n\tagents, err := store.AgentList(&model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Equal(t, 2, len(agents))\n\n\tagents, err = store.AgentList(&model.ListOptions{Page: 1, PerPage: 1})\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(agents))\n}\n\nfunc TestAgentUpdate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Agent))\n\tdefer closer()\n\n\tagent := &model.Agent{\n\t\tID:    int64(1),\n\t\tName:  \"test\",\n\t\tToken: \"secret-token\",\n\t}\n\terr := store.AgentCreate(agent)\n\tassert.NoError(t, err)\n\n\tagent.Backend = \"local\"\n\tagent.Capacity = 2\n\tagent.Version = \"next-abcdef\"\n\terr = store.AgentUpdate(agent)\n\tassert.NoError(t, err)\n}\n\nfunc TestAgentListForOrg(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Agent))\n\tdefer closer()\n\n\tagent1 := &model.Agent{\n\t\tID:    int64(1),\n\t\tName:  \"test-1\",\n\t\tOrgID: int64(100),\n\t}\n\tagent2 := &model.Agent{\n\t\tID:    int64(2),\n\t\tName:  \"test-2\",\n\t\tOrgID: int64(100),\n\t}\n\tagent3 := &model.Agent{\n\t\tID:    int64(3),\n\t\tName:  \"test-3\",\n\t\tOrgID: int64(200),\n\t}\n\tassert.NoError(t, store.AgentCreate(agent1))\n\tassert.NoError(t, store.AgentCreate(agent2))\n\tassert.NoError(t, store.AgentCreate(agent3))\n\n\tagents, err := store.AgentListForOrg(100, &model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Equal(t, 2, len(agents))\n\tassert.Equal(t, \"test-1\", agents[0].Name)\n\tassert.Equal(t, \"test-2\", agents[1].Name)\n\n\tagents, err = store.AgentListForOrg(200, &model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(agents))\n\tassert.Equal(t, \"test-3\", agents[0].Name)\n\n\tagents, err = store.AgentListForOrg(100, &model.ListOptions{Page: 1, PerPage: 1})\n\tassert.NoError(t, err)\n\tassert.Equal(t, 1, len(agents))\n\tassert.Equal(t, \"test-1\", agents[0].Name)\n}\n"
  },
  {
    "path": "server/store/datastore/config.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"crypto/sha256\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (s storage) ConfigsForPipeline(pipelineID int64) ([]*model.Config, error) {\n\tconfigs := make([]*model.Config, 0, perPage)\n\treturn configs, s.engine.\n\t\tTable(\"configs\").\n\t\tJoin(\"LEFT\", \"pipeline_configs\", \"configs.id = pipeline_configs.config_id\").\n\t\tWhere(\"pipeline_configs.pipeline_id = ?\", pipelineID).\n\t\tFind(&configs)\n}\n\nfunc (s storage) configFindIdentical(sess *xorm.Session, repoID int64, hash, name string) (*model.Config, error) {\n\tconf := new(model.Config)\n\tif err := wrapGet(sess.Where(\n\t\tbuilder.Eq{\"repo_id\": repoID, \"hash\": hash, \"name\": name},\n\t).Get(conf)); err != nil {\n\t\treturn nil, err\n\t}\n\treturn conf, nil\n}\n\nfunc (s storage) ConfigPersist(conf *model.Config) (*model.Config, error) {\n\tconf.Hash = fmt.Sprintf(\"%x\", sha256.Sum256(conf.Data))\n\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn nil, err\n\t}\n\n\texistingConfig, err := s.configFindIdentical(sess, conf.RepoID, conf.Hash, conf.Name)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn nil, err\n\t}\n\tif existingConfig != nil {\n\t\treturn existingConfig, nil\n\t}\n\n\tif err := s.configCreate(sess, conf); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn conf, sess.Commit()\n}\n\nfunc (s storage) configCreate(sess *xorm.Session, config *model.Config) error {\n\t// should never happen but just in case\n\tif config.Name == \"\" {\n\t\treturn fmt.Errorf(\"insert config to store failed: 'Name' has to be set\")\n\t}\n\tif config.Hash == \"\" {\n\t\treturn fmt.Errorf(\"insert config to store failed: 'Hash' has to be set\")\n\t}\n\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(sess.Insert(config))\n}\n\nfunc (s storage) PipelineConfigCreate(config *model.PipelineConfig) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(config))\n}\n"
  },
  {
    "path": "server/store/datastore/config_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar (\n\tdata = []byte(\"pipeline: [ { image: golang, commands: [ go build, go test ] } ]\")\n\thash = \"8d8647c9aa90d893bfb79dddbe901f03e258588121e5202632f8ae5738590b26\"\n\tname = \"test\"\n)\n\nfunc TestConfig(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Config), new(model.PipelineConfig), new(model.Pipeline), new(model.Repo))\n\tdefer closer()\n\n\trepo := &model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo))\n\n\tconfig := &model.Config{\n\t\tRepoID: repo.ID,\n\t\tData:   data,\n\t\tHash:   hash,\n\t\tName:   name,\n\t}\n\n\t_, err := store.ConfigPersist(config)\n\tassert.NoError(t, err)\n\n\tpipeline := &model.Pipeline{\n\t\tRepoID: repo.ID,\n\t\tStatus: model.StatusRunning,\n\t\tCommit: \"85f8c029b902ed9400bc600bac301a0aadb144ac\",\n\t}\n\tassert.NoError(t, store.CreatePipeline(pipeline))\n\n\tassert.NoError(t, store.PipelineConfigCreate(\n\t\t&model.PipelineConfig{\n\t\t\tConfigID:   config.ID,\n\t\t\tPipelineID: pipeline.ID,\n\t\t},\n\t))\n\n\tfoundConfig, err := store.configFindIdentical(store.engine.NewSession(), repo.ID, hash, name)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, config, foundConfig)\n\n\tloaded, err := store.ConfigsForPipeline(pipeline.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, config.ID, loaded[0].ID)\n}\n\nfunc TestConfigPersist(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Config))\n\tdefer closer()\n\n\tconf1 := &model.Config{\n\t\tRepoID: 2,\n\t\tData:   data,\n\t\tHash:   hash,\n\t\tName:   name,\n\t}\n\tconf2 := &model.Config{\n\t\tRepoID: 2,\n\t\tData:   []byte(\"steps: [ { image: golang, commands: [ go generate ] } ]\"),\n\t\tName:   \"generate\",\n\t}\n\n\tconf1, err := store.ConfigPersist(conf1)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, hash, conf1.Hash)\n\tconf1secondInsert, err := store.ConfigPersist(conf1)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, conf1, conf1secondInsert)\n\tcount, err := store.engine.Count(new(model.Config))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, count)\n\n\tnewConf2, err := store.ConfigPersist(conf2)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"66f28f8d487a48aacf29d9feea13b0ab5dbb5025296b77a6addde93efcc4d82b\", newConf2.Hash)\n\tcount, err = store.engine.Count(new(model.Config))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 2, count)\n\n\t// test for https://github.com/woodpecker-ci/woodpecker/issues/3093\n\t_, err = store.ConfigPersist(&model.Config{\n\t\tRepoID: 2,\n\t\tData:   data,\n\t\tHash:   hash,\n\t\tName:   \"some other\",\n\t})\n\tassert.NoError(t, err)\n\tcount, err = store.engine.Count(new(model.Config))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 3, count)\n}\n"
  },
  {
    "path": "server/store/datastore/cron.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"xorm.io/builder\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (s storage) CronCreate(cron *model.Cron) error {\n\tif err := cron.Validate(); err != nil {\n\t\treturn err\n\t}\n\terr := wrapInsert(s.engine.Insert(cron))\n\tif errors.Is(err, types.ErrInsertDuplicateDetected) {\n\t\treturn fmt.Errorf(\"create cron failed, duplicate detected: %w\", err)\n\t}\n\treturn err\n}\n\nfunc (s storage) CronFind(repo *model.Repo, id int64) (*model.Cron, error) {\n\tcron := new(model.Cron)\n\treturn cron, wrapGet(s.engine.ID(id).Where(\"repo_id = ?\", repo.ID).Get(cron))\n}\n\nfunc (s storage) CronList(repo *model.Repo, p *model.ListOptions) ([]*model.Cron, error) {\n\tvar crons []*model.Cron\n\treturn crons, s.paginate(p).Where(\"repo_id = ?\", repo.ID).OrderBy(\"name\").Find(&crons)\n}\n\nfunc (s storage) CronUpdate(_ *model.Repo, cron *model.Cron) error {\n\t_, err := s.engine.ID(cron.ID).AllCols().Update(cron)\n\treturn err\n}\n\nfunc (s storage) CronDelete(repo *model.Repo, id int64) error {\n\treturn wrapDelete(s.engine.ID(id).Where(\"repo_id = ?\", repo.ID).Delete(new(model.Cron)))\n}\n\n// CronListNextExecute returns limited number of jobs with NextExec being less or equal to the provided unix timestamp.\nfunc (s storage) CronListNextExecute(nextExec, limit int64) ([]*model.Cron, error) {\n\tcrons := make([]*model.Cron, 0, limit)\n\treturn crons, s.engine.Join(\"INNER\", \"repos\", \"repos.id = crons.repo_id\").Where(builder.Lte{\"next_exec\": nextExec}).And(builder.Eq{\"repos.active\": true, \"enabled\": true}).Limit(int(limit)).Find(&crons)\n}\n\n// CronGetLock try to get a lock by updating NextExec.\nfunc (s storage) CronGetLock(cron *model.Cron, newNextExec int64) (bool, error) {\n\tcols, err := s.engine.ID(cron.ID).Where(builder.Eq{\"next_exec\": cron.NextExec}).\n\t\tCols(\"next_exec\").Update(&model.Cron{NextExec: newNextExec})\n\tgotLock := cols != 0\n\n\tif err == nil && gotLock {\n\t\tcron.NextExec = newNextExec\n\t}\n\n\treturn gotLock, err\n}\n"
  },
  {
    "path": "server/store/datastore/cron_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestCronCreate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Cron))\n\tdefer closer()\n\n\trepo := &model.Repo{ID: 1, Name: \"repo\"}\n\tcron1 := &model.Cron{RepoID: repo.ID, CreatorID: 1, Name: \"sync\", NextExec: 10000, Schedule: \"@every 1h\"}\n\tassert.NoError(t, store.CronCreate(cron1))\n\tassert.NotEqualValues(t, 0, cron1.ID)\n\n\t// cannot insert cron job with same repoID and title\n\tassert.ErrorIs(t, store.CronCreate(cron1), types.ErrInsertDuplicateDetected)\n\n\toldID := cron1.ID\n\tassert.NoError(t, store.CronDelete(repo, oldID))\n\tcron1.ID = 0\n\tassert.NoError(t, store.CronCreate(cron1))\n\tassert.NotEqual(t, oldID, cron1.ID)\n}\n\nfunc TestCronListNextExecute(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Cron), new(model.Repo))\n\tdefer closer()\n\n\trepo1 := &model.Repo{Name: \"aaaa\", Owner: \"a\", FullName: \"a/aaaa\", ForgeRemoteID: \"1\", IsActive: true}\n\trepo2 := &model.Repo{Name: \"bbbb\", Owner: \"a\", FullName: \"a/bbbb\", ForgeRemoteID: \"2\", IsActive: false}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\n\tjobs, err := store.CronListNextExecute(0, 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, jobs, 0)\n\n\tnow := time.Now().Unix()\n\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"some\", RepoID: repo1.ID, NextExec: now, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"aaaa\", RepoID: repo1.ID, NextExec: now, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"bbbb\", RepoID: repo1.ID, NextExec: now, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"none\", RepoID: repo1.ID, NextExec: now + 1000, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"test\", RepoID: repo1.ID, NextExec: now + 2000, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"disabled-repo\", RepoID: repo2.ID, NextExec: now, Enabled: true}))\n\tassert.NoError(t, store.CronCreate(&model.Cron{Schedule: \"@every 1h\", Name: \"disabled-cron\", RepoID: repo1.ID, NextExec: now, Enabled: false}))\n\n\tjobs, err = store.CronListNextExecute(now, 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, jobs, 3)\n\n\tjobs, err = store.CronListNextExecute(now+1500, 10)\n\tassert.NoError(t, err)\n\tassert.Len(t, jobs, 4)\n}\n\nfunc TestCronGetLock(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Cron))\n\tdefer closer()\n\n\tnonExistingJob := &model.Cron{ID: 1000, Name: \"locales\", NextExec: 10000}\n\tgotLock, err := store.CronGetLock(nonExistingJob, time.Now().Unix()+100)\n\tassert.NoError(t, err)\n\tassert.False(t, gotLock)\n\n\tcron1 := &model.Cron{RepoID: 1, Name: \"some-title\", NextExec: 10000, Schedule: \"@every 1h\"}\n\tassert.NoError(t, store.CronCreate(cron1))\n\n\toldJob := *cron1\n\tgotLock, err = store.CronGetLock(cron1, cron1.NextExec+1000)\n\tassert.NoError(t, err)\n\tassert.True(t, gotLock)\n\tassert.NotEqualValues(t, oldJob.NextExec, cron1.NextExec)\n\n\tgotLock, err = store.CronGetLock(&oldJob, oldJob.NextExec+1000)\n\tassert.NoError(t, err)\n\tassert.False(t, gotLock)\n\tassert.EqualValues(t, oldJob.NextExec, oldJob.NextExec)\n}\n"
  },
  {
    "path": "server/store/datastore/engine.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"context\"\n\n\t\"github.com/rs/zerolog\"\n\t\"xorm.io/xorm\"\n\txlog \"xorm.io/xorm/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/datastore/migration\"\n)\n\ntype storage struct {\n\tengine *xorm.Engine\n}\n\nconst perPage = 50\n\nfunc NewEngine(opts *store.Opts) (store.Store, error) {\n\tengine, err := xorm.NewEngine(opts.Driver, opts.Config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlevel := xlog.LogLevel(zerolog.GlobalLevel())\n\tif !opts.XORM.Log {\n\t\tlevel = xlog.LOG_OFF\n\t}\n\n\tlogger := newXORMLogger(level)\n\tengine.SetLogger(logger)\n\tengine.ShowSQL(opts.XORM.ShowSQL)\n\tengine.SetMaxOpenConns(opts.XORM.MaxOpenConns)\n\tengine.SetMaxIdleConns(opts.XORM.MaxIdleConns)\n\tengine.SetConnMaxLifetime(opts.XORM.ConnMaxLifetime)\n\n\treturn &storage{\n\t\tengine: engine,\n\t}, nil\n}\n\nfunc (s storage) Ping() error {\n\treturn s.engine.Ping()\n}\n\n// Migrate old storage or init new one.\nfunc (s storage) Migrate(ctx context.Context, allowLong bool) error {\n\treturn migration.Migrate(ctx, s.engine, allowLong)\n}\n\nfunc (s storage) Close() error {\n\treturn s.engine.Close()\n}\n"
  },
  {
    "path": "server/store/datastore/engine_test.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nfunc testDriverConfig() (driver, config string) {\n\tdriver = \"sqlite3\"\n\tconfig = \":memory:\"\n\n\tif os.Getenv(\"WOODPECKER_DATABASE_DRIVER\") != \"\" {\n\t\tdriver = os.Getenv(\"WOODPECKER_DATABASE_DRIVER\")\n\t\tconfig = os.Getenv(\"WOODPECKER_DATABASE_DATASOURCE\")\n\t}\n\treturn driver, config\n}\n\n// newTestStore creates a new database connection for testing purposes.\n// The database driver and connection string are provided by\n// environment variables, with fallback to in-memory sqlite.\nfunc newTestStore(t *testing.T, tables ...any) (store *storage, closer func()) {\n\tengine, err := xorm.NewEngine(testDriverConfig())\n\trequire.NoError(t, err)\n\n\t// MaxOpenConns=1 and MaxIdleConns=1 are required for in-memory sqlite:\n\t// without them the pool drops idle connections, destroying the in-memory\n\t// schema between calls and breaking migrations.\n\tengine.SetMaxOpenConns(1)\n\tengine.SetMaxIdleConns(1)\n\n\tfor _, table := range tables {\n\t\tif err := engine.Sync(table); err != nil {\n\t\t\tt.Error(err)\n\t\t\tt.FailNow()\n\t\t}\n\t}\n\n\treturn &storage{\n\t\t\tengine: engine,\n\t\t}, func() {\n\t\t\tfor _, bean := range tables {\n\t\t\t\tif err := engine.DropIndexes(bean); err != nil {\n\t\t\t\t\tt.Error(err)\n\t\t\t\t\tt.FailNow()\n\t\t\t\t}\n\t\t\t}\n\t\t\tif err := engine.DropTables(tables...); err != nil {\n\t\t\t\tt.Error(err)\n\t\t\t\tt.FailNow()\n\t\t\t}\n\t\t\tif err := engine.Close(); err != nil {\n\t\t\t\tt.Error(err)\n\t\t\t\tt.FailNow()\n\t\t\t}\n\n\t\t\tdbType := engine.Dialect().URI().DBType\n\t\t\tif dbType == schemas.MYSQL || dbType == schemas.POSTGRES {\n\t\t\t\t// wait for mysql/postgres to sync ...\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t}\n\t\t}\n}\n"
  },
  {
    "path": "server/store/datastore/errors.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\ntype ErrorRepoNotExist struct {\n\tRepoID int64\n}\n\nfunc (e ErrorRepoNotExist) Error() string {\n\treturn fmt.Sprintf(\"Repo with %d is not existing\", e.RepoID)\n}\n\nfunc (ErrorRepoNotExist) Unwrap() error {\n\treturn types.ErrRecordNotExist\n}\n"
  },
  {
    "path": "server/store/datastore/feed.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\n\t\"xorm.io/builder\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) getFeedSelect() string {\n\tconst feedTemplate = `repos.id as repo_id,\npipelines.id as pipeline_id,\npipelines.number as pipeline_number,\npipelines.event as pipeline_event,\npipelines.status as pipeline_status,\npipelines.created as pipeline_created,\npipelines.started as pipeline_started,\npipelines.finished as pipeline_finished,\npipelines.%s as pipeline_commit,\npipelines.branch as pipeline_branch,\npipelines.ref as pipeline_ref,\npipelines.refspec as pipeline_refspec,\npipelines.title as pipeline_title,\npipelines.message as pipeline_message,\npipelines.author as pipeline_author,\npipelines.email as pipeline_email,\npipelines.avatar as pipeline_avatar`\n\n\treturn fmt.Sprintf(feedTemplate, s.engine.Dialect().Quoter().Quote(\"commit\"))\n}\n\nfunc (s storage) GetPipelineQueue() ([]*model.Feed, error) {\n\tfeed := make([]*model.Feed, 0, perPage)\n\terr := s.engine.Table(\"pipelines\").\n\t\tSelect(s.getFeedSelect()).\n\t\tJoin(\"INNER\", \"repos\", \"pipelines.repo_id = repos.id\").\n\t\tIn(\"pipelines.status\", model.StatusPending, model.StatusRunning).\n\t\tFind(&feed)\n\treturn feed, err\n}\n\nfunc (s storage) UserFeed(user *model.User) ([]*model.Feed, error) {\n\tfeed := make([]*model.Feed, 0, perPage)\n\terr := s.engine.Table(\"repos\").\n\t\tSelect(s.getFeedSelect()).\n\t\tJoin(\"INNER\", \"perms\", \"repos.id = perms.repo_id\").\n\t\tJoin(\"INNER\", \"pipelines\", \"repos.id = pipelines.repo_id\").\n\t\tWhere(userPushOrAdminCondition(user.ID)).\n\t\tDesc(\"pipelines.id\").\n\t\tLimit(perPage).\n\t\tFind(&feed)\n\n\treturn feed, err\n}\n\nfunc (s storage) RepoListLatest(user *model.User) ([]*model.Feed, error) {\n\tfeed := make([]*model.Feed, 0, perPage)\n\n\terr := s.engine.Table(\"repos\").\n\t\tSelect(s.getFeedSelect()).\n\t\tJoin(\"INNER\", \"perms\", \"repos.id = perms.repo_id\").\n\t\tJoin(\"LEFT\", \"pipelines\", \"pipelines.id = \"+`(\n\t\t\tSELECT pipelines.id FROM pipelines\n\t\t\tWHERE pipelines.repo_id = repos.id\n\t\t\tORDER BY pipelines.id DESC\n\t\t\tLIMIT 1\n\t\t\t)`).\n\t\tWhere(userPushOrAdminCondition(user.ID)).\n\t\tAnd(builder.Eq{\"repos.active\": true}).\n\t\tAsc(\"repos.full_name\").\n\t\tFind(&feed)\n\n\treturn feed, err\n}\n"
  },
  {
    "path": "server/store/datastore/feed_test.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestGetPipelineQueue(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))\n\tdefer closer()\n\n\tuser := &model.User{\n\t\tLogin:       \"joe\",\n\t\tEmail:       \"foo@bar.com\",\n\t\tAccessToken: \"e42080dddf012c718e476da161d21ad5\",\n\t}\n\tassert.NoError(t, store.CreateUser(user))\n\n\trepo1 := &model.Repo{\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tForgeRemoteID: \"1\",\n\t\tIsActive:      true,\n\t}\n\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tfor _, perm := range []*model.Perm{\n\t\t{UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false},\n\t} {\n\t\tassert.NoError(t, store.PermUpsert(perm))\n\t}\n\tpipeline1 := &model.Pipeline{\n\t\tRepoID:  repo1.ID,\n\t\tStatus:  model.StatusPending,\n\t\tNumber:  1,\n\t\tEvent:   \"push\",\n\t\tCommit:  \"abc123\",\n\t\tBranch:  \"main\",\n\t\tRef:     \"refs/heads/main\",\n\t\tMessage: \"Initial commit\",\n\t\tAuthor:  \"joe\",\n\t\tEmail:   \"foo@bar.com\",\n\t\tTitle:   \"First pipeline\",\n\t}\n\tassert.NoError(t, store.CreatePipeline(pipeline1))\n\n\tfeed, err := store.GetPipelineQueue()\n\tassert.NoError(t, err)\n\tassert.Len(t, feed, 1)\n\n\tfeedItem := feed[0]\n\tassert.Equal(t, repo1.ID, feedItem.RepoID)\n\tassert.Equal(t, pipeline1.ID, feedItem.ID)\n\tassert.Equal(t, pipeline1.Number, feedItem.Number)\n\tassert.EqualValues(t, pipeline1.Event, feedItem.Event)\n\tassert.EqualValues(t, pipeline1.Status, feedItem.Status)\n\tassert.Equal(t, pipeline1.Commit, feedItem.Commit)\n\tassert.Equal(t, pipeline1.Branch, feedItem.Branch)\n\tassert.Equal(t, pipeline1.Ref, feedItem.Ref)\n\tassert.Equal(t, pipeline1.Title, feedItem.Title)\n\tassert.Equal(t, pipeline1.Message, feedItem.Message)\n\tassert.Equal(t, pipeline1.Author, feedItem.Author)\n\tassert.Equal(t, pipeline1.Email, feedItem.Email)\n}\n\nfunc TestUserFeed(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))\n\tdefer closer()\n\n\tuser := &model.User{\n\t\tLogin:       \"joe\",\n\t\tEmail:       \"foo@bar.com\",\n\t\tAccessToken: \"e42080dddf012c718e476da161d21ad5\",\n\t}\n\tassert.NoError(t, store.CreateUser(user))\n\n\trepo1 := &model.Repo{\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test1\",\n\t\tFullName:      \"bradrydzewski/test1\",\n\t\tForgeRemoteID: \"1\",\n\t\tIsActive:      true,\n\t}\n\trepo2 := &model.Repo{\n\t\tOwner:         \"johndoe\",\n\t\tName:          \"test\",\n\t\tFullName:      \"johndoe/test2\",\n\t\tForgeRemoteID: \"2\",\n\t\tIsActive:      true,\n\t}\n\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\n\tfor _, perm := range []*model.Perm{\n\t\t{UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false},\n\t} {\n\t\tassert.NoError(t, store.PermUpsert(perm))\n\t}\n\n\tpipeline1 := &model.Pipeline{\n\t\tRepoID: repo1.ID,\n\t\tStatus: model.StatusFailure,\n\t}\n\n\tassert.NoError(t, store.CreatePipeline(pipeline1))\n\tfeed, err := store.UserFeed(user)\n\tassert.NoError(t, err)\n\tassert.Len(t, feed, 1)\n}\n\nfunc TestRepoListLatest(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))\n\tdefer closer()\n\n\tuser := &model.User{\n\t\tLogin:       \"joe\",\n\t\tEmail:       \"foo@bar.com\",\n\t\tAccessToken: \"e42080dddf012c718e476da161d21ad5\",\n\t}\n\tassert.NoError(t, store.CreateUser(user))\n\n\trepo1 := &model.Repo{\n\t\tID:            1,\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tForgeRemoteID: \"1\",\n\t\tIsActive:      true,\n\t}\n\trepo2 := &model.Repo{\n\t\tID:            2,\n\t\tOwner:         \"test\",\n\t\tName:          \"test\",\n\t\tFullName:      \"test/test\",\n\t\tForgeRemoteID: \"2\",\n\t\tIsActive:      true,\n\t}\n\trepo3 := &model.Repo{\n\t\tID:            3,\n\t\tOwner:         \"octocat\",\n\t\tName:          \"hello-world\",\n\t\tFullName:      \"octocat/hello-world\",\n\t\tForgeRemoteID: \"3\",\n\t\tIsActive:      true,\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\tassert.NoError(t, store.CreateRepo(repo3))\n\n\tfor _, perm := range []*model.Perm{\n\t\t{UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false},\n\t\t{UserID: user.ID, RepoID: repo2.ID, Push: true, Admin: true},\n\t} {\n\t\tassert.NoError(t, store.PermUpsert(perm))\n\t}\n\n\tpipeline1 := &model.Pipeline{\n\t\tRepoID: repo1.ID,\n\t\tStatus: model.StatusFailure,\n\t}\n\tpipeline2 := &model.Pipeline{\n\t\tRepoID: repo1.ID,\n\t\tStatus: model.StatusRunning,\n\t}\n\tpipeline3 := &model.Pipeline{\n\t\tRepoID: repo2.ID,\n\t\tStatus: model.StatusKilled,\n\t}\n\tpipeline4 := &model.Pipeline{\n\t\tRepoID: repo3.ID,\n\t\tStatus: model.StatusError,\n\t}\n\tassert.NoError(t, store.CreatePipeline(pipeline1))\n\tassert.NoError(t, store.CreatePipeline(pipeline2))\n\tassert.NoError(t, store.CreatePipeline(pipeline3))\n\tassert.NoError(t, store.CreatePipeline(pipeline4))\n\n\tpipelines, err := store.RepoListLatest(user)\n\tassert.NoError(t, err)\n\tassert.Len(t, pipelines, 2)\n\tassert.EqualValues(t, model.StatusRunning, pipelines[0].Status)\n\tassert.Equal(t, repo1.ID, pipelines[0].RepoID)\n\tassert.EqualValues(t, model.StatusKilled, pipelines[1].Status)\n\tassert.Equal(t, repo2.ID, pipelines[1].RepoID)\n}\n"
  },
  {
    "path": "server/store/datastore/forge.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) ForgeGet(id int64) (*model.Forge, error) {\n\tforge := new(model.Forge)\n\treturn forge, wrapGet(s.engine.ID(id).Get(forge))\n}\n\nfunc (s storage) ForgeList(p *model.ListOptions) ([]*model.Forge, error) {\n\tforges := make([]*model.Forge, 0, 10)\n\treturn forges, s.paginate(p).Find(&forges)\n}\n\nfunc (s storage) ForgeCreate(forge *model.Forge) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(forge))\n}\n\nfunc (s storage) ForgeUpdate(forge *model.Forge) error {\n\t_, err := s.engine.ID(forge.ID).AllCols().Update(forge)\n\treturn err\n}\n\nfunc (s storage) ForgeDelete(forge *model.Forge) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tif _, err := sess.ID(forge.ID).Delete(new(model.Forge)); err != nil {\n\t\treturn err\n\t}\n\n\treturn sess.Commit()\n}\n"
  },
  {
    "path": "server/store/datastore/forge_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestForgeCRUD(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Forge), new(model.Repo), new(model.User))\n\tdefer closer()\n\n\tforge1 := &model.Forge{\n\t\tType:              \"github\",\n\t\tURL:               \"https://github.com\",\n\t\tOAuthClientID:     \"client\",\n\t\tOAuthClientSecret: \"secret\",\n\t\tSkipVerify:        false,\n\t\tAdditionalOptions: map[string]any{\n\t\t\t\"foo\": \"bar\",\n\t\t},\n\t}\n\n\t// create first forge to play with\n\tassert.NoError(t, store.ForgeCreate(forge1))\n\tassert.EqualValues(t, \"github\", forge1.Type)\n\n\t// retrieve it\n\tforgeOne, err := store.ForgeGet(forge1.ID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, forge1, forgeOne)\n\n\t// change type\n\tassert.NoError(t, store.ForgeUpdate(&model.Forge{ID: forge1.ID, Type: \"gitlab\"}))\n\n\t// find updated forge by id\n\tforgeOne, err = store.ForgeGet(forge1.ID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"gitlab\", forgeOne.Type)\n\n\t// create two more forges and repos\n\tsomeUser := &model.Forge{Type: \"bitbucket\"}\n\tassert.NoError(t, store.ForgeCreate(someUser))\n\tassert.NoError(t, store.ForgeCreate(&model.Forge{Type: \"gitea\"}))\n\n\t// get all repos for a specific forge\n\tforges, err := store.ForgeList(&model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Len(t, forges, 3)\n\n\t// delete an forge and check if it's gone\n\tassert.NoError(t, store.ForgeDelete(forge1))\n\t_, err = store.ForgeGet(forge1.ID)\n\tassert.Error(t, err)\n}\n"
  },
  {
    "path": "server/store/datastore/helper.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\n// wrapGet return error if err not nil or if requested entry do not exist.\nfunc wrapGet(exist bool, err error) error {\n\tif !exist {\n\t\treturn types.ErrRecordNotExist\n\t}\n\tif err != nil {\n\t\t// we only ask for the function's name if needed for performance reasons\n\t\tfnName := callerName(2)\n\t\treturn fmt.Errorf(\"%s: %w\", fnName, err)\n\t}\n\treturn nil\n}\n\n// wrapDelete return error if err not nil or if requested entry do not exist.\nfunc wrapDelete(c int64, err error) error {\n\tif c == 0 {\n\t\treturn types.ErrRecordNotExist\n\t}\n\tif err != nil {\n\t\t// we only ask for the function's name if needed for performance reasons\n\t\tfnName := callerName(2)\n\t\treturn fmt.Errorf(\"%s: %w\", fnName, err)\n\t}\n\treturn nil\n}\n\nfunc wrapInsert(c int64, err error) error {\n\tif err != nil {\n\t\tif errMsg := err.Error(); strings.HasPrefix(errMsg, \"UNIQUE constraint failed\") ||\n\t\t\tstrings.HasPrefix(errMsg, \"pq: duplicate key value violates unique constraint\") ||\n\t\t\tstrings.Contains(errMsg, \"Duplicate entry\") {\n\t\t\treturn types.ErrInsertDuplicateDetected\n\t\t}\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (s storage) paginate(p *model.ListOptions) *xorm.Session {\n\tif p == nil || p.All {\n\t\treturn s.engine.NewSession()\n\t}\n\tif p.PerPage < 1 {\n\t\tp.PerPage = 1\n\t}\n\tif p.Page < 1 {\n\t\tp.Page = 1\n\t}\n\treturn s.engine.Limit(p.PerPage, p.PerPage*(p.Page-1))\n}\n\nfunc callerName(skip int) string {\n\tpc, _, _, ok := runtime.Caller(skip)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tfnName := runtime.FuncForPC(pc).Name()\n\tpIndex := strings.LastIndex(fnName, \".\")\n\tif pIndex != -1 {\n\t\tfnName = fnName[pIndex+1:]\n\t}\n\treturn fnName\n}\n"
  },
  {
    "path": "server/store/datastore/helper_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestWrapGet(t *testing.T) {\n\terr := wrapGet(false, nil)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\n\terr = wrapGet(true, errors.New(\"test err\"))\n\tassert.Equal(t, \"TestWrapGet: test err\", err.Error())\n}\n\nfunc TestWrapDelete(t *testing.T) {\n\terr := wrapDelete(0, nil)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\n\terr = wrapDelete(1, errors.New(\"test err\"))\n\tassert.Equal(t, \"TestWrapDelete: test err\", err.Error())\n}\n\nfunc TestWrapInsert(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Cron))\n\tdefer closer()\n\n\t// test normal insert\n\tcron := &model.Cron{RepoID: 1, CreatorID: 1, Name: \"sync\", NextExec: 10000, Schedule: \"@every 1h\"}\n\tassert.NoError(t, wrapInsert(store.engine.Insert(cron)))\n\n\t// test insert witch should fail because of unique constraint\n\tassert.ErrorIs(t, wrapInsert(store.engine.Insert(cron)), types.ErrInsertDuplicateDetected)\n}\n"
  },
  {
    "path": "server/store/datastore/init.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !cgo\n\npackage datastore\n\nimport (\n\t_ \"github.com/go-sql-driver/mysql\"\n\t_ \"github.com/lib/pq\"\n)\n\n// Supported database drivers.\nconst (\n\tDriverMysql    = \"mysql\"\n\tDriverPostgres = \"postgres\"\n)\n\nfunc SupportedDriver(driver string) bool {\n\tswitch driver {\n\tcase DriverMysql, DriverPostgres:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "server/store/datastore/init_cgo.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build cgo\n\npackage datastore\n\nimport (\n\t// Blank imports to register the sql drivers.\n\t_ \"github.com/go-sql-driver/mysql\"\n\t_ \"github.com/lib/pq\"\n\t_ \"github.com/mattn/go-sqlite3\"\n)\n\n// Supported database drivers.\nconst (\n\tDriverSqlite   = \"sqlite3\"\n\tDriverMysql    = \"mysql\"\n\tDriverPostgres = \"postgres\"\n)\n\nfunc SupportedDriver(driver string) bool {\n\tswitch driver {\n\tcase DriverMysql, DriverPostgres, DriverSqlite:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "server/store/datastore/log.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// Maximum number of records to store in one PostgreSQL statement.\n// Too large a value results in `pq: got XX parameters but PostgreSQL only supports 65535 parameters`.\nconst pgBatchSize = 1000\n\nfunc (s storage) LogFind(step *model.Step) ([]*model.LogEntry, error) {\n\tvar logEntries []*model.LogEntry\n\treturn logEntries, s.engine.Asc(\"id\").Where(\"step_id = ?\", step.ID).Find(&logEntries)\n}\n\nfunc (s storage) LogAppend(_ *model.Step, logEntries []*model.LogEntry) error {\n\tvar errs error\n\n\t// TODO: adapted from slices.Chunk(); switch to it in Go 1.23+\n\tfor i := 0; i < len(logEntries); i += pgBatchSize {\n\t\tend := min(pgBatchSize, len(logEntries[i:]))\n\t\tchunk := logEntries[i : i+end]\n\n\t\tif err := wrapInsert(s.engine.Insert(chunk)); err != nil {\n\t\t\tlog.Error().Err(err).Msg(\"could not store log entries to db\")\n\t\t\terrs = errors.Join(errs, err)\n\t\t}\n\t}\n\n\treturn errs\n}\n\nfunc (s storage) LogDelete(step *model.Step) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\treturn logDelete(sess, step.ID)\n}\n\nfunc logDelete(sess *xorm.Session, stepID int64) error {\n\t_, err := sess.Where(\"step_id = ?\", stepID).Delete(new(model.LogEntry))\n\treturn err\n}\n\nfunc (s storage) StepFinished(_ *model.Step) {}\n"
  },
  {
    "path": "server/store/datastore/log_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestLogCreateFindDelete(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.LogEntry))\n\tdefer closer()\n\n\tstep := model.Step{\n\t\tID: 1,\n\t}\n\n\tlogEntries := []*model.LogEntry{\n\t\t{\n\t\t\tStepID: step.ID,\n\t\t\tData:   []byte(\"hello\"),\n\t\t\tLine:   1,\n\t\t\tTime:   0,\n\t\t},\n\t\t{\n\t\t\tStepID: step.ID,\n\t\t\tData:   []byte(\"world\"),\n\t\t\tLine:   2,\n\t\t\tTime:   10,\n\t\t},\n\t}\n\n\tassert.NoError(t, store.LogAppend(&step, logEntries))\n\n\t// we want to find our inserted logs\n\t_logEntries, err := store.LogFind(&step)\n\tassert.NoError(t, err)\n\tassert.Len(t, _logEntries, len(logEntries))\n\n\t// delete and check\n\tassert.NoError(t, store.LogDelete(&step))\n\t_logEntries, err = store.LogFind(&step)\n\tassert.NoError(t, err)\n\tassert.Len(t, _logEntries, 0)\n}\n\nfunc TestLogAppend(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.LogEntry))\n\tdefer closer()\n\n\tstep := model.Step{\n\t\tID: 1,\n\t}\n\tlogEntries := []*model.LogEntry{\n\t\t{\n\t\t\tStepID: step.ID,\n\t\t\tData:   []byte(\"hello\"),\n\t\t\tLine:   1,\n\t\t\tTime:   0,\n\t\t},\n\t\t{\n\t\t\tStepID: step.ID,\n\t\t\tData:   []byte(\"world\"),\n\t\t\tLine:   2,\n\t\t\tTime:   10,\n\t\t},\n\t}\n\n\tassert.NoError(t, store.LogAppend(&step, logEntries))\n\n\tlogEntry := &model.LogEntry{\n\t\tStepID: step.ID,\n\t\tData:   []byte(\"allo?\"),\n\t\tLine:   3,\n\t\tTime:   20,\n\t}\n\n\tassert.NoError(t, store.LogAppend(&step, []*model.LogEntry{logEntry}))\n\n\t_logEntries, err := store.LogFind(&step)\n\tassert.NoError(t, err)\n\tassert.Len(t, _logEntries, len(logEntries)+1)\n}\n"
  },
  {
    "path": "server/store/datastore/migration/000_legacy_to_xormigrate.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar legacyToXormigrate = xormigrate.Migration{\n\tID: \"legacy-to-xormigrate\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\ttype migrations struct {\n\t\t\tName string `xorm:\"UNIQUE\"`\n\t\t}\n\n\t\tvar mig []*migrations\n\t\tif err := sess.Find(&mig); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, m := range mig {\n\t\t\tif _, err := sess.Insert(&xormigrate.Migration{\n\t\t\t\tID: m.Name,\n\t\t\t}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn sess.DropTable(\"migrations\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/001_add_org_id.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar addOrgID = xormigrate.Migration{\n\tID: \"add-org-id\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\ttype users struct {\n\t\t\tID    int64  `xorm:\"pk autoincr 'user_id'\"`\n\t\t\tLogin string `xorm:\"UNIQUE 'user_login'\"`\n\t\t\tOrgID int64  `xorm:\"user_org_id\"`\n\t\t}\n\t\ttype orgs struct {\n\t\t\tID     int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tName   string `xorm:\"UNIQUE 'name'\"`\n\t\t\tIsUser bool   `xorm:\"is_user\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(users), new(orgs)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\t// get all users\n\t\tvar us []*users\n\t\tif err := sess.Find(&us); err != nil {\n\t\t\treturn fmt.Errorf(\"find all repos failed: %w\", err)\n\t\t}\n\n\t\tfor _, user := range us {\n\t\t\torg := &orgs{}\n\t\t\thas, err := sess.Where(\"name = ?\", user.Login).Get(org)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting org failed: %w\", err)\n\t\t\t} else if !has {\n\t\t\t\torg = &orgs{\n\t\t\t\t\tName:   user.Login,\n\t\t\t\t\tIsUser: true,\n\t\t\t\t}\n\t\t\t\tif _, err := sess.Insert(org); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"inserting org failed: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tuser.OrgID = org.ID\n\t\t\tif _, err := sess.Cols(\"user_org_id\").Update(user); err != nil {\n\t\t\t\treturn fmt.Errorf(\"updating user failed: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/002_task_data_type.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nvar alterTableTasksUpdateColumnTaskDataType = xormigrate.Migration{\n\tID: \"alter-table-tasks-update-type-of-task-data\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tdialect := sess.Engine().Dialect().URI().DBType\n\n\t\tswitch dialect {\n\t\tcase schemas.MYSQL:\n\t\t\t_, err = sess.Exec(\"ALTER TABLE tasks MODIFY COLUMN task_data LONGBLOB\")\n\t\tdefault:\n\t\t\t// xorm uses the same type for all blob sizes in sqlite and postgres\n\t\t\treturn nil\n\t\t}\n\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/003_config_data_type.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nvar alterTableConfigUpdateColumnConfigDataType = xormigrate.Migration{\n\tID: \"alter-table-config-update-type-of-config-data\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tdialect := sess.Engine().Dialect().URI().DBType\n\n\t\tswitch dialect {\n\t\tcase schemas.MYSQL:\n\t\t\t_, err = sess.Exec(\"ALTER TABLE config MODIFY COLUMN config_data LONGBLOB\")\n\t\tdefault:\n\t\t\t// xorm uses the same type for all blob sizes in sqlite and postgres\n\t\t\treturn nil\n\t\t}\n\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/004_remove_secrets_plugin_only_col.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar removePluginOnlyOptionFromSecretsTable = xormigrate.Migration{\n\tID: \"remove-plugin-only-option-from-secrets-table\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype secrets struct {\n\t\t\tID          int64    `json:\"id\"              xorm:\"pk autoincr 'secret_id'\"`\n\t\t\tPluginsOnly bool     `json:\"plugins_only\"    xorm:\"secret_plugins_only\"`\n\t\t\tSkipVerify  bool     `json:\"-\"               xorm:\"secret_skip_verify\"`\n\t\t\tConceal     bool     `json:\"-\"               xorm:\"secret_conceal\"`\n\t\t\tImages      []string `json:\"images\"          xorm:\"json 'secret_images'\"`\n\t\t}\n\n\t\t// make sure plugin_only column exists\n\t\tif err := sess.Sync(new(secrets)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"secrets\", \"secret_plugins_only\", \"secret_skip_verify\", \"secret_conceal\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/005_convert_to_new_pipeline_errors_format.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\n// perPage005 set the size of the slice to read per page.\nvar perPage005 = 100\n\nvar convertToNewPipelineErrorFormat = xormigrate.Migration{\n\tID:   \"convert-to-new-pipeline-error-format\",\n\tLong: true,\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype pipelineError struct {\n\t\t\tType      string `json:\"type\"`\n\t\t\tMessage   string `json:\"message\"`\n\t\t\tIsWarning bool   `json:\"is_warning\"`\n\t\t\tData      any    `json:\"data\"`\n\t\t}\n\n\t\ttype pipelines struct {\n\t\t\tID     int64            `json:\"id\"              xorm:\"pk autoincr 'pipeline_id'\"`\n\t\t\tError  string           `json:\"error\"           xorm:\"LONGTEXT 'pipeline_error'\"` // old error format\n\t\t\tErrors []*pipelineError `json:\"errors\"          xorm:\"json 'pipeline_errors'\"`    // new error format\n\t\t}\n\n\t\t// make sure pipeline_error column exists\n\t\tif err := sess.Sync(new(pipelines)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tpage := 0\n\t\toldPipelines := make([]*pipelines, 0, perPage005)\n\n\t\tfor {\n\t\t\toldPipelines = oldPipelines[:0]\n\n\t\t\terr := sess.Limit(perPage005, page*perPage005).Cols(\"pipeline_id\", \"pipeline_error\").Where(\"pipeline_error != ''\").Find(&oldPipelines)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tfor _, oldPipeline := range oldPipelines {\n\t\t\t\tvar newPipeline pipelines\n\t\t\t\tnewPipeline.ID = oldPipeline.ID\n\t\t\t\tnewPipeline.Errors = []*pipelineError{{\n\t\t\t\t\tType:    \"generic\",\n\t\t\t\t\tMessage: oldPipeline.Error,\n\t\t\t\t}}\n\n\t\t\t\tif _, err := sess.ID(oldPipeline.ID).Cols(\"pipeline_errors\").Update(newPipeline); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(oldPipelines) < perPage005 {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tpage++\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"pipelines\", \"pipeline_error\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/006_link_to_url.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar renameLinkToURL = xormigrate.Migration{\n\tID: \"rename-link-to-url\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_link\", \"pipeline_forge_url\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn renameColumn(sess, \"repos\", \"repo_link\", \"repo_forge_url\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/007_clean_registry_pipeline.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar cleanRegistryPipeline = xormigrate.Migration{\n\tID: \"clean-registry-pipeline\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype registry struct {\n\t\t\tID    int64  `json:\"id\"       xorm:\"pk autoincr 'registry_id'\"`\n\t\t\tToken string `json:\"token\"    xorm:\"TEXT 'registry_token'\"`\n\t\t\tEmail string `json:\"email\"    xorm:\"varchar(500) 'registry_email'\"`\n\t\t}\n\n\t\ttype pipelines struct {\n\t\t\tID       int64  `json:\"id\"                      xorm:\"pk autoincr 'pipeline_id'\"`\n\t\t\tConfigID int64  `json:\"-\"                       xorm:\"pipeline_config_id\"`\n\t\t\tEnqueued int64  `json:\"enqueued_at\"             xorm:\"pipeline_enqueued\"`\n\t\t\tCloneURL string `json:\"clone_url\"               xorm:\"pipeline_clone_url\"`\n\t\t}\n\n\t\t// ensure columns to drop exist\n\t\tif err := sess.Sync(new(registry), new(pipelines)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := dropTableColumns(sess, \"pipelines\", \"pipeline_clone_url\", \"pipeline_config_id\", \"pipeline_enqueued\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"registry\", \"registry_email\", \"registry_token\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/008_set_default_forge_id.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\ntype userV008 struct {\n\tID            int64               `xorm:\"pk autoincr 'user_id'\"`\n\tForgeID       int64               `xorm:\"forge_id\"`\n\tForgeRemoteID model.ForgeRemoteID `xorm:\"forge_remote_id\"`\n\tLogin         string              `xorm:\"UNIQUE 'user_login'\"`\n\tToken         string              `xorm:\"TEXT 'user_token'\"`\n\tSecret        string              `xorm:\"TEXT 'user_secret'\"`\n\tExpiry        int64               `xorm:\"user_expiry\"`\n\tEmail         string              `xorm:\" varchar(500) 'user_email'\"`\n\tAvatar        string              `xorm:\" varchar(500) 'user_avatar'\"`\n\tAdmin         bool                `xorm:\"user_admin\"`\n\tHash          string              `xorm:\"UNIQUE varchar(500) 'user_hash'\"`\n\tOrgID         int64               `xorm:\"user_org_id\"`\n}\n\nfunc (userV008) TableName() string {\n\treturn \"users\"\n}\n\ntype repoV008 struct {\n\tID                           int64                `xorm:\"pk autoincr 'repo_id'\"`\n\tUserID                       int64                `xorm:\"repo_user_id\"`\n\tForgeID                      int64                `xorm:\"forge_id\"`\n\tForgeRemoteID                model.ForgeRemoteID  `xorm:\"forge_remote_id\"`\n\tOrgID                        int64                `xorm:\"repo_org_id\"`\n\tOwner                        string               `xorm:\"UNIQUE(name) 'repo_owner'\"`\n\tName                         string               `xorm:\"UNIQUE(name) 'repo_name'\"`\n\tFullName                     string               `xorm:\"UNIQUE 'repo_full_name'\"`\n\tAvatar                       string               `xorm:\"varchar(500) 'repo_avatar'\"`\n\tForgeURL                     string               `xorm:\"varchar(1000) 'repo_forge_url'\"`\n\tClone                        string               `xorm:\"varchar(1000) 'repo_clone'\"`\n\tCloneSSH                     string               `xorm:\"varchar(1000) 'repo_clone_ssh'\"`\n\tBranch                       string               `xorm:\"varchar(500) 'repo_branch'\"`\n\tPREnabled                    bool                 `xorm:\"DEFAULT TRUE 'repo_pr_enabled'\"`\n\tTimeout                      int64                `xorm:\"repo_timeout\"`\n\tVisibility                   model.RepoVisibility `xorm:\"varchar(10) 'repo_visibility'\"`\n\tIsSCMPrivate                 bool                 `xorm:\"repo_private\"`\n\tIsTrusted                    bool                 `xorm:\"repo_trusted\"`\n\tIsGated                      bool                 `xorm:\"repo_gated\"`\n\tIsActive                     bool                 `xorm:\"repo_active\"`\n\tAllowPull                    bool                 `xorm:\"repo_allow_pr\"`\n\tAllowDeploy                  bool                 `xorm:\"repo_allow_deploy\"`\n\tConfig                       string               `xorm:\"varchar(500) 'repo_config_path'\"`\n\tHash                         string               `xorm:\"varchar(500) 'repo_hash'\"`\n\tPerm                         *model.Perm          `xorm:\"-\"`\n\tCancelPreviousPipelineEvents []model.WebhookEvent `xorm:\"json 'cancel_previous_pipeline_events'\"`\n\tNetrcOnlyTrusted             bool                 `xorm:\"NOT NULL DEFAULT true 'netrc_only_trusted'\"`\n}\n\nfunc (repoV008) TableName() string {\n\treturn \"repos\"\n}\n\ntype forge struct {\n\tID                int64           `xorm:\"pk autoincr 'id'\"`\n\tType              model.ForgeType `xorm:\"VARCHAR(250) 'type'\"`\n\tURL               string          `xorm:\"VARCHAR(500) 'url'\"`\n\tClient            string          `xorm:\"VARCHAR(250) 'client'\"`\n\tClientSecret      string          `xorm:\"VARCHAR(250) 'client_secret'\"`\n\tSkipVerify        bool            `xorm:\"bool 'skip_verify'\"`\n\tOAuthHost         string          `xorm:\"VARCHAR(250) 'oauth_host'\"` // public url for oauth if different from url\n\tAdditionalOptions map[string]any  `xorm:\"json 'additional_options'\"`\n}\n\nfunc (forge) TableName() string {\n\treturn \"forge\"\n}\n\nvar setForgeID = xormigrate.Migration{\n\tID: \"set-forge-id\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tif err := sess.Sync(new(userV008), new(repoV008), new(forge), new(model.Org)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\t_, err = sess.Exec(fmt.Sprintf(\"UPDATE `%s` SET forge_id=1;\", userV008{}.TableName()))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = sess.Exec(fmt.Sprintf(\"UPDATE `%s` SET forge_id=1;\", model.Org{}.TableName()))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = sess.Exec(fmt.Sprintf(\"UPDATE `%s` SET forge_id=1;\", repoV008{}.TableName()))\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/009_unify_columns_tables.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar unifyColumnsTables = xormigrate.Migration{\n\tID: \"unify-columns-tables\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype config struct {\n\t\t\tID     int64  `xorm:\"pk autoincr 'config_id'\"`\n\t\t\tRepoID int64  `xorm:\"UNIQUE(s) 'config_repo_id'\"`\n\t\t\tHash   string `xorm:\"UNIQUE(s) 'config_hash'\"`\n\t\t\tName   string `xorm:\"UNIQUE(s) 'config_name'\"`\n\t\t\tData   []byte `xorm:\"LONGBLOB 'config_data'\"`\n\t\t}\n\n\t\ttype crons struct {\n\t\t\tID        int64  `xorm:\"pk autoincr 'i_d'\"`\n\t\t\tName      string `xorm:\"name UNIQUE(s) INDEX\"`\n\t\t\tRepoID    int64  `xorm:\"repo_id UNIQUE(s) INDEX\"`\n\t\t\tCreatorID int64  `xorm:\"creator_id INDEX\"`\n\t\t\tNextExec  int64  `xorm:\"next_exec\"`\n\t\t\tSchedule  string `xorm:\"schedule NOT NULL\"`\n\t\t\tCreated   int64  `xorm:\"created NOT NULL DEFAULT 0\"`\n\t\t\tBranch    string `xorm:\"branch\"`\n\t\t}\n\n\t\ttype perms struct {\n\t\t\tUserID int64 `xorm:\"UNIQUE(s) INDEX NOT NULL 'perm_user_id'\"`\n\t\t\tRepoID int64 `xorm:\"UNIQUE(s) INDEX NOT NULL 'perm_repo_id'\"`\n\t\t\tPull   bool  `xorm:\"perm_pull\"`\n\t\t\tPush   bool  `xorm:\"perm_push\"`\n\t\t\tAdmin  bool  `xorm:\"perm_admin\"`\n\t\t\tSynced int64 `xorm:\"perm_synced\"`\n\t\t}\n\n\t\ttype pipelineError struct {\n\t\t\tType      string `json:\"type\"`\n\t\t\tMessage   string `json:\"message\"`\n\t\t\tIsWarning bool   `json:\"is_warning\"`\n\t\t\tData      any    `json:\"data\"`\n\t\t}\n\n\t\ttype pipelines struct {\n\t\t\tID         int64            `xorm:\"pk autoincr 'pipeline_id'\"`\n\t\t\tRepoID     int64            `xorm:\"UNIQUE(s) INDEX 'pipeline_repo_id'\"`\n\t\t\tNumber     int64            `xorm:\"UNIQUE(s) 'pipeline_number'\"`\n\t\t\tAuthor     string           `xorm:\"INDEX 'pipeline_author'\"`\n\t\t\tParent     int64            `xorm:\"pipeline_parent\"`\n\t\t\tEvent      string           `xorm:\"pipeline_event\"`\n\t\t\tStatus     string           `xorm:\"INDEX 'pipeline_status'\"`\n\t\t\tErrors     []*pipelineError `xorm:\"json 'pipeline_errors'\"`\n\t\t\tCreated    int64            `xorm:\"pipeline_created\"`\n\t\t\tStarted    int64            `xorm:\"pipeline_started\"`\n\t\t\tFinished   int64            `xorm:\"pipeline_finished\"`\n\t\t\tDeploy     string           `xorm:\"pipeline_deploy\"`\n\t\t\tDeployTask string           `xorm:\"pipeline_deploy_task\"`\n\t\t\tCommit     string           `xorm:\"pipeline_commit\"`\n\t\t\tBranch     string           `xorm:\"pipeline_branch\"`\n\t\t\tRef        string           `xorm:\"pipeline_ref\"`\n\t\t\tRefspec    string           `xorm:\"pipeline_refspec\"`\n\t\t\tTitle      string           `xorm:\"pipeline_title\"`\n\t\t\tMessage    string           `xorm:\"TEXT 'pipeline_message'\"`\n\t\t\tTimestamp  int64            `xorm:\"pipeline_timestamp\"`\n\t\t\tSender     string           `xorm:\"pipeline_sender\"` // uses reported user for webhooks and name of cron for cron pipelines\n\t\t\tAvatar     string           `xorm:\"pipeline_avatar\"`\n\t\t\tEmail      string           `xorm:\"pipeline_email\"`\n\t\t\tForgeURL   string           `xorm:\"pipeline_forge_url\"`\n\t\t\tReviewer   string           `xorm:\"pipeline_reviewer\"`\n\t\t\tReviewed   int64            `xorm:\"pipeline_reviewed\"`\n\t\t}\n\n\t\ttype redirections struct {\n\t\t\tID int64 `xorm:\"pk autoincr 'redirection_id'\"`\n\t\t}\n\n\t\ttype registry struct {\n\t\t\tID       int64  `xorm:\"pk autoincr 'registry_id'\"`\n\t\t\tRepoID   int64  `xorm:\"UNIQUE(s) INDEX 'registry_repo_id'\"`\n\t\t\tAddress  string `xorm:\"UNIQUE(s) INDEX 'registry_addr'\"`\n\t\t\tUsername string `xorm:\"varchar(2000) 'registry_username'\"`\n\t\t\tPassword string `xorm:\"TEXT 'registry_password'\"`\n\t\t}\n\n\t\ttype repos struct {\n\t\t\tID           int64  `xorm:\"pk autoincr 'repo_id'\"`\n\t\t\tUserID       int64  `xorm:\"repo_user_id\"`\n\t\t\tOrgID        int64  `xorm:\"repo_org_id\"`\n\t\t\tOwner        string `xorm:\"UNIQUE(name) 'repo_owner'\"`\n\t\t\tName         string `xorm:\"UNIQUE(name) 'repo_name'\"`\n\t\t\tFullName     string `xorm:\"UNIQUE 'repo_full_name'\"`\n\t\t\tAvatar       string `xorm:\"varchar(500) 'repo_avatar'\"`\n\t\t\tForgeURL     string `xorm:\"varchar(1000) 'repo_forge_url'\"`\n\t\t\tClone        string `xorm:\"varchar(1000) 'repo_clone'\"`\n\t\t\tCloneSSH     string `xorm:\"varchar(1000) 'repo_clone_ssh'\"`\n\t\t\tBranch       string `xorm:\"varchar(500) 'repo_branch'\"`\n\t\t\tSCMKind      string `xorm:\"varchar(50) 'repo_scm'\"`\n\t\t\tPREnabled    bool   `xorm:\"DEFAULT TRUE 'repo_pr_enabled'\"`\n\t\t\tTimeout      int64  `xorm:\"repo_timeout\"`\n\t\t\tVisibility   string `xorm:\"varchar(10) 'repo_visibility'\"`\n\t\t\tIsSCMPrivate bool   `xorm:\"repo_private\"`\n\t\t\tIsTrusted    bool   `xorm:\"repo_trusted\"`\n\t\t\tIsGated      bool   `xorm:\"repo_gated\"`\n\t\t\tIsActive     bool   `xorm:\"repo_active\"`\n\t\t\tAllowPull    bool   `xorm:\"repo_allow_pr\"`\n\t\t\tAllowDeploy  bool   `xorm:\"repo_allow_deploy\"`\n\t\t\tConfig       string `xorm:\"varchar(500) 'repo_config_path'\"`\n\t\t\tHash         string `xorm:\"varchar(500) 'repo_hash'\"`\n\t\t}\n\n\t\ttype secrets struct {\n\t\t\tID     int64    `xorm:\"pk autoincr 'secret_id'\"`\n\t\t\tOrgID  int64    `xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'\"`\n\t\t\tRepoID int64    `xorm:\"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'\"`\n\t\t\tName   string   `xorm:\"NOT NULL UNIQUE(s) INDEX 'secret_name'\"`\n\t\t\tValue  string   `xorm:\"TEXT 'secret_value'\"`\n\t\t\tImages []string `xorm:\"json 'secret_images'\"`\n\t\t\tEvents []string `xorm:\"json 'secret_events'\"`\n\t\t}\n\n\t\ttype steps struct {\n\t\t\tID         int64  `xorm:\"pk autoincr 'step_id'\"`\n\t\t\tUUID       string `xorm:\"INDEX 'step_uuid'\"`\n\t\t\tPipelineID int64  `xorm:\"UNIQUE(s) INDEX 'step_pipeline_id'\"`\n\t\t\tPID        int    `xorm:\"UNIQUE(s) 'step_pid'\"`\n\t\t\tPPID       int    `xorm:\"step_ppid\"`\n\t\t\tName       string `xorm:\"step_name\"`\n\t\t\tState      string `xorm:\"step_state\"`\n\t\t\tError      string `xorm:\"TEXT 'step_error'\"`\n\t\t\tFailure    string `xorm:\"step_failure\"`\n\t\t\tExitCode   int    `xorm:\"step_exit_code\"`\n\t\t\tStarted    int64  `xorm:\"step_started\"`\n\t\t\tStopped    int64  `xorm:\"step_stopped\"`\n\t\t\tType       string `xorm:\"step_type\"`\n\t\t}\n\n\t\ttype tasks struct {\n\t\t\tID           string            `xorm:\"PK UNIQUE 'task_id'\"`\n\t\t\tData         []byte            `xorm:\"LONGBLOB 'task_data'\"`\n\t\t\tLabels       map[string]string `xorm:\"json 'task_labels'\"`\n\t\t\tDependencies []string          `xorm:\"json 'task_dependencies'\"`\n\t\t\tRunOn        []string          `xorm:\"json 'task_run_on'\"`\n\t\t\tDepStatus    map[string]string `xorm:\"json 'task_dep_status'\"`\n\t\t}\n\n\t\ttype users struct {\n\t\t\tID     int64  `xorm:\"pk autoincr 'user_id'\"`\n\t\t\tLogin  string `xorm:\"UNIQUE 'user_login'\"`\n\t\t\tToken  string `xorm:\"TEXT 'user_token'\"`\n\t\t\tSecret string `xorm:\"TEXT 'user_secret'\"`\n\t\t\tExpiry int64  `xorm:\"user_expiry\"`\n\t\t\tEmail  string `xorm:\" varchar(500) 'user_email'\"`\n\t\t\tAvatar string `xorm:\" varchar(500) 'user_avatar'\"`\n\t\t\tAdmin  bool   `xorm:\"user_admin\"`\n\t\t\tHash   string `xorm:\"UNIQUE varchar(500) 'user_hash'\"`\n\t\t\tOrgID  int64  `xorm:\"user_org_id\"`\n\t\t}\n\n\t\ttype workflows struct {\n\t\t\tID         int64             `xorm:\"pk autoincr 'workflow_id'\"`\n\t\t\tPipelineID int64             `xorm:\"UNIQUE(s) INDEX 'workflow_pipeline_id'\"`\n\t\t\tPID        int               `xorm:\"UNIQUE(s) 'workflow_pid'\"`\n\t\t\tName       string            `xorm:\"workflow_name\"`\n\t\t\tState      string            `xorm:\"workflow_state\"`\n\t\t\tError      string            `xorm:\"TEXT 'workflow_error'\"`\n\t\t\tStarted    int64             `xorm:\"workflow_started\"`\n\t\t\tStopped    int64             `xorm:\"workflow_stopped\"`\n\t\t\tAgentID    int64             `xorm:\"workflow_agent_id\"`\n\t\t\tPlatform   string            `xorm:\"workflow_platform\"`\n\t\t\tEnviron    map[string]string `xorm:\"json 'workflow_environ'\"`\n\t\t\tAxisID     int               `xorm:\"workflow_axis_id\"`\n\t\t}\n\n\t\ttype serverConfig struct {\n\t\t\tKey   string `xorm:\"pk 'key'\"`\n\t\t\tValue string `xorm:\"value\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(config), new(crons), new(perms), new(pipelines), new(redirections), new(registry), new(repos), new(secrets), new(steps), new(tasks), new(users), new(workflows), new(serverConfig)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync models failed: %w\", err)\n\t\t}\n\n\t\t// Config\n\t\tif err := renameColumn(sess, \"config\", \"config_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"config\", \"config_repo_id\", \"repo_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"config\", \"config_hash\", \"hash\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"config\", \"config_name\", \"name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"config\", \"config_data\", \"data\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameTable(sess, \"config\", \"configs\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// PipelineConfig\n\t\tif err := renameTable(sess, \"pipeline_config\", \"pipeline_configs\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Cron\n\t\tif err := renameColumn(sess, \"crons\", \"i_d\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Forge\n\t\tif err := renameTable(sess, \"forge\", \"forges\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Perm\n\t\tif err := renameColumn(sess, \"perms\", \"perm_user_id\", \"user_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"perms\", \"perm_repo_id\", \"repo_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"perms\", \"perm_pull\", \"pull\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"perms\", \"perm_push\", \"push\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"perms\", \"perm_admin\", \"admin\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"perms\", \"perm_synced\", \"synced\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Pipeline\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_repo_id\", \"repo_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_number\", \"number\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_author\", \"author\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_parent\", \"parent\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_event\", \"event\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_status\", \"status\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_errors\", \"errors\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_created\", \"created\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_started\", \"started\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_finished\", \"finished\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_deploy\", \"deploy\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_deploy_task\", \"deploy_task\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_commit\", \"commit\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_branch\", \"branch\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_ref\", \"ref\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_refspec\", \"refspec\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_title\", \"title\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_message\", \"message\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_timestamp\", \"timestamp\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_sender\", \"sender\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_avatar\", \"avatar\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_email\", \"email\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_forge_url\", \"forge_url\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_reviewer\", \"reviewer\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"pipelines\", \"pipeline_reviewed\", \"reviewed\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Redirection\n\t\tif err := renameColumn(sess, \"redirections\", \"redirection_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Registry\n\t\tif err := renameColumn(sess, \"registry\", \"registry_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"registry\", \"registry_repo_id\", \"repo_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"registry\", \"registry_addr\", \"address\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"registry\", \"registry_username\", \"username\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"registry\", \"registry_password\", \"password\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameTable(sess, \"registry\", \"registries\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Repo\n\t\tif err := renameColumn(sess, \"repos\", \"repo_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_user_id\", \"user_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_org_id\", \"org_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_owner\", \"owner\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_name\", \"name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_full_name\", \"full_name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_avatar\", \"avatar\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_forge_url\", \"forge_url\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_clone\", \"clone\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_clone_ssh\", \"clone_ssh\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_branch\", \"branch\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_scm\", \"scm\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_pr_enabled\", \"pr_enabled\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_timeout\", \"timeout\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_visibility\", \"visibility\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_private\", \"private\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_trusted\", \"trusted\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_gated\", \"gated\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_active\", \"active\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_allow_pr\", \"allow_pr\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_allow_deploy\", \"allow_deploy\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_config_path\", \"config_path\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"repos\", \"repo_hash\", \"hash\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Secrets\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_org_id\", \"org_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_repo_id\", \"repo_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_name\", \"name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_value\", \"value\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_images\", \"images\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"secrets\", \"secret_events\", \"events\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// ServerConfig\n\t\tif err := renameTable(sess, \"server_config\", \"server_configs\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Step\n\t\tif err := renameColumn(sess, \"steps\", \"step_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_uuid\", \"uuid\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_pipeline_id\", \"pipeline_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_pid\", \"pid\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_ppid\", \"ppid\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_name\", \"name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_state\", \"state\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_error\", \"error\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_failure\", \"failure\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_exit_code\", \"exit_code\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_started\", \"started\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_stopped\", \"stopped\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"steps\", \"step_type\", \"type\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Task\n\t\tif err := renameColumn(sess, \"tasks\", \"task_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"tasks\", \"task_data\", \"data\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"tasks\", \"task_labels\", \"labels\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"tasks\", \"task_dependencies\", \"dependencies\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"tasks\", \"task_run_on\", \"run_on\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"tasks\", \"task_dep_status\", \"dependencies_status\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// User\n\t\tif err := renameColumn(sess, \"users\", \"user_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_login\", \"login\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_token\", \"token\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_secret\", \"secret\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_expiry\", \"expiry\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_email\", \"email\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_avatar\", \"avatar\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_admin\", \"admin\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_hash\", \"hash\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"users\", \"user_org_id\", \"org_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Workflow\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_id\", \"id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_pipeline_id\", \"pipeline_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_pid\", \"pid\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_name\", \"name\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_state\", \"state\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_error\", \"error\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_started\", \"started\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_stopped\", \"stopped\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_agent_id\", \"agent_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_platform\", \"platform\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_environ\", \"environ\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"workflows\", \"workflow_axis_id\", \"axis_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/010_registries_add_user.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar alterTableRegistriesFixRequiredFields = xormigrate.Migration{\n\tID: \"alter-table-registries-fix-required-fields\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\tif err := alterColumnDefault(sess, \"registries\", \"repo_id\", \"0\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := alterColumnNull(sess, \"registries\", \"repo_id\", false); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn alterColumnNull(sess, \"registries\", \"address\", false)\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/011_cron_without_sec.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar cronWithoutSec = xormigrate.Migration{\n\tID: \"cron-without-sec\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\tif err := sess.Sync(new(model.Cron)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\tvar crons []*model.Cron\n\t\tif err := sess.Find(&crons); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, c := range crons {\n\t\t\tif strings.HasPrefix(strings.TrimSpace(c.Schedule), \"@\") {\n\t\t\t\t// something like \"@daily\"\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif _, err := sess.Update(&model.Cron{\n\t\t\t\tSchedule: strings.SplitN(strings.TrimSpace(c.Schedule), \" \", 2)[1],\n\t\t\t}, c); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/012_rename_start_end_time.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar renameStartEndTime = xormigrate.Migration{\n\tID: \"rename-start-end-time\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype steps struct {\n\t\t\tFinished int64 `xorm:\"stopped\"`\n\t\t}\n\t\ttype workflows struct {\n\t\t\tFinished int64 `xorm:\"stopped\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(steps), new(workflows)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync models failed: %w\", err)\n\t\t}\n\n\t\t// Step\n\t\tif err := renameColumn(sess, \"steps\", \"stopped\", \"finished\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Workflow\n\t\tif err := renameColumn(sess, \"workflows\", \"stopped\", \"finished\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/013_fix_v31_registries.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar fixV31Registries = xormigrate.Migration{\n\tID: \"fix-v31-registries\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\thas, err := sess.IsTableExist(\"registry_v031\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif has {\n\t\t\treturn sess.DropTable(\"registry_v031\")\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/014_remove_old_migrations_of_v1.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar removeOldMigrationsOfV1 = xormigrate.Migration{\n\tID: \"remove-old-migrations-of-v1\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\t_, err = sess.Table(&xormigrate.Migration{}).In(\"id\", []string{\n\t\t\t\"xorm\",\n\t\t\t\"alter-table-drop-repo-fallback\",\n\t\t\t\"drop-allow-push-tags-deploys-columns\",\n\t\t\t\"fix-pr-secret-event-name\",\n\t\t\t\"alter-table-drop-counter\",\n\t\t\t\"drop-senders\",\n\t\t\t\"alter-table-logs-update-type-of-data\",\n\t\t\t\"alter-table-add-secrets-user-id\",\n\t\t\t\"lowercase-secret-names\",\n\t\t\t\"recreate-agents-table\",\n\t\t\t\"rename-builds-to-pipeline\",\n\t\t\t\"rename-columns-builds-to-pipeline\",\n\t\t\t\"rename-procs-to-steps\",\n\t\t\t\"rename-remote-to-forge\",\n\t\t\t\"rename-forge-id-to-forge-remote-id\",\n\t\t\t\"remove-active-from-users\",\n\t\t\t\"remove-inactive-repos\",\n\t\t\t\"drop-files\",\n\t\t\t\"remove-machine-col\",\n\t\t\t\"drop-old-col\",\n\t\t\t\"init-log_entries\",\n\t\t\t\"migrate-logs-to-log_entries\",\n\t\t\t\"parent-steps-to-workflows\",\n\t\t\t\"add-orgs\",\n\t\t}).Delete()\n\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/015_add_org_agents.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar addOrgAgents = xormigrate.Migration{\n\tID: \"add-org-agents\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype agents struct {\n\t\t\tID      int64 `xorm:\"pk autoincr 'id'\"`\n\t\t\tOwnerID int64 `xorm:\"INDEX 'owner_id'\"`\n\t\t\tOrgID   int64 `xorm:\"INDEX 'org_id'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(agents)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync models failed: %w\", err)\n\t\t}\n\n\t\t// Update all existing agents to be global agents\n\t\t_, err = sess.Cols(\"org_id\").Update(&agents{\n\t\t\tOrgID: model.IDNotSet,\n\t\t})\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/016_add_custom_labels_to_agent.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar addCustomLabelsToAgent = xormigrate.Migration{\n\tID: \"add-custom-labels-to-agent\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype agents struct {\n\t\t\tID           int64             `xorm:\"pk autoincr 'id'\"`\n\t\t\tCustomLabels map[string]string `xorm:\"JSON 'custom_labels'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(agents)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync models failed: %w\", err)\n\t\t}\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/017_split_trusted.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar splitTrusted = xormigrate.Migration{\n\tID: \"split-trusted\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\ttype repos struct {\n\t\t\tID        int64                      `xorm:\"pk autoincr 'id'\"`\n\t\t\tIsTrusted bool                       `xorm:\"'trusted'\"`\n\t\t\tTrusted   model.TrustedConfiguration `xorm:\"json 'trusted_conf'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(repos)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\tif _, err := sess.Where(\"trusted = ?\", false).Cols(\"trusted_conf\").Update(&repos{\n\t\t\tTrusted: model.TrustedConfiguration{\n\t\t\t\tNetwork:  false,\n\t\t\t\tSecurity: false,\n\t\t\t\tVolumes:  false,\n\t\t\t},\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif _, err := sess.Where(\"trusted = ?\", true).Cols(\"trusted_conf\").Update(&repos{\n\t\t\tTrusted: model.TrustedConfiguration{\n\t\t\t\tNetwork:  true,\n\t\t\t\tSecurity: true,\n\t\t\t\tVolumes:  true,\n\t\t\t},\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := dropTableColumns(sess, \"repos\", \"trusted\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := sess.Commit(); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn renameColumn(sess, \"repos\", \"trusted_conf\", \"trusted\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/018_fix_orgs_users_match.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nvar correctPotentialCorruptOrgsUsersRelation = xormigrate.Migration{\n\tID: \"correct-potential-corrupt-orgs-users-relation\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\ttype users struct {\n\t\t\tID      int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tForgeID int64  `xorm:\"forge_id\"`\n\t\t\tLogin   string `xorm:\"UNIQUE 'login'\"`\n\t\t\tOrgID   int64  `xorm:\"org_id\"`\n\t\t}\n\n\t\ttype orgs struct {\n\t\t\tID      int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tForgeID int64  `xorm:\"forge_id\"`\n\t\t\tName    string `xorm:\"UNIQUE 'name'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(users), new(orgs)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\tdialect := sess.Engine().Dialect().URI().DBType\n\t\tvar err error\n\t\tswitch dialect {\n\t\tcase schemas.MYSQL:\n\t\t\t_, err = sess.Exec(`UPDATE users u JOIN orgs o ON o.name = u.login AND o.forge_id = u.forge_id SET u.org_id = o.id;`)\n\t\tcase schemas.POSTGRES:\n\t\t\t_, err = sess.Exec(`UPDATE users u SET org_id = o.id FROM orgs o WHERE o.name = u.login AND o.forge_id = u.forge_id;`)\n\t\tcase schemas.SQLITE:\n\t\t\t_, err = sess.Exec(`UPDATE users SET org_id = ( SELECT orgs.id FROM orgs WHERE orgs.name = users.login AND orgs.forge_id = users.forge_id ) WHERE users.login IN (SELECT orgs.name FROM orgs);`)\n\t\tdefault:\n\t\t\terr = fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t\t}\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/019_gated_to_require_approval.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n)\n\nvar gatedToRequireApproval = xormigrate.Migration{\n\tID: \"gated-to-require-approval\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tconst (\n\t\t\trequireApprovalOldNotGated string = \"old_not_gated\"\n\t\t\trequireApprovalAllEvents   string = \"all_events\"\n\t\t)\n\n\t\ttype repos struct {\n\t\t\tID              int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tIsGated         bool   `xorm:\"gated\"`\n\t\t\tRequireApproval string `xorm:\"require_approval\"`\n\t\t\tVisibility      string `xorm:\"varchar(10) 'visibility'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(repos)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\t// migrate gated repos\n\t\tif _, err := sess.Exec(\n\t\t\tbuilder.Update(builder.Eq{\"require_approval\": requireApprovalAllEvents}).\n\t\t\t\tFrom(\"repos\").\n\t\t\t\tWhere(builder.Eq{\"gated\": true})); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// migrate non gated repos to old_not_gated (no approval required)\n\t\tif _, err := sess.Exec(\n\t\t\tbuilder.Update(builder.Eq{\"require_approval\": requireApprovalOldNotGated}).\n\t\t\t\tFrom(\"repos\").\n\t\t\t\tWhere(builder.Eq{\"gated\": false})); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"repos\", \"gated\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/020_remove_repo_netrc_only_trusted.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar removeRepoNetrcOnlyTrusted = xormigrate.Migration{\n\tID: \"remove-repo-netrc-only-trusted\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype repos struct {\n\t\t\tNetrcOnlyTrusted string `xorm:\"netrc_only_trusted\"`\n\t\t}\n\n\t\t// ensure columns to drop exist\n\t\tif err := sess.Sync(new(repos)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"repos\", \"netrc_only_trusted\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/021_rename_token_fields.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar renameTokenFields = xormigrate.Migration{\n\tID: \"rename-token-fields\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype users struct {\n\t\t\tAccessToken  string `xorm:\"TEXT 'token'\"`\n\t\t\tRefreshToken string `xorm:\"TEXT 'secret'\"`\n\t\t}\n\n\t\t// ensure columns to rename exist\n\t\tif err := sess.Sync(new(users)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif err := renameColumn(sess, \"users\", \"token\", \"access_token\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn renameColumn(sess, \"users\", \"secret\", \"refresh_token\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/022_set_new_defaults_for_require_approval.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n)\n\nvar setNewDefaultsForRequireApproval = xormigrate.Migration{\n\tID: \"set-new-defaults-for-require-approval\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\tconst (\n\t\t\tRequireApprovalOldNotGated string = \"old_not_gated\"\n\t\t\tRequireApprovalNone        string = \"none\"\n\t\t\tRequireApprovalForks       string = \"forks\"\n\t\t\tRequireApprovalAllEvents   string = \"all_events\"\n\t\t)\n\n\t\ttype repos struct {\n\t\t\tRequireApproval string `xorm:\"require_approval\"`\n\t\t\tVisibility      string `xorm:\"varchar(10) 'visibility'\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(repos)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\t// migrate public repos to require approval for forks\n\t\tif _, err := sess.Exec(\n\t\t\tbuilder.Update(builder.Eq{\"require_approval\": RequireApprovalForks}).\n\t\t\t\tFrom(\"repos\").\n\t\t\t\tWhere(builder.Eq{\"require_approval\": RequireApprovalOldNotGated, \"visibility\": \"public\"})); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// migrate private repos to require no approval\n\t\tif _, err := sess.Exec(\n\t\t\tbuilder.Update(builder.Eq{\"require_approval\": RequireApprovalNone}).\n\t\t\t\tFrom(\"repos\").\n\t\t\t\tWhere(builder.Eq{\"require_approval\": RequireApprovalOldNotGated}.And(builder.Neq{\"visibility\": \"public\"}))); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/023_remove_repo_scm.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar removeRepoScm = xormigrate.Migration{\n\tID: \"remove-repo-scm\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype repos struct {\n\t\t\tSCMKind string `xorm:\"varchar(50) 'scm'\"`\n\t\t}\n\n\t\t// ensure columns to drop exist\n\t\tif err := sess.Sync(new(repos)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn dropTableColumns(sess, \"repos\", \"scm\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/024_unsanitize_org_and_user_names.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n)\n\nvar unsanitizeOrgAndUserNames = xormigrate.Migration{\n\tID: \"unsanitize-org-and-user-names\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype user struct {\n\t\t\tID      int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tLogin   string `xorm:\"TEXT 'login'\"`\n\t\t\tForgeID int64  `xorm:\"forge_id\"`\n\t\t}\n\n\t\ttype org struct {\n\t\t\tID      int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tName    string `xorm:\"TEXT 'name'\"`\n\t\t\tForgeID int64  `xorm:\"forge_id\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(user), new(org)); err != nil {\n\t\t\treturn fmt.Errorf(\"sync new models failed: %w\", err)\n\t\t}\n\n\t\t// get all users\n\t\tvar users []*user\n\t\tif err := sess.Find(&users); err != nil {\n\t\t\treturn fmt.Errorf(\"find all repos failed: %w\", err)\n\t\t}\n\n\t\tfor _, user := range users {\n\t\t\tuserOrg := &org{}\n\t\t\t_, err := sess.Where(\"name = ? AND forge_id = ?\", user.Login, user.ForgeID).Get(userOrg)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting org failed: %w\", err)\n\t\t\t}\n\n\t\t\tif user.Login != userOrg.Name {\n\t\t\t\tuserOrg.Name = user.Login\n\t\t\t\tif _, err := sess.Where(builder.Eq{\"id\": userOrg.ID}).Cols(\"Name\").Update(userOrg); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"updating org name failed: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/025_fix_zero_forge_id_ref.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar replaceZeroForgeIDsInOrgs = xormigrate.Migration{\n\tID: \"replace-zero-forge-ids-in-orgs\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\t_, err = sess.Exec(\"UPDATE orgs SET forge_id=1 WHERE forge_id=0;\")\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/026_fix_forge_columns.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n)\n\nvar fixForgeColumns = xormigrate.Migration{\n\tID: \"fix-forge-columns\",\n\tMigrateSession: func(sess *xorm.Session) (err error) {\n\t\ttype forges struct {\n\t\t\tID                int64  `xorm:\"pk autoincr 'id'\"`\n\t\t\tClient            string `xorm:\"VARCHAR(250) 'client'\"`\n\t\t\tClientSecret      string `xorm:\"VARCHAR(250) 'client_secret'\"`\n\t\t\tOAuthClientID     string `xorm:\"VARCHAR(250) 'o_auth_client_i_d'\"`\n\t\t\tOAuthClientSecret string `xorm:\"VARCHAR(250) 'o_auth_client_secret'\"`\n\t\t}\n\n\t\t// Ensure columns to rename exist\n\t\tif err := sess.Sync(new(forges)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Rename old columns to new names\n\t\tif err := renameColumn(sess, \"forges\", \"o_auth_client_i_d\", \"oauth_client_id\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := renameColumn(sess, \"forges\", \"o_auth_client_secret\", \"oauth_client_secret\"); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Drop client and client_secret columns if they still exist\n\t\treturn dropTableColumns(sess, \"forges\", \"client\", \"client_secret\")\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/027_add_cron_field.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nvar addCronField = xormigrate.Migration{\n\tID: \"add-cron-field\",\n\tMigrateSession: func(sess *xorm.Session) error {\n\t\ttype pipelines struct {\n\t\t\tID int64 `xorm:\"pk autoincr 'id'\"`\n\n\t\t\t// new cron field\n\t\t\tCron string `xorm:\"cron\"`\n\t\t}\n\n\t\tif err := sess.Sync(new(pipelines)); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err := sess.Exec(\"UPDATE pipelines SET cron = sender, sender = '', message = '' WHERE event = ?\", model.EventCron)\n\t\treturn err\n\t},\n}\n"
  },
  {
    "path": "server/store/datastore/migration/common.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nfunc renameTable(sess *xorm.Session, oldTable, newTable string) error {\n\tdialect := sess.Engine().Dialect().URI().DBType\n\tswitch dialect {\n\tcase schemas.MYSQL:\n\t\t_, err := sess.Exec(fmt.Sprintf(\"RENAME TABLE `%s` TO `%s`;\", oldTable, newTable))\n\t\treturn err\n\tcase schemas.POSTGRES, schemas.SQLITE:\n\t\t_, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` RENAME TO `%s`;\", oldTable, newTable))\n\t\treturn err\n\tdefault:\n\t\treturn fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t}\n}\n\n// WARNING: YOU MUST COMMIT THE SESSION AT THE END.\nfunc dropTableColumns(sess *xorm.Session, tableName string, columnNames ...string) (err error) {\n\t// Copyright 2017 The Gitea Authors. All rights reserved.\n\t// Use of this source code is governed by a MIT-style\n\t// license that can be found in the LICENSE file.\n\n\tif tableName == \"\" || len(columnNames) == 0 {\n\t\treturn nil\n\t}\n\t// TODO: This will not work if there are foreign keys\n\n\tdialect := sess.Engine().Dialect().URI().DBType\n\tswitch dialect {\n\tcase schemas.SQLITE:\n\t\t// First drop the indexes on the columns\n\t\tres, errIndex := sess.Query(fmt.Sprintf(\"PRAGMA index_list(`%s`)\", tableName))\n\t\tif errIndex != nil {\n\t\t\treturn errIndex\n\t\t}\n\t\tfor _, row := range res {\n\t\t\tindexName := row[\"name\"]\n\t\t\tindexRes, err := sess.Query(fmt.Sprintf(\"PRAGMA index_info(`%s`)\", indexName))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif len(indexRes) != 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tindexColumn := string(indexRes[0][\"name\"])\n\t\t\tfor _, name := range columnNames {\n\t\t\t\tif name == indexColumn {\n\t\t\t\t\t_, err := sess.Exec(fmt.Sprintf(\"DROP INDEX `%s`\", indexName))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Now drop the columns\n\t\tfor _, columnName := range columnNames {\n\t\t\t_, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` DROP COLUMN `%s`;\", tableName, columnName))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"table `%s`, drop column %v: %w\", tableName, columnName, err)\n\t\t\t}\n\t\t}\n\n\tcase schemas.POSTGRES:\n\t\tcols := \"\"\n\t\tfor _, col := range columnNames {\n\t\t\tif cols != \"\" {\n\t\t\t\tcols += \", \"\n\t\t\t}\n\t\t\tcols += \"DROP COLUMN `\" + col + \"` CASCADE\"\n\t\t}\n\t\tif _, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` %s\", tableName, cols)); err != nil {\n\t\t\treturn fmt.Errorf(\"table `%s`, drop columns %v: %w\", tableName, columnNames, err)\n\t\t}\n\tcase schemas.MYSQL:\n\t\t// Drop indexes on columns first\n\t\tsql := fmt.Sprintf(\"SHOW INDEX FROM %s WHERE column_name IN ('%s')\", tableName, strings.Join(columnNames, \"','\"))\n\t\tres, err := sess.Query(sql)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, index := range res {\n\t\t\tindexName := index[\"column_name\"]\n\t\t\tif len(indexName) > 0 {\n\t\t\t\t_, err := sess.Exec(fmt.Sprintf(\"DROP INDEX `%s` ON `%s`\", indexName, tableName))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Now drop the columns\n\t\tcols := \"\"\n\t\tfor _, col := range columnNames {\n\t\t\tif cols != \"\" {\n\t\t\t\tcols += \", \"\n\t\t\t}\n\t\t\tcols += \"DROP COLUMN `\" + col + \"`\"\n\t\t}\n\t\tif _, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` %s\", tableName, cols)); err != nil {\n\t\t\treturn fmt.Errorf(\"table `%s`, drop columns %v: %w\", tableName, columnNames, err)\n\t\t}\n\tdefault:\n\t\treturn fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t}\n\n\treturn nil\n}\n\nfunc alterColumnDefault(sess *xorm.Session, table, column, defValue string) error {\n\tdialect := sess.Engine().Dialect().URI().DBType\n\tswitch dialect {\n\tcase schemas.MYSQL:\n\t\tsql := fmt.Sprintf(\"SHOW COLUMNS FROM `%s` WHERE lower(field) = '%s'\", table, strings.ToLower(column))\n\t\tres, err := sess.Query(sql)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(res) == 0 || len(res[0][\"Type\"]) == 0 {\n\t\t\treturn fmt.Errorf(\"column %s data type in table %s can not be detected\", column, table)\n\t\t}\n\n\t\tdataType := string(res[0][\"Type\"])\n\t\tvar nullable string\n\t\tif string(res[0][\"Null\"]) == \"NO\" {\n\t\t\tnullable = \"NOT NULL\"\n\t\t}\n\n\t\t_, err = sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` MODIFY `%s` %s %s DEFAULT %s;\", table, column, dataType, nullable, defValue))\n\t\treturn err\n\tcase schemas.POSTGRES:\n\t\t_, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` ALTER COLUMN `%s` SET DEFAULT %s;\", table, column, defValue))\n\t\treturn err\n\tcase schemas.SQLITE:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t}\n}\n\nfunc alterColumnNull(sess *xorm.Session, table, column string, null bool) error {\n\tval := \"NULL\"\n\tif !null {\n\t\tval = \"NOT NULL\"\n\t}\n\tdialect := sess.Engine().Dialect().URI().DBType\n\tswitch dialect {\n\tcase schemas.MYSQL:\n\t\tsql := fmt.Sprintf(\"SHOW COLUMNS FROM `%s` WHERE lower(field) = '%s'\", table, strings.ToLower(column))\n\t\tres, err := sess.Query(sql)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(res) == 0 || len(res[0][\"Type\"]) == 0 {\n\t\t\treturn fmt.Errorf(\"column %s data type in table %s can not be detected\", column, table)\n\t\t}\n\n\t\tdataType := string(res[0][\"Type\"])\n\t\tdefValue := string(res[0][\"Default\"])\n\n\t\tif defValue != \"NULL\" && defValue != \"\" {\n\t\t\tdefValue = fmt.Sprintf(\"DEFAULT '%s'\", defValue)\n\t\t} else {\n\t\t\tdefValue = \"\"\n\t\t}\n\n\t\t_, err = sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` MODIFY `%s` %s %s %s;\", table, column, dataType, val, defValue))\n\t\treturn err\n\tcase schemas.POSTGRES:\n\t\t_, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` ALTER COLUMN `%s` SET %s;\", table, column, val))\n\t\treturn err\n\tcase schemas.SQLITE:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t}\n}\n\nfunc renameColumn(sess *xorm.Session, table, column, newName string) error {\n\tdialect := sess.Engine().Dialect().URI().DBType\n\tswitch dialect {\n\tcase schemas.MYSQL,\n\t\tschemas.POSTGRES,\n\t\tschemas.SQLITE:\n\t\t_, err := sess.Exec(fmt.Sprintf(\"ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`;\", table, column, newName))\n\t\treturn err\n\tdefault:\n\t\treturn fmt.Errorf(\"dialect '%s' not supported\", dialect)\n\t}\n}\n\nvar (\n\twhitespaces     = regexp.MustCompile(`\\s+`)\n\tcolumnSeparator = regexp.MustCompile(`\\s?,\\s?`)\n)\n\nfunc removeColumnFromSQLITETableSchema(schema string, names ...string) string {\n\tif len(names) == 0 {\n\t\treturn schema\n\t}\n\tfor i := range names {\n\t\tif len(names[i]) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tschema = regexp.MustCompile(`\\s(`+\n\t\t\tregexp.QuoteMeta(\"`\"+names[i]+\"`\")+\n\t\t\t\"|\"+\n\t\t\tregexp.QuoteMeta(names[i])+\n\t\t\t\")[^`,)]*?[,)]\").ReplaceAllString(schema, \"\")\n\t}\n\treturn schema\n}\n\nfunc normalizeSQLiteTableSchema(schema string) string {\n\treturn columnSeparator.ReplaceAllString(\n\t\twhitespaces.ReplaceAllString(\n\t\t\tstrings.ReplaceAll(schema, \"\\n\", \" \"),\n\t\t\t\" \"),\n\t\t\", \")\n}\n"
  },
  {
    "path": "server/store/datastore/migration/common_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestRemoveColumnFromSQLITETableSchema(t *testing.T) {\n\tschema := \"CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, repo_user_id INTEGER, repo_owner TEXT, \" +\n\t\t\"repo_name TEXT, repo_full_name TEXT, `repo_avatar` TEXT, repo_branch TEXT, repo_timeout INTEGER, \" +\n\t\t\"repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, \" +\n\t\t\"repo_fallback BOOLEAN, UNIQUE(repo_full_name) )\"\n\n\tassert.EqualValues(t, schema, removeColumnFromSQLITETableSchema(schema, \"\"))\n\n\tassert.EqualValues(t, \"CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, repo_user_id INTEGER, repo_owner TEXT, \"+\n\t\t\"repo_name TEXT, repo_full_name TEXT, repo_branch TEXT, repo_timeout INTEGER, \"+\n\t\t\"repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, \"+\n\t\t\"repo_fallback BOOLEAN, UNIQUE(repo_full_name) )\", removeColumnFromSQLITETableSchema(schema, \"repo_avatar\"))\n\n\tassert.EqualValues(t, \"CREATE TABLE repos ( repo_user_id INTEGER, repo_owner TEXT, \"+\n\t\t\"repo_name TEXT, repo_full_name TEXT, `repo_avatar` TEXT, repo_timeout INTEGER, \"+\n\t\t\"repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, \"+\n\t\t\"repo_fallback BOOLEAN, UNIQUE(repo_full_name) )\", removeColumnFromSQLITETableSchema(schema, \"repo_id\", \"repo_branch\", \"invalid\", \"\"))\n}\n\nfunc TestNormalizeSQLiteTableSchema(t *testing.T) {\n\tassert.EqualValues(t, \"\", normalizeSQLiteTableSchema(``))\n\tassert.EqualValues(t,\n\t\t\"CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, \"+\n\t\t\t\"repo_user_id INTEGER, repo_owner TEXT, repo_name TEXT, repo_full_name TEXT, \"+\n\t\t\t\"`repo_avatar` TEXT, repo_link TEXT, repo_clone TEXT, repo_branch TEXT, \"+\n\t\t\t\"repo_timeout INTEGER, repo_allow_pr BOOLEAN, repo_config_path TEXT, \"+\n\t\t\t\"repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, \"+\n\t\t\t\"repo_fallback BOOLEAN, UNIQUE(repo_full_name) )\",\n\t\tnormalizeSQLiteTableSchema(`CREATE TABLE repos (\n repo_id            INTEGER PRIMARY KEY AUTOINCREMENT\n,repo_user_id       INTEGER\n,repo_owner         TEXT,\n  repo_name         TEXT\n,repo_full_name     TEXT\n,`+\"`\"+`repo_avatar`+\"`\"+`        TEXT\n,repo_link          TEXT\n,repo_clone         TEXT\n,repo_branch        TEXT ,repo_timeout\t\t\tINTEGER\n,repo_allow_pr      BOOLEAN\n,repo_config_path   TEXT\n, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, repo_fallback BOOLEAN,UNIQUE(repo_full_name)\n)`))\n}\n"
  },
  {
    "path": "server/store/datastore/migration/logger.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog/log\"\n)\n\ntype xormigrateLogger struct{}\n\nfunc (l *xormigrateLogger) Debug(v ...any) {\n\tlog.Debug().Msg(fmt.Sprint(v...))\n}\n\nfunc (l *xormigrateLogger) Debugf(format string, v ...any) {\n\tlog.Debug().Msgf(format, v...)\n}\n\nfunc (l *xormigrateLogger) Info(v ...any) {\n\tlog.Info().Msg(fmt.Sprint(v...))\n}\n\nfunc (l *xormigrateLogger) Infof(format string, v ...any) {\n\tlog.Info().Msgf(format, v...)\n}\n\nfunc (l *xormigrateLogger) Warn(v ...any) {\n\tlog.Warn().Msg(fmt.Sprint(v...))\n}\n\nfunc (l *xormigrateLogger) Warnf(format string, v ...any) {\n\tlog.Warn().Msgf(format, v...)\n}\n\nfunc (l *xormigrateLogger) Error(v ...any) {\n\tlog.Error().Msg(fmt.Sprint(v...))\n}\n\nfunc (l *xormigrateLogger) Errorf(format string, v ...any) {\n\tlog.Error().Msgf(format, v...)\n}\n"
  },
  {
    "path": "server/store/datastore/migration/migration.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"src.techknowlogick.com/xormigrate\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// APPEND NEW MIGRATIONS\n// They are executed in order and if one fails Xormigrate will try to rollback that specific one and quits.\nvar migrationTasks = []*xormigrate.Migration{\n\t&legacyToXormigrate,\n\t&addOrgID,\n\t&alterTableTasksUpdateColumnTaskDataType,\n\t&alterTableConfigUpdateColumnConfigDataType,\n\t&removePluginOnlyOptionFromSecretsTable,\n\t&convertToNewPipelineErrorFormat,\n\t&renameLinkToURL,\n\t&cleanRegistryPipeline,\n\t&setForgeID,\n\t&unifyColumnsTables,\n\t&alterTableRegistriesFixRequiredFields,\n\t&cronWithoutSec,\n\t&renameStartEndTime,\n\t&fixV31Registries,\n\t&removeOldMigrationsOfV1,\n\t&addOrgAgents,\n\t&addCustomLabelsToAgent,\n\t&splitTrusted,\n\t&correctPotentialCorruptOrgsUsersRelation,\n\t&gatedToRequireApproval,\n\t&removeRepoNetrcOnlyTrusted,\n\t&renameTokenFields,\n\t&setNewDefaultsForRequireApproval,\n\t&removeRepoScm,\n\t&unsanitizeOrgAndUserNames,\n\t&replaceZeroForgeIDsInOrgs,\n\t&fixForgeColumns,\n\t&addCronField,\n}\n\nvar allBeans = []any{\n\tnew(model.Agent),\n\tnew(model.Pipeline),\n\tnew(model.PipelineConfig),\n\tnew(model.Config),\n\tnew(model.LogEntry),\n\tnew(model.Perm),\n\tnew(model.Step),\n\tnew(model.Registry),\n\tnew(model.Repo),\n\tnew(model.Secret),\n\tnew(model.Task),\n\tnew(model.User),\n\tnew(model.ServerConfig),\n\tnew(model.Cron),\n\tnew(model.Redirection),\n\tnew(model.Forge),\n\tnew(model.Workflow),\n\tnew(model.Org),\n}\n\n// TODO: make xormigrate context aware\nfunc Migrate(_ context.Context, e *xorm.Engine, allowLong bool) error {\n\te.SetDisableGlobalCache(true)\n\n\tm := xormigrate.New(e, migrationTasks)\n\tm.AllowLong(allowLong)\n\n\toldExist, err := e.IsTableExist(\"migrations\")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\toldEmpty := false\n\tif oldExist {\n\t\toldEmpty, err = e.IsTableEmpty(\"migrations\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif !oldExist || oldEmpty {\n\t\t// allow new schema initialization if old migrations table is empty or it does not exist (err != nil)\n\t\t// schema initialization will always run if we call `InitSchema`\n\t\tm.InitSchema(func(_ *xorm.Engine) error {\n\t\t\t// do nothing on schema init, models are synced in any case below\n\t\t\treturn nil\n\t\t})\n\t}\n\n\tm.SetLogger(&xormigrateLogger{})\n\n\tif err := m.Migrate(); err != nil {\n\t\treturn err\n\t}\n\n\te.SetDisableGlobalCache(false)\n\n\tif err := syncAll(e); err != nil {\n\t\treturn fmt.Errorf(\"msg: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc syncAll(sess *xorm.Engine) error {\n\tfor _, bean := range allBeans {\n\t\tif err := sess.Sync(bean); err != nil {\n\t\t\treturn fmt.Errorf(\"sync error '%s': %w\", reflect.TypeOf(bean), err)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "server/store/datastore/migration/migration_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage migration\n\nimport (\n\t\"database/sql\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t// Blank imports to register the sql drivers.\n\t_ \"github.com/go-sql-driver/mysql\"\n\t_ \"github.com/lib/pq\"\n\t_ \"github.com/mattn/go-sqlite3\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"xorm.io/xorm\"\n\t\"xorm.io/xorm/schemas\"\n)\n\nconst (\n\tsqliteDB     = \"./test-files/sqlite.db\"\n\tpostgresDump = \"./test-files/postgres.sql\"\n)\n\nfunc testDriver() string {\n\tdriver := os.Getenv(\"WOODPECKER_DATABASE_DRIVER\")\n\tif len(driver) == 0 {\n\t\treturn \"sqlite3\"\n\t}\n\treturn driver\n}\n\nfunc createSQLiteDB(t *testing.T) string {\n\ttmpF, err := os.CreateTemp(\"./test-files\", \"tmp_\")\n\trequire.NoError(t, err)\n\tdbF, err := os.ReadFile(sqliteDB)\n\trequire.NoError(t, err)\n\n\trequire.NoError(t, os.WriteFile(tmpF.Name(), dbF, 0o644))\n\treturn tmpF.Name()\n}\n\nfunc testDB(t *testing.T, initNewDB bool) (engine *xorm.Engine, closeDB func()) {\n\tdriver := testDriver()\n\tvar err error\n\tcloseDB = func() {}\n\tswitch driver {\n\tcase \"sqlite3\":\n\t\tconfig := \":memory:\"\n\t\tif !initNewDB {\n\t\t\tconfig = createSQLiteDB(t)\n\t\t\tcloseDB = func() {\n\t\t\t\t_ = os.Remove(config)\n\t\t\t}\n\t\t}\n\t\tengine, err = xorm.NewEngine(driver, config)\n\t\trequire.NoError(t, err)\n\t\treturn engine, closeDB\n\tcase \"mysql\":\n\t\tconfig := os.Getenv(\"WOODPECKER_DATABASE_DATASOURCE\")\n\t\tif !initNewDB {\n\t\t\tt.Logf(\"do not have dump to test against\")\n\t\t\tt.SkipNow()\n\t\t}\n\t\tengine, err = xorm.NewEngine(driver, config)\n\t\trequire.NoError(t, err)\n\t\treturn engine, closeDB\n\tcase \"postgres\":\n\t\tconfig := os.Getenv(\"WOODPECKER_DATABASE_DATASOURCE\")\n\t\tcloseDB = func() {\n\t\t\tcleanPostgresDB(t, config)\n\t\t}\n\t\tif !initNewDB {\n\t\t\trestorePostgresDump(t, config)\n\t\t}\n\t\tengine, err = xorm.NewEngine(driver, config)\n\t\trequire.NoError(t, err)\n\t\treturn engine, closeDB\n\tdefault:\n\t\tt.Errorf(\"unsupported driver: %s\", driver)\n\t\tt.FailNow()\n\t}\n\treturn engine, closeDB\n}\n\n// restorePostgresDump only supports dumps generated with `pg_dump --inserts`.\nfunc restorePostgresDump(t *testing.T, config string) {\n\tdump, err := os.ReadFile(postgresDump)\n\trequire.NoError(t, err)\n\n\tdb, err := sql.Open(\"postgres\", config)\n\trequire.NoError(t, err)\n\tdefer db.Close()\n\n\t// clean dump\n\tlines := strings.Split(string(dump), \"\\n\")\n\tnewLines := make([]string, 0, len(lines))\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tswitch {\n\t\tcase line == \"\",\n\t\t\tstrings.HasPrefix(line, \"\\\\\"),\n\t\t\tstrings.HasPrefix(line, \"--\"):\n\t\t\tcontinue\n\t\t}\n\t\tnewLines = append(newLines, line)\n\t}\n\n\tfor _, stmt := range strings.Split(strings.Join(newLines, \"\\n\"), \";\") {\n\t\tif stmt == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t_, err = db.Exec(stmt)\n\t\tif err != nil {\n\t\t\tt.Logf(\"Failed to execute statement: %s\", stmt[:min(len(stmt), 100)])\n\t\t\trequire.NoErrorf(t, err, \"could not load postgres dump\")\n\t\t}\n\t}\n}\n\nfunc cleanPostgresDB(t *testing.T, config string) {\n\tdb, err := sql.Open(\"postgres\", config)\n\trequire.NoError(t, err)\n\tdefer db.Close()\n\n\t// Drop and recreate the public schema\n\t// This removes all tables, indexes, constraints, sequences, etc.\n\t_, err = db.Exec(`\n\t\tDROP SCHEMA public CASCADE;\n\t\tCREATE SCHEMA public;\n\t\tGRANT ALL ON SCHEMA public TO postgres;\n\t\tGRANT ALL ON SCHEMA public TO public;\n\t`)\n\trequire.NoError(t, err)\n}\n\nfunc TestMigrate(t *testing.T) {\n\t// init new db\n\tengine, closeDB := testDB(t, true)\n\tassert.NoError(t, Migrate(t.Context(), engine, true))\n\tcloseDB()\n\n\tdbType := engine.Dialect().URI().DBType\n\tif dbType == schemas.MYSQL || dbType == schemas.POSTGRES {\n\t\t// wait for mysql/postgres to sync ...\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\n\t// migrate old db\n\tengine, closeDB = testDB(t, false)\n\tassert.NoError(t, Migrate(t.Context(), engine, true))\n\tcloseDB()\n}\n"
  },
  {
    "path": "server/store/datastore/migration/test-files/.gitignore",
    "content": "tmp_*\n"
  },
  {
    "path": "server/store/datastore/migration/test-files/postgres.sql",
    "content": "--\n-- PostgreSQL database dump\n--\n\n\\restrict CqELvZI3DY4n4ETCf9XharkGfqppgD8kxo1FDoGUSOJMtIcV1VUigzQFXRMJZRb\n\n-- Dumped from database version 17.6 (Debian 17.6-2.pgdg13+1)\n-- Dumped by pg_dump version 17.6\n\nSET statement_timeout = 0;\nSET lock_timeout = 0;\nSET idle_in_transaction_session_timeout = 0;\nSET transaction_timeout = 0;\nSET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSELECT pg_catalog.set_config('search_path', '', false);\nSET check_function_bodies = false;\nSET xmloption = content;\nSET client_min_messages = warning;\nSET row_security = off;\n\nSET default_tablespace = '';\n\nSET default_table_access_method = heap;\n\n--\n-- Name: agents; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.agents (\n    id bigint NOT NULL,\n    created bigint,\n    updated bigint,\n    name character varying(255),\n    owner_id bigint,\n    token character varying(255),\n    last_contact bigint,\n    platform character varying(100),\n    backend character varying(100),\n    capacity integer,\n    version character varying(255),\n    no_schedule boolean,\n    last_work bigint,\n    org_id bigint,\n    custom_labels json\n);\n\n\nALTER TABLE public.agents OWNER TO postgres;\n\n--\n-- Name: agents_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.agents_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.agents_id_seq OWNER TO postgres;\n\n--\n-- Name: agents_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.agents_id_seq OWNED BY public.agents.id;\n\n\n--\n-- Name: pipelines; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.pipelines (\n    id integer NOT NULL,\n    repo_id integer,\n    number integer,\n    event character varying(500),\n    status character varying(500),\n    created integer,\n    started integer,\n    finished integer,\n    commit character varying(500),\n    branch character varying(500),\n    ref character varying(500),\n    refspec character varying(1000),\n    title character varying(1000),\n    message text,\n    \"timestamp\" integer,\n    author character varying(500),\n    avatar character varying(1000),\n    email character varying(500),\n    forge_url character varying(1000),\n    deploy character varying(500),\n    parent integer,\n    reviewer character varying(250),\n    reviewed integer,\n    sender character varying(250),\n    changed_files text,\n    updated bigint DEFAULT 0 NOT NULL,\n    additional_variables json,\n    pr_labels json,\n    errors json,\n    deploy_task character varying(255),\n    is_prerelease boolean,\n    from_fork boolean\n);\n\n\nALTER TABLE public.pipelines OWNER TO postgres;\n\n--\n-- Name: builds_build_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.builds_build_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.builds_build_id_seq OWNER TO postgres;\n\n--\n-- Name: builds_build_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.builds_build_id_seq OWNED BY public.pipelines.id;\n\n\n--\n-- Name: configs; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.configs (\n    id integer NOT NULL,\n    repo_id integer,\n    hash character varying(250),\n    data bytea,\n    name text\n);\n\n\nALTER TABLE public.configs OWNER TO postgres;\n\n--\n-- Name: config_config_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.config_config_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.config_config_id_seq OWNER TO postgres;\n\n--\n-- Name: config_config_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.config_config_id_seq OWNED BY public.configs.id;\n\n\n--\n-- Name: crons; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.crons (\n    id bigint NOT NULL,\n    name character varying(255),\n    repo_id bigint,\n    creator_id bigint,\n    next_exec bigint,\n    schedule character varying(255) NOT NULL,\n    created bigint DEFAULT 0 NOT NULL,\n    branch character varying(255)\n);\n\n\nALTER TABLE public.crons OWNER TO postgres;\n\n--\n-- Name: crons_i_d_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.crons_i_d_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.crons_i_d_seq OWNER TO postgres;\n\n--\n-- Name: crons_i_d_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.crons_i_d_seq OWNED BY public.crons.id;\n\n\n--\n-- Name: forges; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.forges (\n    id bigint NOT NULL,\n    type character varying(250),\n    url character varying(500),\n    client character varying(250),\n    client_secret character varying(250),\n    skip_verify boolean,\n    oauth_host character varying(250),\n    additional_options json\n);\n\n\nALTER TABLE public.forges OWNER TO postgres;\n\n--\n-- Name: forge_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.forge_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.forge_id_seq OWNER TO postgres;\n\n--\n-- Name: forge_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.forge_id_seq OWNED BY public.forges.id;\n\n\n--\n-- Name: log_entries; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.log_entries (\n    id bigint NOT NULL,\n    step_id bigint,\n    \"time\" bigint,\n    line integer,\n    data bytea,\n    created bigint,\n    type integer\n);\n\n\nALTER TABLE public.log_entries OWNER TO postgres;\n\n--\n-- Name: log_entries_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.log_entries_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.log_entries_id_seq OWNER TO postgres;\n\n--\n-- Name: log_entries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.log_entries_id_seq OWNED BY public.log_entries.id;\n\n\n--\n-- Name: migration; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.migration (\n    id character varying(255),\n    description character varying(255)\n);\n\n\nALTER TABLE public.migration OWNER TO postgres;\n\n--\n-- Name: orgs; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.orgs (\n    id bigint NOT NULL,\n    name character varying(255),\n    is_user boolean,\n    private boolean,\n    forge_id bigint\n);\n\n\nALTER TABLE public.orgs OWNER TO postgres;\n\n--\n-- Name: orgs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.orgs_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.orgs_id_seq OWNER TO postgres;\n\n--\n-- Name: orgs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.orgs_id_seq OWNED BY public.orgs.id;\n\n\n--\n-- Name: perms; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.perms (\n    user_id integer NOT NULL,\n    repo_id integer NOT NULL,\n    pull boolean,\n    push boolean,\n    admin boolean,\n    synced integer,\n    created bigint,\n    updated bigint\n);\n\n\nALTER TABLE public.perms OWNER TO postgres;\n\n--\n-- Name: pipeline_configs; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.pipeline_configs (\n    config_id bigint NOT NULL,\n    pipeline_id bigint NOT NULL\n);\n\n\nALTER TABLE public.pipeline_configs OWNER TO postgres;\n\n--\n-- Name: steps; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.steps (\n    id integer NOT NULL,\n    pipeline_id integer,\n    pid integer,\n    ppid integer,\n    name character varying(250),\n    state character varying(250),\n    error text,\n    exit_code integer,\n    started integer,\n    finished integer,\n    uuid character varying(255),\n    failure character varying(255),\n    type character varying(255)\n);\n\n\nALTER TABLE public.steps OWNER TO postgres;\n\n--\n-- Name: procs_proc_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.procs_proc_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.procs_proc_id_seq OWNER TO postgres;\n\n--\n-- Name: procs_proc_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.procs_proc_id_seq OWNED BY public.steps.id;\n\n\n--\n-- Name: redirections; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.redirections (\n    id bigint NOT NULL,\n    repo_id bigint,\n    repo_full_name character varying(255)\n);\n\n\nALTER TABLE public.redirections OWNER TO postgres;\n\n--\n-- Name: redirections_redirection_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.redirections_redirection_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.redirections_redirection_id_seq OWNER TO postgres;\n\n--\n-- Name: redirections_redirection_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.redirections_redirection_id_seq OWNED BY public.redirections.id;\n\n\n--\n-- Name: registries; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.registries (\n    id integer NOT NULL,\n    repo_id integer DEFAULT 0 NOT NULL,\n    address character varying(250) NOT NULL,\n    username character varying(2000),\n    password text,\n    org_id bigint DEFAULT 0 NOT NULL\n);\n\n\nALTER TABLE public.registries OWNER TO postgres;\n\n--\n-- Name: registry_registry_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.registry_registry_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.registry_registry_id_seq OWNER TO postgres;\n\n--\n-- Name: registry_registry_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.registry_registry_id_seq OWNED BY public.registries.id;\n\n\n--\n-- Name: repos; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.repos (\n    id integer NOT NULL,\n    user_id integer,\n    owner character varying(250),\n    name character varying(250),\n    full_name character varying(250),\n    avatar character varying(500),\n    forge_url character varying(1000),\n    clone character varying(1000),\n    branch character varying(500),\n    timeout integer,\n    private boolean,\n    allow_pr boolean,\n    repo_allow_push boolean,\n    hash character varying(500),\n    config_path character varying(500),\n    visibility character varying(50),\n    active boolean,\n    forge_remote_id character varying(255),\n    org_id bigint,\n    cancel_previous_pipeline_events json,\n    clone_ssh character varying(1000),\n    pr_enabled boolean DEFAULT true,\n    forge_id bigint,\n    allow_deploy boolean,\n    require_approval character varying(255),\n    trusted json,\n    netrc_trusted json\n);\n\n\nALTER TABLE public.repos OWNER TO postgres;\n\n--\n-- Name: repos_repo_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.repos_repo_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.repos_repo_id_seq OWNER TO postgres;\n\n--\n-- Name: repos_repo_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.repos_repo_id_seq OWNED BY public.repos.id;\n\n\n--\n-- Name: secrets; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.secrets (\n    id integer NOT NULL,\n    repo_id integer DEFAULT 0 NOT NULL,\n    name character varying(250) NOT NULL,\n    value bytea,\n    images character varying(2000),\n    events character varying(2000),\n    org_id bigint DEFAULT 0 NOT NULL\n);\n\n\nALTER TABLE public.secrets OWNER TO postgres;\n\n--\n-- Name: secrets_secret_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.secrets_secret_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.secrets_secret_id_seq OWNER TO postgres;\n\n--\n-- Name: secrets_secret_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.secrets_secret_id_seq OWNED BY public.secrets.id;\n\n\n--\n-- Name: server_configs; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.server_configs (\n    key character varying(255) NOT NULL,\n    value character varying(255)\n);\n\n\nALTER TABLE public.server_configs OWNER TO postgres;\n\n--\n-- Name: tasks; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.tasks (\n    id character varying(250) NOT NULL,\n    data bytea,\n    labels bytea,\n    dependencies bytea,\n    run_on bytea,\n    dependencies_status json,\n    agent_id bigint\n);\n\n\nALTER TABLE public.tasks OWNER TO postgres;\n\n--\n-- Name: users; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.users (\n    id integer NOT NULL,\n    login character varying(250),\n    access_token text,\n    refresh_token text,\n    expiry integer,\n    email character varying(500),\n    avatar character varying(500),\n    admin boolean,\n    hash character varying(500),\n    forge_remote_id character varying(255),\n    org_id bigint,\n    forge_id bigint\n);\n\n\nALTER TABLE public.users OWNER TO postgres;\n\n--\n-- Name: users_user_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.users_user_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.users_user_id_seq OWNER TO postgres;\n\n--\n-- Name: users_user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.users_user_id_seq OWNED BY public.users.id;\n\n\n--\n-- Name: workflows; Type: TABLE; Schema: public; Owner: postgres\n--\n\nCREATE TABLE public.workflows (\n    id bigint NOT NULL,\n    pipeline_id bigint,\n    pid integer,\n    name character varying(255),\n    state character varying(255),\n    error text,\n    started bigint,\n    finished bigint,\n    agent_id bigint,\n    platform character varying(255),\n    environ json,\n    axis_id integer\n);\n\n\nALTER TABLE public.workflows OWNER TO postgres;\n\n--\n-- Name: workflows_workflow_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres\n--\n\nCREATE SEQUENCE public.workflows_workflow_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\nALTER SEQUENCE public.workflows_workflow_id_seq OWNER TO postgres;\n\n--\n-- Name: workflows_workflow_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres\n--\n\nALTER SEQUENCE public.workflows_workflow_id_seq OWNED BY public.workflows.id;\n\n\n--\n-- Name: agents id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.agents ALTER COLUMN id SET DEFAULT nextval('public.agents_id_seq'::regclass);\n\n\n--\n-- Name: configs id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.configs ALTER COLUMN id SET DEFAULT nextval('public.config_config_id_seq'::regclass);\n\n\n--\n-- Name: crons id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.crons ALTER COLUMN id SET DEFAULT nextval('public.crons_i_d_seq'::regclass);\n\n\n--\n-- Name: forges id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.forges ALTER COLUMN id SET DEFAULT nextval('public.forge_id_seq'::regclass);\n\n\n--\n-- Name: log_entries id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.log_entries ALTER COLUMN id SET DEFAULT nextval('public.log_entries_id_seq'::regclass);\n\n\n--\n-- Name: orgs id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.orgs ALTER COLUMN id SET DEFAULT nextval('public.orgs_id_seq'::regclass);\n\n\n--\n-- Name: pipelines id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.pipelines ALTER COLUMN id SET DEFAULT nextval('public.builds_build_id_seq'::regclass);\n\n\n--\n-- Name: redirections id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.redirections ALTER COLUMN id SET DEFAULT nextval('public.redirections_redirection_id_seq'::regclass);\n\n\n--\n-- Name: registries id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.registries ALTER COLUMN id SET DEFAULT nextval('public.registry_registry_id_seq'::regclass);\n\n\n--\n-- Name: repos id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.repos ALTER COLUMN id SET DEFAULT nextval('public.repos_repo_id_seq'::regclass);\n\n\n--\n-- Name: secrets id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.secrets ALTER COLUMN id SET DEFAULT nextval('public.secrets_secret_id_seq'::regclass);\n\n\n--\n-- Name: steps id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.steps ALTER COLUMN id SET DEFAULT nextval('public.procs_proc_id_seq'::regclass);\n\n\n--\n-- Name: users id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_user_id_seq'::regclass);\n\n\n--\n-- Name: workflows id; Type: DEFAULT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.workflows ALTER COLUMN id SET DEFAULT nextval('public.workflows_workflow_id_seq'::regclass);\n\n\n--\n-- Data for Name: agents; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.agents VALUES (1, 1641630000, 1641630000, 'agent-1', 1, 'agent_token_abc123xyz', 1641630000, 'linux', 'docker', 2, '1.0.0', false, NULL, -1, NULL);\nINSERT INTO public.agents VALUES (2, 1641630100, 1641630100, 'agent-2', 1, 'agent_token_def456uvw', 1641630100, 'linux', 'docker', 4, '1.0.0', false, NULL, -1, NULL);\nINSERT INTO public.agents VALUES (3, 1641630200, 1641630200, 'agent-3', 2, 'agent_token_ghi789rst', 1641630200, 'linux', 'kubernetes', 8, '1.0.1', false, NULL, -1, NULL);\n\n\n--\n-- Data for Name: configs; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.configs VALUES (1, 105, 'ec8ca9529d6081e631aec26175b26ac91699395b96b9c5fc1f3af6d3aef5d3a8', '\\x636c6f6e653a0a20206769743a0a20202020696d6167653a20776f6f647065636b657263692f706c7567696e2d6769743a746573740a0a73746570733a0a20205072696e743a0a20202020696d6167653a207072696e742f656e760a20202020736563726574733a205b204141414141414141414141414141414141414141414141414141205d', 'woodpecker');\n\n\n--\n-- Data for Name: crons; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.crons VALUES (1, 'nightly-build', 105, 1, 1641686400, '0 0 * * *', 1641630600, 'master');\n\n\n--\n-- Data for Name: forges; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.forges VALUES (1, 'gitea', 'http://100.114.106.50:3000', '6e9119df-a86d-4fe0-b392-fe125d7a265f', 'gto_bagkxxp5yio7npmj7uzrf5neyyalfbqykfmri3ryqfpgvlylqwsa', false, '', '{}');\n\n\n--\n-- Data for Name: log_entries; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.log_entries VALUES (1, 2, 0, 0, '\\x537465704e616d653a20636c6f6e65', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (2, 2, 0, 1, '\\x53746570547970653a20636c6f6e65', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (3, 2, 0, 2, '\\x53746570555549443a2030314a3151344e443232594b534a31465a443654533234343357', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (4, 2, 0, 3, '\\x53746570436f6d6d616e64733a', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (5, 2, 0, 4, '\\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (6, 2, 0, 5, '\\x', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (7, 2, 0, 6, '\\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (8, 2, 0, 7, '\\x', 1641630525, 0);\nINSERT INTO public.log_entries VALUES (9, 3, 0, 0, '\\x537465704e616d653a205072696e74', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (10, 3, 0, 1, '\\x53746570547970653a20636f6d6d616e6473', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (11, 3, 0, 2, '\\x53746570555549443a2030314a3151344e443232594b534a31465a44365739385a573047', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (12, 3, 0, 3, '\\x53746570436f6d6d616e64733a', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (13, 3, 0, 4, '\\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (14, 3, 0, 5, '\\x7072696e7420656e7620636f6d6d616e64', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (15, 3, 0, 6, '\\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630526, 0);\nINSERT INTO public.log_entries VALUES (16, 3, 0, 7, '\\x', 1641630526, 0);\n\n\n--\n-- Data for Name: migration; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.migration VALUES ('SCHEMA_INIT', '');\nINSERT INTO public.migration VALUES ('legacy-to-xormigrate', '');\nINSERT INTO public.migration VALUES ('add-org-id', '');\nINSERT INTO public.migration VALUES ('alter-table-tasks-update-type-of-task-data', '');\nINSERT INTO public.migration VALUES ('alter-table-config-update-type-of-config-data', '');\nINSERT INTO public.migration VALUES ('remove-plugin-only-option-from-secrets-table', '');\nINSERT INTO public.migration VALUES ('convert-to-new-pipeline-error-format', '');\nINSERT INTO public.migration VALUES ('rename-link-to-url', '');\nINSERT INTO public.migration VALUES ('clean-registry-pipeline', '');\nINSERT INTO public.migration VALUES ('set-forge-id', '');\nINSERT INTO public.migration VALUES ('unify-columns-tables', '');\nINSERT INTO public.migration VALUES ('alter-table-registries-fix-required-fields', '');\nINSERT INTO public.migration VALUES ('correct-potential-corrupt-orgs-users-relation', '');\nINSERT INTO public.migration VALUES ('gated-to-require-approval', '');\nINSERT INTO public.migration VALUES ('cron-without-sec', '');\nINSERT INTO public.migration VALUES ('rename-start-end-time', '');\nINSERT INTO public.migration VALUES ('fix-v31-registries', '');\nINSERT INTO public.migration VALUES ('remove-old-migrations-of-v1', '');\nINSERT INTO public.migration VALUES ('add-org-agents', '');\nINSERT INTO public.migration VALUES ('add-custom-labels-to-agent', '');\nINSERT INTO public.migration VALUES ('split-trusted', '');\nINSERT INTO public.migration VALUES ('remove-repo-netrc-only-trusted', '');\nINSERT INTO public.migration VALUES ('rename-token-fields', '');\nINSERT INTO public.migration VALUES ('set-new-defaults-for-require-approval', '');\nINSERT INTO public.migration VALUES ('remove-repo-scm', '');\n\n\n--\n-- Data for Name: orgs; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.orgs VALUES (1, '2', false, false, 1);\nINSERT INTO public.orgs VALUES (2, 'test', true, false, 1);\n\n\n--\n-- Data for Name: perms; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.perms VALUES (1, 1, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 2, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 3, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 4, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 5, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 6, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 7, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 8, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 9, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 10, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 11, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 12, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 13, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 14, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 15, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 16, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 17, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 18, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 19, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 20, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 21, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 22, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 23, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 24, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 25, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 26, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 27, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 28, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 29, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 30, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 31, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 32, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 33, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 34, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 35, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 36, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 37, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 38, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 39, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 40, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 41, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 42, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 43, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 44, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 45, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 46, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 47, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 48, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 49, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 50, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 51, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 52, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 53, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 54, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 55, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 56, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 57, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 58, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 59, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 60, true, true, true, 1641626844, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 115, true, true, true, 1641630451, NULL, NULL);\nINSERT INTO public.perms VALUES (1, 105, true, true, true, 1641630452, NULL, NULL);\n\n\n--\n-- Data for Name: pipeline_configs; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.pipeline_configs VALUES (1, 1);\n\n\n--\n-- Data for Name: pipelines; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.pipelines VALUES (1, 105, 1, 'push', 'failure', 1641630525, 1641630525, 1641630527, '24bf205107cea48b92bc6444e18e40d21733a594', 'master', 'refs/heads/master', '', '', '„.woodpecker.yml“ hinzufügen\\n', 1641630525, 'test', 'http://10.40.8.5:3000/avatars/d6c72f5d7e2a070b52e1194969df2cfe', 'test@test.test', 'http://10.40.8.5:3000/2/settings/compare/3fee083df05667d525878b5fcbd4eaf2a121c559...24bf205107cea48b92bc6444e18e40d21733a594', '', 0, '', 0, 'test', '[\".woodpecker.yml\"]\\n', 0, NULL, NULL, NULL, NULL, NULL, NULL);\n\n\n--\n-- Data for Name: redirections; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\n\n\n--\n-- Data for Name: registries; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\n\n\n--\n-- Data for Name: repos; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.repos VALUES (115, 1, '2', 'testCIservices', '2/testCIservices', 'http://10.40.8.5:3000/avatars/c81e728d9d4c2f636f067f89cc14862c', 'http://10.40.8.5:3000/2/testCIservices', 'http://10.40.8.5:3000/2/testCIservices.git', 'master', 60, false, true, true, 'FOUXTSNL2GXK7JP2SQQJVWVAS6J4E4SGIQYPAHEJBIFPVR46LLDA====', '.woodpecker.yml', 'public', true, NULL, 1, NULL, NULL, true, 1, NULL, 'forks', '{\"network\":false,\"volumes\":false,\"security\":false}', NULL);\nINSERT INTO public.repos VALUES (105, 1, '2', 'settings', '2/settings', 'http://10.40.8.5:3000/avatars/c81e728d9d4c2f636f067f89cc14862c', 'http://10.40.8.5:3000/2/settings', 'http://10.40.8.5:3000/2/settings.git', 'master', 60, false, true, true, '3OQA7X5CNGPTILDYLQSJFDML6U2W7UUFBPPP2G2LRBG3WETAYZLA====', '.woodpecker.yml', 'public', true, NULL, 1, NULL, NULL, true, 1, NULL, 'forks', '{\"network\":false,\"volumes\":false,\"security\":false}', NULL);\n\n\n--\n-- Data for Name: secrets; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.secrets VALUES (1, 105, 'wow', '\\x74657374', 'null\\n', '[\"push\",\"tag\",\"deployment\",\"pull_request\"]\\n', 0);\nINSERT INTO public.secrets VALUES (2, 105, 'n', '\\x6e', 'null\\n', '[\"deployment\"]\\n', 0);\nINSERT INTO public.secrets VALUES (3, 105, 'abc', '\\x656466', 'null\\n', '[\"push\"]\\n', 0);\nINSERT INTO public.secrets VALUES (4, 105, 'quak', '\\x66647361', 'null\\n', '[\"pull_request\"]\\n', 0);\n\n\n--\n-- Data for Name: server_configs; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.server_configs VALUES ('signature-private-key', '1fe3b71c87d7f89fa878306028cf08d66020ef6cafc2af90d05c40ebd03eee3c93189d2a3c46fe5292afc33e9237615ed595ee3d588dce431d5f6848b6a9bf77');\nINSERT INTO public.server_configs VALUES ('jwt-secret', 'GKQDHRJXNN5ONIMOHJUMYDBR4IYIH46M6E5HOXX3Q2KEVZ35GM5Q====');\n\n\n--\n-- Data for Name: steps; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.steps VALUES (2, 1, 2, 1, 'git', 'success', '', 0, 1641630525, 1641630527, NULL, NULL, NULL);\nINSERT INTO public.steps VALUES (3, 1, 3, 1, 'Print', 'skipped', '', 0, 0, 0, NULL, NULL, NULL);\n\n\n--\n-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\n\n\n--\n-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.users VALUES (1, 'test', 'eyJhbGciOiJSUzI1NiIsImtpZCI6IldmbUJ1c2Q0RndUVWRmMjc2NHowUWlEYlJ3TnRBcU5pNVlXS1U1c2k0eEEiLCJ0eXAiOiJKV1QifQ.eyJnbnQiOjEsInR0IjowLCJleHAiOjE2NDE2MzQxMjcsImlhdCI6MTY0MTYzMDUyN30.Fu0wUP-08NpPjq737y6HOeyKN_-_SE4iOZr5yrH7S8Jrz8nIuNKfU7AvlypeMSJ7wo8e3cSTadbSH1polZuFv-Nb1AqWDDXeuXudm61BkF96sTslbSHd0nF7cOy6hqCfIAfQLQpqZTJZ4E26oOSSJxPfOOntOWhlEejRl5F-flXAoYAQLegHxdn9IfYJeM1eanZqF4k6dT9hthFp9v4fmUjODPPfHip_iS7ckPonP1E4-8KeNkU3O-lIS1fgrsbCDA8531FXIGB0U7cSur7H0picKGL6WSzAErPGntlNlQWYB5JedDtLN9Ionxy1Y9LKQON76XYL4gM1Ji98RCEXggVqd7TW0B1fGV-Jve2hU3fKaDyQywsCJp36mpnVaqb5eiTssncHixAwZE0C4yh_XsTd-WoVhsbqlEuDfPTjrtAK94mSzHJTcO3fbtE9L-MoPevQIPM7Yog0i2Xn1oPUCDXVXsV2yJriBiI_r2xbG0nz5Bwn8KAFZ0dNGJ7T9urqKaKMh9guE4jgYLIpRpod_Fd13_GAK0ebgF2CZJdjJT7eEGhzzcg4uFpFdIXL2kNgVN1D6YLMPw3HhVg7_MIfASbJgpcppFhYa4Fk-OpchL5-e_mMyeWogvaJA2wSpyY1f5zJlBnFuIyk_OdV0TwQ3b_TjutehsiibT9WRpOK8h8', 'eyJhbGciOiJSUzI1NiIsImtpZCI6IldmbUJ1c2Q0RndUVWRmMjc2NHowUWlEYlJ3TnRBcU5pNVlXS1U1c2k0eEEiLCJ0eXAiOiJKV1QifQ.eyJnbnQiOjEsInR0IjoxLCJleHAiOjE2NDQyNTg1MjcsImlhdCI6MTY0MTYzMDUyN30.iVtIGQ6VTgRI8L3xFD_YNvVBGZ6kdFb3ERdyOCIHC_CHhOEpZxVGawMGnNNooqbNdmOqJQ0RLJyiAirEKdxSVrtWvqub6uVMjjpeBylE1sAFymCGNJQf77dKvgPHW3QY5FvOSoOoNcRU2g99Bx8sbZhiI12GnNOB-abazrzICpOUikiTdb2ri3w_TNF2Ibrn-itSa1yuhmTrVpqXt_CT4MEfteiDmgjyqonmk-J_BqbcriF3DKAvrXNK1VKVU7xODcFSIRizlgA2kDmnpMT3Oo-Z1I37TFIGAuDOTgcceOPa7rXg_Mfd_jhL7bSH1BI4RsK0rgde3NaCQlU2n7yVOYGbJCSsSWwSAi-gCjjuTTPnQWe3ep3IWrB73_7tKG2_x7YxZ1nQCSFKouA5rZH4g6yoV8wdJh8_bX2Z64-MJBUl8E7JGM2urA5GY1abo0GZ6ZuQi2JS5WnG1iTL9pFlmOoTpN1DKtNE2PUE90GJwi0qGeACif9uJBXQPDAgKk7fbUxKYQobc6ko2CJ1isoRjbi8-GsJ9lhw7tXno5zfAvN3eps9SYgmIRNh0t_vx-LMBezSTSEcTJpv-7Ap6F10GD3E9KmGcYrOMvdtaYgkWFXO6rh49uElUVid-C1tNVpKjnj7ewUosQo9MHSn-d5l1df0rJSueXcaUMSqRSrEzqQ', 1641634127, 'test@test.test', 'http://10.40.8.5:3000/avatars/d6c72f5d7e2a070b52e1194969df2cfe', true, 'OBW2OF5QH3NMCYJ44VU5B5YEQ5LHZLTFW2FDSAJ4R4JVZ4HWSNVQ====', NULL, 2, 1);\nINSERT INTO public.users VALUES (2, 'user2', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsImlhdCI6MTY0MTYzMDUyNywiZXhwIjoxNjQxNjM0MTI3fQ.example_token_user2', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsImlhdCI6MTY0MTYzMDUyNywiZXhwIjoxNjQ0MjU4NTI3fQ.example_secret_user2', 1641634127, 'user2@test.test', 'http://10.40.8.5:3000/avatars/default2', false, 'HASH2EXAMPLEHASH2EXAMPLEHASH2EXAMPLEHASH2EXAMPLE====', NULL, 2, 1);\n\n\n--\n-- Data for Name: workflows; Type: TABLE DATA; Schema: public; Owner: postgres\n--\n\nINSERT INTO public.workflows VALUES (1, 1, 1, 'woodpecker', 'failure', 'Error response from daemon: manifest for woodpeckerci/plugin-git:test not found: manifest unknown: manifest unknown', 1641630525, 1641630527, 0, '', '{}', NULL);\n\n\n--\n-- Name: agents_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.agents_id_seq', 3, true);\n\n\n--\n-- Name: builds_build_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.builds_build_id_seq', 1, true);\n\n\n--\n-- Name: config_config_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.config_config_id_seq', 1, true);\n\n\n--\n-- Name: crons_i_d_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.crons_i_d_seq', 1, false);\n\n\n--\n-- Name: forge_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.forge_id_seq', 1, true);\n\n\n--\n-- Name: log_entries_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.log_entries_id_seq', 1, false);\n\n\n--\n-- Name: orgs_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.orgs_id_seq', 2, true);\n\n\n--\n-- Name: procs_proc_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.procs_proc_id_seq', 3, true);\n\n\n--\n-- Name: redirections_redirection_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.redirections_redirection_id_seq', 1, false);\n\n\n--\n-- Name: registry_registry_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.registry_registry_id_seq', 1, false);\n\n\n--\n-- Name: repos_repo_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.repos_repo_id_seq', 122, true);\n\n\n--\n-- Name: secrets_secret_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.secrets_secret_id_seq', 4, true);\n\n\n--\n-- Name: users_user_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.users_user_id_seq', 2, true);\n\n\n--\n-- Name: workflows_workflow_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres\n--\n\nSELECT pg_catalog.setval('public.workflows_workflow_id_seq', 1, true);\n\n\n--\n-- Name: agents agents_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.agents\n    ADD CONSTRAINT agents_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: pipelines builds_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.pipelines\n    ADD CONSTRAINT builds_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: configs config_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.configs\n    ADD CONSTRAINT config_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: crons crons_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.crons\n    ADD CONSTRAINT crons_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: forges forge_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.forges\n    ADD CONSTRAINT forge_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: log_entries log_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.log_entries\n    ADD CONSTRAINT log_entries_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: orgs orgs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.orgs\n    ADD CONSTRAINT orgs_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: perms perms_perm_user_id_perm_repo_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.perms\n    ADD CONSTRAINT perms_perm_user_id_perm_repo_id_key UNIQUE (user_id, repo_id);\n\n\n--\n-- Name: steps procs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.steps\n    ADD CONSTRAINT procs_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: redirections redirections_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.redirections\n    ADD CONSTRAINT redirections_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: registries registry_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.registries\n    ADD CONSTRAINT registry_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: repos repos_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.repos\n    ADD CONSTRAINT repos_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: secrets secrets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.secrets\n    ADD CONSTRAINT secrets_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: server_configs server_config_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.server_configs\n    ADD CONSTRAINT server_config_pkey PRIMARY KEY (key);\n\n\n--\n-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.tasks\n    ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.users\n    ADD CONSTRAINT users_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: workflows workflows_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres\n--\n\nALTER TABLE ONLY public.workflows\n    ADD CONSTRAINT workflows_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: IDX_agents_org_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_agents_org_id\" ON public.agents USING btree (org_id);\n\n\n--\n-- Name: IDX_crons_creator_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_crons_creator_id\" ON public.crons USING btree (creator_id);\n\n\n--\n-- Name: IDX_crons_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_crons_name\" ON public.crons USING btree (name);\n\n\n--\n-- Name: IDX_crons_repo_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_crons_repo_id\" ON public.crons USING btree (repo_id);\n\n\n--\n-- Name: IDX_log_entries_step_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_log_entries_step_id\" ON public.log_entries USING btree (step_id);\n\n\n--\n-- Name: IDX_perms_perm_repo_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_perms_perm_repo_id\" ON public.perms USING btree (repo_id);\n\n\n--\n-- Name: IDX_perms_perm_user_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_perms_perm_user_id\" ON public.perms USING btree (user_id);\n\n\n--\n-- Name: IDX_pipelines_pipeline_author; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_pipelines_pipeline_author\" ON public.pipelines USING btree (author);\n\n\n--\n-- Name: IDX_pipelines_pipeline_repo_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_pipelines_pipeline_repo_id\" ON public.pipelines USING btree (repo_id);\n\n\n--\n-- Name: IDX_pipelines_pipeline_status; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_pipelines_pipeline_status\" ON public.pipelines USING btree (status);\n\n\n--\n-- Name: IDX_registries_address; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_registries_address\" ON public.registries USING btree (address);\n\n\n--\n-- Name: IDX_registries_org_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_registries_org_id\" ON public.registries USING btree (org_id);\n\n\n--\n-- Name: IDX_registries_repo_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_registries_repo_id\" ON public.registries USING btree (repo_id);\n\n\n--\n-- Name: IDX_repos_org_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_repos_org_id\" ON public.repos USING btree (org_id);\n\n\n--\n-- Name: IDX_repos_user_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_repos_user_id\" ON public.repos USING btree (user_id);\n\n\n--\n-- Name: IDX_secrets_secret_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_secrets_secret_name\" ON public.secrets USING btree (name);\n\n\n--\n-- Name: IDX_secrets_secret_org_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_secrets_secret_org_id\" ON public.secrets USING btree (org_id);\n\n\n--\n-- Name: IDX_secrets_secret_repo_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_secrets_secret_repo_id\" ON public.secrets USING btree (repo_id);\n\n\n--\n-- Name: IDX_steps_pipeline_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_steps_pipeline_id\" ON public.steps USING btree (pipeline_id);\n\n\n--\n-- Name: IDX_steps_uuid; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_steps_uuid\" ON public.steps USING btree (uuid);\n\n\n--\n-- Name: IDX_workflows_pipeline_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE INDEX \"IDX_workflows_pipeline_id\" ON public.workflows USING btree (pipeline_id);\n\n\n--\n-- Name: UQE_config_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_config_s\" ON public.configs USING btree (repo_id, hash, name);\n\n\n--\n-- Name: UQE_crons_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_crons_s\" ON public.crons USING btree (name, repo_id);\n\n\n--\n-- Name: UQE_orgs_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_orgs_name\" ON public.orgs USING btree (name);\n\n\n--\n-- Name: UQE_pipeline_config_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_pipeline_config_s\" ON public.pipeline_configs USING btree (config_id, pipeline_id);\n\n\n--\n-- Name: UQE_pipelines_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_pipelines_s\" ON public.pipelines USING btree (repo_id, number);\n\n\n--\n-- Name: UQE_redirections_repo_full_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_redirections_repo_full_name\" ON public.redirections USING btree (repo_full_name);\n\n\n--\n-- Name: UQE_registries_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_registries_s\" ON public.registries USING btree (org_id, repo_id, address);\n\n\n--\n-- Name: UQE_repos_full_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_repos_full_name\" ON public.repos USING btree (full_name);\n\n\n--\n-- Name: UQE_repos_name; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_repos_name\" ON public.repos USING btree (owner, name);\n\n\n--\n-- Name: UQE_secrets_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_secrets_s\" ON public.secrets USING btree (org_id, repo_id, name);\n\n\n--\n-- Name: UQE_steps_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_steps_s\" ON public.steps USING btree (pipeline_id, pid);\n\n\n--\n-- Name: UQE_tasks_task_id; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_tasks_task_id\" ON public.tasks USING btree (id);\n\n\n--\n-- Name: UQE_users_hash; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_users_hash\" ON public.users USING btree (hash);\n\n\n--\n-- Name: UQE_users_login; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_users_login\" ON public.users USING btree (login);\n\n\n--\n-- Name: UQE_workflows_s; Type: INDEX; Schema: public; Owner: postgres\n--\n\nCREATE UNIQUE INDEX \"UQE_workflows_s\" ON public.workflows USING btree (pipeline_id, pid);\n\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict CqELvZI3DY4n4ETCf9XharkGfqppgD8kxo1FDoGUSOJMtIcV1VUigzQFXRMJZRb\n\n"
  },
  {
    "path": "server/store/datastore/org.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) OrgCreate(org *model.Org) error {\n\treturn s.orgCreate(org, s.engine.NewSession())\n}\n\nfunc (s storage) orgCreate(org *model.Org, sess *xorm.Session) error {\n\tif org.Name == \"\" {\n\t\treturn fmt.Errorf(\"org name is empty\")\n\t}\n\treturn wrapInsert(sess.Insert(org))\n}\n\nfunc (s storage) OrgGet(id int64) (*model.Org, error) {\n\torg := new(model.Org)\n\treturn org, wrapGet(s.engine.ID(id).Get(org))\n}\n\nfunc (s storage) OrgUpdate(org *model.Org) error {\n\treturn s.orgUpdate(s.engine.NewSession(), org)\n}\n\nfunc (s storage) orgUpdate(sess *xorm.Session, org *model.Org) error {\n\t// update\n\t_, err := sess.ID(org.ID).AllCols().Update(org)\n\treturn err\n}\n\nfunc (s storage) OrgDelete(id int64) error {\n\treturn s.orgDelete(s.engine.NewSession(), id)\n}\n\nfunc (s storage) orgDelete(sess *xorm.Session, id int64) error {\n\tif _, err := sess.Where(\"org_id = ?\", id).Delete(new(model.Secret)); err != nil {\n\t\treturn err\n\t}\n\n\tvar repos []*model.Repo\n\tif err := sess.Where(\"org_id = ?\", id).Find(&repos); err != nil {\n\t\treturn err\n\t}\n\n\tfor _, repo := range repos {\n\t\tif err := s.deleteRepo(sess, repo); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn wrapDelete(sess.ID(id).Delete(new(model.Org)))\n}\n\nfunc (s storage) OrgFindByName(name string, forgeID int64) (*model.Org, error) {\n\treturn s.orgFindByName(s.engine.NewSession(), name, forgeID)\n}\n\nfunc (s storage) orgFindByName(sess *xorm.Session, name string, forgeID int64) (*model.Org, error) {\n\t// sanitize\n\torg := new(model.Org)\n\treturn org, wrapGet(sess.Where(\"LOWER(name) = ?\", strings.ToLower(name)).And(\"forge_id = ?\", forgeID).Get(org))\n}\n\nfunc (s storage) OrgRepoList(org *model.Org, p *model.ListOptions) ([]*model.Repo, error) {\n\tvar repos []*model.Repo\n\treturn repos, s.paginate(p).OrderBy(\"id\").Where(\"org_id = ?\", org.ID).Find(&repos)\n}\n\nfunc (s storage) OrgList(p *model.ListOptions) ([]*model.Org, error) {\n\tvar orgs []*model.Org\n\treturn orgs, s.paginate(p).Where(\"is_user = ?\", false).OrderBy(\"id\").Find(&orgs)\n}\n"
  },
  {
    "path": "server/store/datastore/org_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestOrgCRUD(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Org), new(model.Repo), new(model.Secret), new(model.Config), new(model.Perm), new(model.Registry), new(model.Redirection), new(model.Pipeline))\n\tdefer closer()\n\n\torg1 := &model.Org{\n\t\tName:    \"someAwesomeOrg\",\n\t\tForgeID: 1,\n\t\tIsUser:  false,\n\t\tPrivate: true,\n\t}\n\n\t// create first org to play with\n\tassert.NoError(t, store.OrgCreate(org1))\n\tassert.EqualValues(t, \"someAwesomeOrg\", org1.Name)\n\n\t// don't allow the same name in different casing\n\tassert.Error(t, store.OrgCreate(&model.Org{ID: org1.ID, Name: \"someawesomeorg\"}))\n\n\t// retrieve it\n\torgOne, err := store.OrgGet(org1.ID)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, org1, orgOne)\n\n\t// change name\n\tassert.NoError(t, store.OrgUpdate(&model.Org{ID: org1.ID, ForgeID: 1, Name: \"RenamedOrg\"}))\n\n\t// find updated org by name\n\torgOne, err = store.OrgFindByName(\"RenamedOrg\", 1)\n\tassert.NoError(t, err)\n\tassert.NotEqualValues(t, org1, orgOne)\n\tassert.EqualValues(t, org1.ID, orgOne.ID)\n\tassert.EqualValues(t, false, orgOne.IsUser)\n\tassert.EqualValues(t, false, orgOne.Private)\n\tassert.EqualValues(t, \"RenamedOrg\", orgOne.Name)\n\n\t// create two more orgs and repos\n\tsomeUser := &model.Org{Name: \"some_other_u\", IsUser: true}\n\tassert.NoError(t, store.OrgCreate(someUser))\n\tassert.NoError(t, store.OrgCreate(&model.Org{Name: \"some_other_org\"}))\n\tassert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: \"a\", UserID: 1, Owner: \"some_other_u\", Name: \"abc\", FullName: \"some_other_u/abc\", OrgID: someUser.ID}))\n\tassert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: \"b\", UserID: 1, Owner: \"some_other_u\", Name: \"xyz\", FullName: \"some_other_u/xyz\", OrgID: someUser.ID}))\n\tassert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: \"c\", UserID: 1, Owner: \"renamedorg\", Name: \"567\", FullName: \"renamedorg/567\", OrgID: orgOne.ID}))\n\tassert.Error(t, store.OrgCreate(&model.Org{Name: \"\"}), \"expect to fail if name is empty\")\n\n\t// get all repos for a specific org\n\trepos, err := store.OrgRepoList(someUser, &model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Len(t, repos, 2)\n\n\t// delete an org and check if it's gone\n\tassert.NoError(t, store.OrgDelete(org1.ID))\n\tassert.Error(t, store.OrgDelete(org1.ID))\n}\n"
  },
  {
    "path": "server/store/datastore/permission.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) {\n\tperm := new(model.Perm)\n\treturn perm, wrapGet(s.engine.\n\t\tWhere(builder.Eq{\"user_id\": user.ID, \"repo_id\": repo.ID}).\n\t\tGet(perm))\n}\n\nfunc (s storage) PermUpsert(perm *model.Perm) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.permUpsert(sess, perm); err != nil {\n\t\treturn err\n\t}\n\n\treturn sess.Commit()\n}\n\nfunc (s storage) permUpsert(sess *xorm.Session, perm *model.Perm) error {\n\tif perm.RepoID == 0 {\n\t\treturn fmt.Errorf(\"could not determine repo for permission: %v\", perm)\n\t}\n\n\tif perm.UserID == 0 {\n\t\treturn fmt.Errorf(\"could not determine user for permission: %v\", perm)\n\t}\n\n\texist, err := sess.Where(userIDAndRepoIDCond(perm)).Exist(new(model.Perm))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif exist {\n\t\tperm.Updated = time.Now().Unix()\n\t\t_, err = sess.Where(userIDAndRepoIDCond(perm)).AllCols().Update(perm)\n\t} else {\n\t\t// insert will set auto created ID back to perm object\n\t\tperm.Created = time.Now().Unix()\n\t\tperm.Updated = perm.Created\n\t\terr = wrapInsert(sess.Insert(perm))\n\t}\n\treturn err\n}\n\n// userPushOrAdminCondition return condition where user must have push or admin rights\n// if used make sure to have permission table (\"perms\") joined.\nfunc userPushOrAdminCondition(userID int64) builder.Cond {\n\treturn builder.Eq{\"perms.user_id\": userID}.\n\t\tAnd(builder.Eq{\"perms.push\": true}.\n\t\t\tOr(builder.Eq{\"perms.admin\": true}))\n}\n\nfunc userIDAndRepoIDCond(perm *model.Perm) builder.Cond {\n\treturn builder.Eq{\"user_id\": perm.UserID, \"repo_id\": perm.RepoID}\n}\n\n// PermPrune deletes all permission rows for a user\n// where the repo_id is NOT IN the provided keepRepoIDs list. If keepRepoIDs\n// is empty, all permissions for the user are deleted.\nfunc (s storage) PermPrune(userID int64, keepRepoIDs []int64) error {\n\tif len(keepRepoIDs) == 0 {\n\t\t_, err := s.engine.Where(builder.Eq{\"user_id\": userID}).Delete(new(model.Perm))\n\t\treturn err\n\t}\n\n\t_, err := s.engine.Where(builder.Eq{\"user_id\": userID}).\n\t\tAnd(builder.NotIn(\"repo_id\", keepRepoIDs)).\n\t\tDelete(new(model.Perm))\n\treturn err\n}\n"
  },
  {
    "path": "server/store/datastore/permission_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestPermFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User))\n\tdefer closer()\n\n\tuser := &model.User{ID: 1}\n\trepo := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tForgeRemoteID: \"1\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo))\n\n\terr := store.PermUpsert(\n\t\t&model.Perm{\n\t\t\tUserID: user.ID,\n\t\t\tRepoID: repo.ID,\n\t\t\tPull:   true,\n\t\t\tPush:   false,\n\t\t\tAdmin:  false,\n\t\t},\n\t)\n\tassert.NoError(t, err)\n\n\tperm, err := store.PermFind(user, repo)\n\tassert.NoError(t, err)\n\tassert.True(t, perm.Pull)\n\tassert.False(t, perm.Push)\n\tassert.False(t, perm.Admin)\n}\n\nfunc TestPermUpsert(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User))\n\tdefer closer()\n\n\tuser := &model.User{ID: 1}\n\trepo := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tForgeRemoteID: \"1\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo))\n\n\terr := store.PermUpsert(\n\t\t&model.Perm{\n\t\t\tUserID: user.ID,\n\t\t\tRepoID: repo.ID,\n\t\t\tPull:   true,\n\t\t\tPush:   false,\n\t\t\tAdmin:  false,\n\t\t},\n\t)\n\tassert.NoError(t, err)\n\n\tperm, err := store.PermFind(user, repo)\n\tassert.NoError(t, err)\n\tassert.True(t, perm.Pull)\n\tassert.False(t, perm.Push)\n\tassert.False(t, perm.Admin)\n\n\t//\n\t// this will attempt to replace the existing permissions\n\t// using the insert or replace logic.\n\t//\n\n\terr = store.PermUpsert(\n\t\t&model.Perm{\n\t\t\tUserID: user.ID,\n\t\t\tRepoID: repo.ID,\n\t\t\tPull:   true,\n\t\t\tPush:   true,\n\t\t\tAdmin:  true,\n\t\t},\n\t)\n\tassert.NoError(t, err)\n\n\tperm, err = store.PermFind(user, repo)\n\tassert.NoError(t, err)\n\tassert.True(t, perm.Pull)\n\tassert.True(t, perm.Push)\n\tassert.True(t, perm.Admin)\n}\n\nfunc TestPermPruneDeleteAll(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User))\n\tdefer closer()\n\n\tuser := &model.User{ID: 1}\n\n\trepo1 := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"woodpecker-ci/woodpecker1\",\n\t\tOwner:         \"woodpecker-ci\",\n\t\tName:          \"repo1\",\n\t\tForgeRemoteID: \"101\",\n\t}\n\trepo2 := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"woodpecker-ci/woodpecker2\",\n\t\tOwner:         \"woodpecker\",\n\t\tName:          \"repo2\",\n\t\tForgeRemoteID: \"102\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\n\tassert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo1.ID, Pull: true}))\n\tassert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo2.ID, Pull: true}))\n\n\t_, err := store.PermFind(user, repo1)\n\tassert.NoError(t, err)\n\t_, err = store.PermFind(user, repo2)\n\tassert.NoError(t, err)\n\n\tassert.NoError(t, store.PermPrune(user.ID, []int64{}))\n\n\t_, err = store.PermFind(user, repo1)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\t_, err = store.PermFind(user, repo2)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n}\n\nfunc TestPermPruneKeepOne(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User))\n\tdefer closer()\n\n\tuser := &model.User{ID: 1}\n\n\trepo1 := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"woodpecker-ci/woodpecker1\",\n\t\tOwner:         \"woodpecker-ci\",\n\t\tName:          \"repo1\",\n\t\tForgeRemoteID: \"101\",\n\t}\n\trepo2 := &model.Repo{\n\t\tUserID:        1,\n\t\tFullName:      \"woodpecker-ci/woodpecker2\",\n\t\tOwner:         \"woodpecker\",\n\t\tName:          \"repo2\",\n\t\tForgeRemoteID: \"102\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\n\tassert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo1.ID, Pull: true}))\n\tassert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo2.ID, Pull: true}))\n\n\t_, err := store.PermFind(user, repo1)\n\tassert.NoError(t, err)\n\t_, err = store.PermFind(user, repo2)\n\tassert.NoError(t, err)\n\n\t// Prune everything EXCEPT repo2\n\tassert.NoError(t, store.PermPrune(user.ID, []int64{repo2.ID}))\n\n\t_, err = store.PermFind(user, repo1)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\n\t_, err = store.PermFind(user, repo2)\n\tassert.NoError(t, err)\n}\n"
  },
  {
    "path": "server/store/datastore/pipeline.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) GetPipeline(id int64) (*model.Pipeline, error) {\n\tpipeline := &model.Pipeline{}\n\treturn pipeline, wrapGet(s.engine.ID(id).Get(pipeline))\n}\n\nfunc (s storage) GetPipelineNumber(repo *model.Repo, num int64) (*model.Pipeline, error) {\n\tpipeline := new(model.Pipeline)\n\treturn pipeline, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"repo_id\": repo.ID, \"number\": num},\n\t).Get(pipeline))\n}\n\nfunc (s storage) GetPipelineBadge(repo *model.Repo, branch string, events []model.WebhookEvent) (*model.Pipeline, error) {\n\tpipeline := new(model.Pipeline)\n\treturn pipeline, wrapGet(s.engine.\n\t\tDesc(\"number\").\n\t\tWhere(builder.Eq{\"repo_id\": repo.ID, \"branch\": branch}).\n\t\tWhere(builder.In(\"event\", events)).\n\t\tWhere(builder.Neq{\"status\": model.StatusBlocked}).\n\t\tGet(pipeline))\n}\n\nfunc (s storage) GetPipelineLastByBranch(repo *model.Repo, branch string) (*model.Pipeline, error) {\n\tpipeline := new(model.Pipeline)\n\treturn pipeline, wrapGet(s.engine.\n\t\tDesc(\"number\").\n\t\tWhere(builder.Eq{\"repo_id\": repo.ID, \"branch\": branch, \"event\": model.EventPush}).\n\t\tGet(pipeline))\n}\n\nfunc (s storage) GetPipelineLastBefore(repo *model.Repo, branch string, num int64) (*model.Pipeline, error) {\n\tpipeline := new(model.Pipeline)\n\treturn pipeline, wrapGet(s.engine.\n\t\tDesc(\"number\").\n\t\tWhere(builder.Lt{\"id\": num}.\n\t\t\tAnd(builder.Eq{\"repo_id\": repo.ID, \"branch\": branch})).\n\t\tGet(pipeline))\n}\n\nfunc (s storage) GetPipelineList(repo *model.Repo, p *model.ListOptions, f *model.PipelineFilter) ([]*model.Pipeline, error) {\n\tpipelines := make([]*model.Pipeline, 0, 16)\n\n\tcond := builder.NewCond().And(builder.Eq{\"repo_id\": repo.ID})\n\n\tif f != nil {\n\t\tif f.After != 0 {\n\t\t\tcond = cond.And(builder.Gt{\"created\": f.After})\n\t\t}\n\n\t\tif f.Before != 0 {\n\t\t\tcond = cond.And(builder.Lt{\"created\": f.Before})\n\t\t}\n\n\t\tif f.Branch != \"\" {\n\t\t\tcond = cond.And(builder.Eq{\"branch\": f.Branch})\n\t\t}\n\n\t\tif f.Status != \"\" {\n\t\t\tcond = cond.And(builder.Eq{\"status\": f.Status})\n\t\t}\n\n\t\tif len(f.Events) != 0 {\n\t\t\tcond = cond.And(builder.In(\"event\", f.Events))\n\t\t}\n\n\t\tif f.RefContains != \"\" {\n\t\t\tcond = cond.And(builder.Like{\"ref\", f.RefContains})\n\t\t}\n\t}\n\n\treturn pipelines, s.paginate(p).Where(cond).\n\t\tDesc(\"number\").\n\t\tFind(&pipelines)\n}\n\n// GetRepoLatestPipelines get the latest pipeline for each repo.\nfunc (s storage) GetRepoLatestPipelines(repoIDs []int64) ([]*model.Pipeline, error) {\n\tpipelines := make([]*model.Pipeline, 0, len(repoIDs))\n\n\tpipelineIDs := make([]int64, 0, len(repoIDs))\n\tif err := s.engine.Select(\"MAX(id) AS id\").\n\t\tTable(\"pipelines\").\n\t\tWhere(builder.In(\"repo_id\", repoIDs)).\n\t\tGroupBy(\"repo_id\").\n\t\tFind(&pipelineIDs); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn pipelines, s.engine.Where(builder.In(\"id\", pipelineIDs)).Find(&pipelines)\n}\n\n// GetActivePipelineList get all pipelines that are pending, running or blocked.\nfunc (s storage) GetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error) {\n\tpipelines := make([]*model.Pipeline, 0)\n\tquery := s.engine.\n\t\tWhere(\"repo_id = ?\", repo.ID).\n\t\tIn(\"status\", model.StatusPending, model.StatusRunning, model.StatusBlocked).\n\t\tDesc(\"number\")\n\treturn pipelines, query.Find(&pipelines)\n}\n\nfunc (s storage) GetPipelineCount() (int64, error) {\n\treturn s.engine.Count(new(model.Pipeline))\n}\n\n// CreatePipeline creates a new pipeline with retry logic for unique constraint errors.\nfunc (s storage) CreatePipeline(pipeline *model.Pipeline, stepList ...*model.Step) error {\n\t// Maximum number of retries\n\tconst maxRetries = 3\n\n\t// Create backoff configuration\n\texponentialBackoff := backoff.NewExponentialBackOff()\n\n\t// Execute with backoff retry\n\t_, err := backoff.Retry(context.Background(), func() (struct{}, error) {\n\t\tsess := s.engine.NewSession()\n\t\tdefer sess.Close()\n\t\tif err := sess.Begin(); err != nil {\n\t\t\treturn struct{}{}, err\n\t\t}\n\n\t\trepoExist, err := sess.Where(\"id = ?\", pipeline.RepoID).Exist(&model.Repo{})\n\t\tif err != nil {\n\t\t\treturn struct{}{}, err\n\t\t}\n\n\t\tif !repoExist {\n\t\t\treturn struct{}{}, ErrorRepoNotExist{RepoID: pipeline.RepoID}\n\t\t}\n\n\t\t// calc pipeline number\n\t\tvar number int64\n\t\tif _, err := sess.Select(\"MAX(number)\").\n\t\t\tTable(new(model.Pipeline)).\n\t\t\tWhere(\"repo_id = ?\", pipeline.RepoID).\n\t\t\tGet(&number); err != nil {\n\t\t\treturn struct{}{}, err\n\t\t}\n\t\tpipeline.Number = number + 1\n\n\t\tpipeline.Created = time.Now().UTC().Unix()\n\t\t// only Insert set auto created ID back to object\n\t\tif err := wrapInsert(sess.Insert(pipeline)); err != nil {\n\t\t\tif isUniqueConstraintError(err) {\n\t\t\t\treturn struct{}{}, err\n\t\t\t}\n\t\t\treturn struct{}{}, backoff.Permanent(err)\n\t\t}\n\n\t\tfor i := range stepList {\n\t\t\tstepList[i].PipelineID = pipeline.ID\n\t\t\t// only Insert set auto created ID back to object\n\t\t\tif err := wrapInsert(sess.Insert(stepList[i])); err != nil {\n\t\t\t\tif isUniqueConstraintError(err) {\n\t\t\t\t\treturn struct{}{}, err\n\t\t\t\t}\n\t\t\t\treturn struct{}{}, backoff.Permanent(err)\n\t\t\t}\n\t\t}\n\n\t\treturn struct{}{}, sess.Commit()\n\t}, backoff.WithBackOff(exponentialBackoff), backoff.WithMaxTries(maxRetries))\n\n\treturn err\n}\n\n// isUniqueConstraintError checks if an error is a unique constraint violation error.\nfunc isUniqueConstraintError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\terrStr := err.Error()\n\t// Check for common unique constraint error patterns across different databases\n\treturn strings.Contains(errStr, \"duplicate key\") ||\n\t\tstrings.Contains(errStr, \"Duplicate entry\") ||\n\t\tstrings.Contains(errStr, \"UNIQUE constraint failed\") ||\n\t\tstrings.Contains(errStr, \"unique constraint\") ||\n\t\tstrings.Contains(errStr, \"UNIQUE violation\")\n}\n\nfunc (s storage) UpdatePipeline(pipeline *model.Pipeline) error {\n\t_, err := s.engine.ID(pipeline.ID).AllCols().Update(pipeline)\n\treturn err\n}\n\nfunc (s storage) DeletePipeline(pipeline *model.Pipeline) error {\n\treturn s.deletePipeline(s.engine.NewSession(), pipeline.ID)\n}\n\nfunc (s storage) deletePipeline(sess *xorm.Session, pipelineID int64) error {\n\tif err := s.workflowsDelete(sess, pipelineID); err != nil {\n\t\treturn err\n\t}\n\n\tvar confIDs []int64\n\tif err := sess.Table(new(model.PipelineConfig)).Select(\"config_id\").Where(\"pipeline_id = ?\", pipelineID).Find(&confIDs); err != nil {\n\t\treturn err\n\t}\n\tfor _, confID := range confIDs {\n\t\texist, err := sess.Where(builder.Eq{\"config_id\": confID}.And(builder.Neq{\"pipeline_id\": pipelineID})).Exist(new(model.PipelineConfig))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !exist {\n\t\t\t// this config is only used for this pipeline. so delete it\n\t\t\tif _, err := sess.Where(builder.Eq{\"id\": confID}).Delete(new(model.Config)); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tif _, err := sess.Where(\"pipeline_id = ?\", pipelineID).Delete(new(model.PipelineConfig)); err != nil {\n\t\treturn err\n\t}\n\treturn wrapDelete(sess.ID(pipelineID).Delete(new(model.Pipeline)))\n}\n"
  },
  {
    "path": "server/store/datastore/pipeline_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestPipelines(t *testing.T) {\n\trepo := &model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tassert.NoError(t, store.CreateRepo(repo))\n\n\t// Fail when the repo is not existing\n\tpipeline := model.Pipeline{\n\t\tRepoID: 100,\n\t\tStatus: model.StatusSuccess,\n\t}\n\terr := store.CreatePipeline(&pipeline)\n\tassert.Error(t, err)\n\n\tcount, err := store.GetPipelineCount()\n\tassert.NoError(t, err)\n\tassert.Zero(t, count)\n\n\t// add pipeline\n\tpipeline = model.Pipeline{\n\t\tRepoID: repo.ID,\n\t\tStatus: model.StatusSuccess,\n\t\tCommit: \"85f8c029b902ed9400bc600bac301a0aadb144ac\",\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"some-branch\",\n\t}\n\terr = store.CreatePipeline(&pipeline)\n\tassert.NoError(t, err)\n\tassert.NotZero(t, pipeline.ID)\n\tassert.EqualValues(t, 1, pipeline.Number)\n\tassert.Equal(t, \"85f8c029b902ed9400bc600bac301a0aadb144ac\", pipeline.Commit)\n\n\tcount, err = store.GetPipelineCount()\n\tassert.NoError(t, err)\n\tassert.NotZero(t, count)\n\n\tGetPipeline, err := store.GetPipeline(pipeline.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, pipeline.ID, GetPipeline.ID)\n\tassert.Equal(t, pipeline.RepoID, GetPipeline.RepoID)\n\tassert.Equal(t, pipeline.Status, GetPipeline.Status)\n\n\t// update pipeline\n\tpipeline.Status = model.StatusRunning\n\trequire.NoError(t, store.UpdatePipeline(&pipeline))\n\tGetPipeline, err1 := store.GetPipeline(pipeline.ID)\n\trequire.NoError(t, err1)\n\tassert.Equal(t, pipeline.ID, GetPipeline.ID)\n\tassert.Equal(t, pipeline.RepoID, GetPipeline.RepoID)\n\tassert.Equal(t, pipeline.Status, GetPipeline.Status)\n\tassert.Equal(t, pipeline.Number, GetPipeline.Number)\n\n\tpipeline2 := &model.Pipeline{\n\t\tRepoID: repo.ID,\n\t\tStatus: model.StatusPending,\n\t\tEvent:  model.EventPush,\n\t\tBranch: \"main\",\n\t}\n\trequire.NoError(t, store.CreatePipeline(pipeline2, []*model.Step{}...))\n\tGetPipeline, err3 := store.GetPipelineNumber(&model.Repo{ID: 1}, pipeline2.Number)\n\trequire.NoError(t, err3)\n\tassert.Equal(t, pipeline2.ID, GetPipeline.ID)\n\tassert.Equal(t, pipeline2.RepoID, GetPipeline.RepoID)\n\tassert.Equal(t, pipeline2.Number, GetPipeline.Number)\n\n\tGetPipeline, err4 := store.GetPipelineLastByBranch(&model.Repo{ID: repo.ID}, pipeline2.Branch)\n\trequire.NoError(t, err4)\n\tassert.Equal(t, pipeline2.ID, GetPipeline.ID)\n\tassert.Equal(t, pipeline2.RepoID, GetPipeline.RepoID)\n\tassert.Equal(t, pipeline2.Number, GetPipeline.Number)\n\tassert.Equal(t, pipeline2.Status, GetPipeline.Status)\n\n\tpipeline3 := &model.Pipeline{\n\t\tRepoID:   repo.ID,\n\t\tStatus:   model.StatusRunning,\n\t\tBranch:   \"main\",\n\t\tEvent:    model.EventPull,\n\t\tCommit:   \"85f8c029b902ed9400bc600bac301a0aadb144aa\",\n\t\tForgeURL: \"example.com/id3\",\n\t}\n\trequire.NoError(t, store.CreatePipeline(pipeline3))\n\n\tGetPipeline, err5 := store.GetPipelineLastBefore(&model.Repo{ID: 1}, pipeline3.Branch, pipeline3.ID)\n\trequire.NoError(t, err5)\n\tassert.EqualValues(t, pipeline2, GetPipeline)\n}\n\nfunc TestPipelineListFilter(t *testing.T) {\n\trepo := &model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tassert.NoError(t, store.CreateRepo(repo))\n\n\tpipeline1 := &model.Pipeline{\n\t\tRepoID: repo.ID,\n\t\tStatus: model.StatusFailure,\n\t\tEvent:  model.EventCron,\n\t\tRef:    \"refs/heads/some-branch\",\n\t\tBranch: \"some-branch\",\n\t}\n\tpipeline2 := &model.Pipeline{\n\t\tRepoID: repo.ID,\n\t\tStatus: model.StatusSuccess,\n\t\tEvent:  model.EventPull,\n\t\tRef:    \"refs/pull/32\",\n\t\tBranch: \"main\",\n\t}\n\terr := store.CreatePipeline(pipeline1, []*model.Step{}...)\n\tassert.NoError(t, err)\n\ttime.Sleep(1 * time.Second)\n\tbefore := time.Now().Unix()\n\terr = store.CreatePipeline(pipeline2, []*model.Step{}...)\n\tassert.NoError(t, err)\n\n\tpipelines, err := store.GetPipelineList(&model.Repo{ID: 1}, &model.ListOptions{Page: 1, PerPage: 50}, nil)\n\tassert.NoError(t, err)\n\tassert.Len(t, (pipelines), 2)\n\tassert.Equal(t, pipeline2.ID, pipelines[0].ID)\n\tassert.Equal(t, pipeline2.RepoID, pipelines[0].RepoID)\n\tassert.Equal(t, pipeline2.Status, pipelines[0].Status)\n\n\tpipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{\n\t\tBranch: \"main\",\n\t})\n\tassert.NoError(t, err)\n\tassert.Len(t, pipelines, 1)\n\tassert.Equal(t, pipeline2.ID, pipelines[0].ID)\n\n\tpipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{\n\t\tEvents: []model.WebhookEvent{model.EventCron},\n\t})\n\tassert.NoError(t, err)\n\tassert.Len(t, pipelines, 1)\n\tassert.Equal(t, pipeline1.ID, pipelines[0].ID)\n\n\tpipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{\n\t\tEvents:      []model.WebhookEvent{model.EventCron, model.EventPull},\n\t\tRefContains: \"32\",\n\t})\n\tassert.NoError(t, err)\n\tassert.Len(t, (pipelines), 1)\n\tassert.Equal(t, pipeline2.ID, pipelines[0].ID)\n\n\tpipelines, err3 := store.GetPipelineList(&model.Repo{ID: 1}, &model.ListOptions{Page: 1, PerPage: 50}, &model.PipelineFilter{Before: before})\n\tassert.NoError(t, err3)\n\tassert.Len(t, pipelines, 1)\n\tassert.Equal(t, pipeline1.ID, pipelines[0].ID)\n\tassert.Equal(t, pipeline1.RepoID, pipelines[0].RepoID)\n\n\tpipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{\n\t\tStatus: model.StatusSuccess,\n\t})\n\tassert.NoError(t, err)\n\tassert.Len(t, pipelines, 1)\n\tassert.Equal(t, pipeline2.ID, pipelines[0].ID)\n\tassert.Equal(t, model.StatusSuccess, pipelines[0].Status)\n}\n\nfunc TestPipelineIncrement(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Pipeline), new(model.Repo))\n\tdefer closer()\n\n\tassert.NoError(t, store.CreateRepo(&model.Repo{ID: 1, Owner: \"1\", Name: \"1\", FullName: \"1/1\", ForgeRemoteID: \"1\"}))\n\tassert.NoError(t, store.CreateRepo(&model.Repo{ID: 2, Owner: \"2\", Name: \"2\", FullName: \"2/2\", ForgeRemoteID: \"2\"}))\n\n\tpipelineA := &model.Pipeline{RepoID: 1}\n\trequire.NoError(t, store.CreatePipeline(pipelineA))\n\tassert.EqualValues(t, 1, pipelineA.Number)\n\n\tpipelineB := &model.Pipeline{RepoID: 1}\n\tassert.NoError(t, store.CreatePipeline(pipelineB))\n\tassert.EqualValues(t, 2, pipelineB.Number)\n\n\tpipelineC := &model.Pipeline{RepoID: 2}\n\tassert.NoError(t, store.CreatePipeline(pipelineC))\n\tassert.EqualValues(t, 1, pipelineC.Number)\n}\n\nfunc TestDeletePipeline(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Pipeline), new(model.Repo), new(model.Workflow),\n\t\tnew(model.Step), new(model.LogEntry), new(model.PipelineConfig), new(model.Config))\n\tdefer closer()\n\n\terr := wrapInsert(store.engine.Insert(\n\t\t&model.Pipeline{\n\t\t\tID:     2,\n\t\t\tNumber: 2,\n\t\t\tRepoID: 7,\n\t\t},\n\t\t&model.Pipeline{\n\t\t\tID:     5,\n\t\t\tNumber: 3,\n\t\t\tRepoID: 7,\n\t\t},\n\t\t&model.Pipeline{\n\t\t\tID:     8,\n\t\t\tNumber: 4,\n\t\t\tRepoID: 7,\n\t\t},\n\t\t&model.Config{\n\t\t\tID:     23,\n\t\t\tHash:   \"1234\",\n\t\t\tName:   \"test\",\n\t\t\tRepoID: 7,\n\t\t},\n\t\t&model.Config{\n\t\t\tID:     25,\n\t\t\tHash:   \"6789\",\n\t\t\tName:   \"test\",\n\t\t\tRepoID: 7,\n\t\t},\n\t\t&model.PipelineConfig{\n\t\t\tPipelineID: 2,\n\t\t\tConfigID:   23,\n\t\t},\n\t\t&model.PipelineConfig{\n\t\t\tPipelineID: 5,\n\t\t\tConfigID:   23,\n\t\t},\n\t\t&model.PipelineConfig{\n\t\t\tPipelineID: 8,\n\t\t\tConfigID:   25,\n\t\t},\n\t))\n\tassert.NoError(t, err)\n\n\t// delete non existing pipeline\n\tassert.ErrorIs(t, types.ErrRecordNotExist, store.DeletePipeline(&model.Pipeline{ID: 1}))\n\n\t// delete pipeline with shares config\n\tassert.NoError(t, store.DeletePipeline(&model.Pipeline{ID: 2}))\n\tcount, err := store.engine.Count(new(model.Config))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 2, count)\n\n\t// delete pipeline with unique config\n\tassert.NoError(t, store.DeletePipeline(&model.Pipeline{ID: 8}))\n\tcount, err = store.engine.Count(new(model.Config))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, count)\n}\n"
  },
  {
    "path": "server/store/datastore/redirection.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) getRedirection(e *xorm.Session, fullName string) (*model.Redirection, error) {\n\trepo := new(model.Redirection)\n\treturn repo, wrapGet(e.Where(\"repo_full_name = ?\", fullName).Get(repo))\n}\n\nfunc (s storage) CreateRedirection(redirect *model.Redirection) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\treturn s.createRedirection(sess, redirect)\n}\n\nfunc (s storage) createRedirection(e *xorm.Session, redirect *model.Redirection) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(e.Insert(redirect))\n}\n\nfunc (s storage) HasRedirectionForRepo(repoID int64, fullName string) (bool, error) {\n\treturn s.engine.Where(\n\t\tbuilder.Eq{\"repo_id\": repoID, \"repo_full_name\": fullName},\n\t).Exist(new(model.Redirection))\n}\n"
  },
  {
    "path": "server/store/datastore/redirection_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestCreateRedirection(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Redirection))\n\tdefer closer()\n\n\tredirection := &model.Redirection{\n\t\tRepoID:   1,\n\t\tFullName: \"foo/bar\",\n\t}\n\tassert.NoError(t, store.CreateRedirection(redirection))\n}\n\nfunc TestHasRedirectionForRepo(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Redirection))\n\tdefer closer()\n\n\tredirection := &model.Redirection{\n\t\tRepoID:   1,\n\t\tFullName: \"foo/bar\",\n\t}\n\tassert.NoError(t, store.CreateRedirection(redirection))\n\thas, err := store.HasRedirectionForRepo(1, \"foo/bar\")\n\tassert.NoError(t, err)\n\tassert.True(t, has)\n\thas, err = store.HasRedirectionForRepo(1, \"foo/baz\")\n\tassert.NoError(t, err)\n\tassert.False(t, has)\n}\n"
  },
  {
    "path": "server/store/datastore/registry.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"xorm.io/builder\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst orderRegistriesBy = \"id\"\n\nfunc (s storage) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) {\n\treg := new(model.Registry)\n\treturn reg, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"repo_id\": repo.ID, \"address\": addr},\n\t).Get(reg))\n}\n\nfunc (s storage) RegistryList(repo *model.Repo, includeGlobalAndOrg bool, p *model.ListOptions) ([]*model.Registry, error) {\n\tvar regs []*model.Registry\n\tvar cond builder.Cond = builder.Eq{\"repo_id\": repo.ID}\n\tif includeGlobalAndOrg {\n\t\tcond = cond.Or(builder.Eq{\"org_id\": repo.OrgID}).\n\t\t\tOr(builder.And(builder.Eq{\"org_id\": 0}, builder.Eq{\"repo_id\": 0}))\n\t}\n\treturn regs, s.paginate(p).Where(cond).OrderBy(orderRegistriesBy).Find(&regs)\n}\n\nfunc (s storage) RegistryListAll() ([]*model.Registry, error) {\n\tvar registries []*model.Registry\n\treturn registries, s.engine.Find(&registries)\n}\n\nfunc (s storage) RegistryCreate(registry *model.Registry) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(registry))\n}\n\nfunc (s storage) RegistryUpdate(registry *model.Registry) error {\n\t_, err := s.engine.ID(registry.ID).AllCols().Update(registry)\n\treturn err\n}\n\nfunc (s storage) RegistryDelete(registry *model.Registry) error {\n\treturn wrapDelete(s.engine.ID(registry.ID).Delete(new(model.Registry)))\n}\n\nfunc (s storage) OrgRegistryFind(orgID int64, name string) (*model.Registry, error) {\n\tregistry := new(model.Registry)\n\treturn registry, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"org_id\": orgID, \"address\": name},\n\t).Get(registry))\n}\n\nfunc (s storage) OrgRegistryList(orgID int64, p *model.ListOptions) ([]*model.Registry, error) {\n\tregistries := make([]*model.Registry, 0)\n\treturn registries, s.paginate(p).Where(\"org_id = ?\", orgID).OrderBy(orderRegistriesBy).Find(&registries)\n}\n\nfunc (s storage) GlobalRegistryFind(name string) (*model.Registry, error) {\n\tregistry := new(model.Registry)\n\treturn registry, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"org_id\": 0, \"repo_id\": 0, \"address\": name},\n\t).Get(registry))\n}\n\nfunc (s storage) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) {\n\tregistries := make([]*model.Registry, 0)\n\treturn registries, s.paginate(p).Where(\n\t\tbuilder.Eq{\"org_id\": 0, \"repo_id\": 0},\n\t).OrderBy(orderRegistriesBy).Find(&registries)\n}\n"
  },
  {
    "path": "server/store/datastore/registry_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestRegistryFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\terr := store.RegistryCreate(&model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t})\n\tassert.NoError(t, err)\n\n\tregistry, err := store.RegistryFind(&model.Repo{ID: 1}, \"index.docker.io\")\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, registry.RepoID)\n\tassert.Equal(t, \"index.docker.io\", registry.Address)\n\tassert.Equal(t, \"foo\", registry.Username)\n\tassert.Equal(t, \"bar\", registry.Password)\n}\n\nfunc TestRegistryList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t}))\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"foo.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t}))\n\n\tlist, err := store.RegistryList(&model.Repo{ID: 1}, false, &model.ListOptions{Page: 1, PerPage: 50})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 2)\n}\n\nfunc TestRegistryUpdate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\tregistry := &model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t}\n\tassert.NoError(t, store.RegistryCreate(registry))\n\tregistry.Password = \"qux\"\n\tassert.NoError(t, store.RegistryUpdate(registry))\n\tupdated, err := store.RegistryFind(&model.Repo{ID: 1}, \"index.docker.io\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"qux\", updated.Password)\n}\n\nfunc TestRegistryIndexes(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t}))\n\n\t// fail due to duplicate addr\n\tassert.Error(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"baz\",\n\t\tPassword: \"qux\",\n\t}))\n}\n\nfunc TestRegistryDelete(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry), new(model.Repo))\n\tdefer closer()\n\n\treg1 := &model.Registry{\n\t\tRepoID:   1,\n\t\tAddress:  \"index.docker.io\",\n\t\tUsername: \"foo\",\n\t\tPassword: \"bar\",\n\t}\n\trequire.NoError(t, store.RegistryCreate(reg1))\n\n\tassert.NoError(t, store.RegistryDelete(reg1))\n\tassert.ErrorIs(t, store.RegistryDelete(reg1), types.ErrRecordNotExist)\n}\n\nfunc createTestRegistries(t *testing.T, store *storage) {\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tOrgID:   12,\n\t\tAddress: \"my.regsitry.local\",\n\t}))\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:  1,\n\t\tAddress: \"private.registry.local\",\n\t}))\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tRepoID:  1,\n\t\tAddress: \"very-private.registry.local\",\n\t}))\n\tassert.NoError(t, store.RegistryCreate(&model.Registry{\n\t\tAddress: \"index.docker.io\",\n\t}))\n}\n\nfunc TestOrgRegistryFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\terr := store.RegistryCreate(&model.Registry{\n\t\tOrgID:    12,\n\t\tAddress:  \"my.regsitry.local\",\n\t\tUsername: \"username\",\n\t\tPassword: \"password\",\n\t})\n\tassert.NoError(t, err)\n\n\tregistry, err := store.OrgRegistryFind(12, \"my.regsitry.local\")\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 12, registry.OrgID)\n\tassert.Equal(t, \"my.regsitry.local\", registry.Address)\n\tassert.Equal(t, \"username\", registry.Username)\n\tassert.Equal(t, \"password\", registry.Password)\n}\n\nfunc TestOrgRegistryList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\tcreateTestRegistries(t, store)\n\n\tlist, err := store.OrgRegistryList(12, &model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\trequire.Len(t, list, 1)\n\n\tassert.True(t, list[0].IsOrganization())\n}\n\nfunc TestGlobalRegistryFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\terr := store.RegistryCreate(&model.Registry{\n\t\tAddress:  \"my.regsitry.local\",\n\t\tUsername: \"username\",\n\t\tPassword: \"password\",\n\t})\n\tassert.NoError(t, err)\n\n\tregistry, err := store.GlobalRegistryFind(\"my.regsitry.local\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"my.regsitry.local\", registry.Address)\n\tassert.Equal(t, \"username\", registry.Username)\n\tassert.Equal(t, \"password\", registry.Password)\n}\n\nfunc TestGlobalRegistryList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Registry))\n\tdefer closer()\n\n\tcreateTestRegistries(t, store)\n\n\tlist, err := store.GlobalRegistryList(&model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 1)\n\n\tassert.True(t, list[0].IsGlobal())\n}\n"
  },
  {
    "path": "server/store/datastore/repo.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (s storage) GetRepo(id int64) (*model.Repo, error) {\n\trepo := new(model.Repo)\n\treturn repo, wrapGet(s.engine.ID(id).Get(repo))\n}\n\nfunc (s storage) GetRepoForgeID(forgeID int64, remoteID model.ForgeRemoteID) (*model.Repo, error) {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\treturn s.getRepoForgeID(sess, forgeID, remoteID)\n}\n\nfunc (s storage) getRepoForgeID(e *xorm.Session, forgeID int64, remoteID model.ForgeRemoteID) (*model.Repo, error) {\n\trepo := new(model.Repo)\n\treturn repo, wrapGet(e.Where(\"forge_id = ? AND forge_remote_id = ?\", forgeID, remoteID).Get(repo))\n}\n\nfunc (s storage) GetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\treturn s.getRepoNameFallback(sess, forgeID, remoteID, fullName)\n}\n\nfunc (s storage) getRepoNameFallback(e *xorm.Session, forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) {\n\trepo, err := s.getRepoForgeID(e, forgeID, remoteID)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn s.getRepoName(e, fullName)\n\t}\n\treturn repo, err\n}\n\nfunc (s storage) GetRepoName(fullName string) (*model.Repo, error) {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\trepo, err := s.getRepoName(sess, fullName)\n\tif errors.Is(err, types.ErrRecordNotExist) {\n\t\t// the repository does not exist, so look for a redirection\n\t\tredirect, err := s.getRedirection(sess, fullName)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn s.GetRepo(redirect.RepoID)\n\t}\n\treturn repo, err\n}\n\nfunc (s storage) getRepoName(e *xorm.Session, fullName string) (*model.Repo, error) {\n\trepo := new(model.Repo)\n\treturn repo, wrapGet(e.Where(\"LOWER(full_name) = ?\", strings.ToLower(fullName)).Get(repo))\n}\n\nfunc (s storage) GetRepoCount() (int64, error) {\n\treturn s.engine.Where(builder.Eq{\"active\": true}).Count(new(model.Repo))\n}\n\nfunc (s storage) CreateRepo(repo *model.Repo) error {\n\tswitch {\n\tcase repo.Name == \"\":\n\t\treturn fmt.Errorf(\"repo name is empty\")\n\tcase repo.Owner == \"\":\n\t\treturn fmt.Errorf(\"repo owner is empty\")\n\tcase repo.FullName == \"\":\n\t\treturn fmt.Errorf(\"repo full name is empty\")\n\t}\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(repo))\n}\n\nfunc (s storage) UpdateRepo(repo *model.Repo) error {\n\t_, err := s.engine.ID(repo.ID).AllCols().Update(repo)\n\treturn err\n}\n\nfunc (s storage) DeleteRepo(repo *model.Repo) error {\n\treturn s.deleteRepo(s.engine.NewSession(), repo)\n}\n\nfunc (s storage) deleteRepo(sess *xorm.Session, repo *model.Repo) error {\n\tconst batchSize = perPage\n\tif _, err := sess.Where(\"repo_id = ?\", repo.ID).Delete(new(model.Config)); err != nil {\n\t\treturn err\n\t}\n\tif _, err := sess.Where(\"repo_id = ?\", repo.ID).Delete(new(model.Perm)); err != nil {\n\t\treturn err\n\t}\n\tif _, err := sess.Where(\"repo_id = ?\", repo.ID).Delete(new(model.Registry)); err != nil {\n\t\treturn err\n\t}\n\tif _, err := sess.Where(\"repo_id = ?\", repo.ID).Delete(new(model.Secret)); err != nil {\n\t\treturn err\n\t}\n\tif _, err := sess.Where(\"repo_id = ?\", repo.ID).Delete(new(model.Redirection)); err != nil {\n\t\treturn err\n\t}\n\n\t// delete related pipelines\n\tfor startPipelines := 0; ; startPipelines += batchSize {\n\t\tpipelineIDs := make([]int64, 0, batchSize)\n\t\tif err := sess.Limit(batchSize, startPipelines).Table(\"pipelines\").Cols(\"id\").Where(\"repo_id = ?\", repo.ID).Find(&pipelineIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(pipelineIDs) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tfor i := range pipelineIDs {\n\t\t\tif err := s.deletePipeline(sess, pipelineIDs[i]); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn wrapDelete(sess.ID(repo.ID).Delete(new(model.Repo)))\n}\n\n// RepoList list all repos where permissions for specific user are stored\n// TODO: paginate\nfunc (s storage) RepoList(user *model.User, owned, active bool, f *model.RepoFilter) ([]*model.Repo, error) {\n\trepos := make([]*model.Repo, 0)\n\tsess := s.engine.Table(\"repos\").\n\t\tJoin(\"INNER\", \"perms\", \"perms.repo_id = repos.id\").\n\t\tWhere(\"perms.user_id = ?\", user.ID)\n\tif owned {\n\t\tsess = sess.And(builder.Eq{\"perms.push\": true}.Or(builder.Eq{\"perms.admin\": true}))\n\t}\n\tif active {\n\t\tsess = sess.And(builder.Eq{\"repos.active\": true})\n\t}\n\tif f != nil && f.Name != \"\" {\n\t\tsess = sess.And(builder.Eq{\"repos.name\": f.Name})\n\t}\n\treturn repos, sess.\n\t\tAsc(\"full_name\").\n\t\tFind(&repos)\n}\n\n// RepoListAll list all repos.\nfunc (s storage) RepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error) {\n\trepos := make([]*model.Repo, 0)\n\tsess := s.paginate(p).Table(\"repos\")\n\tif active {\n\t\tsess = sess.And(builder.Eq{\"repos.active\": true})\n\t}\n\treturn repos, sess.\n\t\tAsc(\"full_name\").\n\t\tFind(&repos)\n}\n"
  },
  {
    "path": "server/store/datastore/repo_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestCreateRepo(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo))\n\tdefer closer()\n\n\trepo := model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\terr := store.CreateRepo(&repo)\n\tassert.NoError(t, err)\n\tassert.NotZero(t, repo.ID)\n\n\terr2 := store.UpdateRepo(&repo)\n\tgetRepo, err3 := store.GetRepo(repo.ID)\n\n\tassert.NoError(t, err2)\n\tassert.NoError(t, err3)\n\tassert.Equal(t, repo.ID, getRepo.ID)\n\n\t// test that repo has name/owner/fullname\n\tassert.Error(t, store.CreateRepo(&model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"\",\n\t}))\n\tassert.Error(t, store.CreateRepo(&model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"/test\",\n\t\tOwner:    \"\",\n\t\tName:     \"test\",\n\t}))\n\tassert.Error(t, store.CreateRepo(&model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}))\n\n\t// test unique name\n\trepo2 := model.Repo{\n\t\tUserID:   2,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\terr2 = store.CreateRepo(&repo2)\n\tassert.Error(t, err2)\n}\n\nfunc TestGetRepo(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo))\n\tdefer closer()\n\trepo := model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/test\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"test\",\n\t}\n\tassert.NoError(t, store.CreateRepo(&repo))\n\tgetrepo, err := store.GetRepo(repo.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, repo.ID, getrepo.ID)\n\tassert.Equal(t, repo.UserID, getrepo.UserID)\n\tassert.Equal(t, repo.Owner, getrepo.Owner)\n\tassert.Equal(t, repo.Name, getrepo.Name)\n}\n\nfunc TestGetRepoName(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo))\n\tdefer closer()\n\trepo := model.Repo{\n\t\tUserID:   1,\n\t\tFullName: \"bradrydzewski/TEST\",\n\t\tOwner:    \"bradrydzewski\",\n\t\tName:     \"TEST\",\n\t}\n\n\tassert.NoError(t, store.CreateRepo(&repo))\n\tgetrepo, err := store.GetRepoName(repo.FullName)\n\tassert.NoError(t, err)\n\tassert.Equal(t, repo.ID, getrepo.ID)\n\tassert.Equal(t, repo.UserID, getrepo.UserID)\n\tassert.Equal(t, repo.Owner, getrepo.Owner)\n\tassert.Equal(t, repo.Name, getrepo.Name)\n}\n\nfunc TestRepoList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org))\n\tdefer closer()\n\n\tuser := &model.User{\n\t\tLogin:       \"joe\",\n\t\tEmail:       \"foo@bar.com\",\n\t\tAccessToken: \"e42080dddf012c718e476da161d21ad5\",\n\t}\n\tassert.NoError(t, store.CreateUser(user))\n\n\trepo1 := &model.Repo{\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tForgeRemoteID: \"1\",\n\t}\n\trepo2 := &model.Repo{\n\t\tOwner:         \"test\",\n\t\tName:          \"test\",\n\t\tFullName:      \"test/test\",\n\t\tForgeRemoteID: \"2\",\n\t}\n\trepo3 := &model.Repo{\n\t\tOwner:         \"octocat\",\n\t\tName:          \"hello-world\",\n\t\tFullName:      \"octocat/hello-world\",\n\t\tForgeRemoteID: \"3\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\tassert.NoError(t, store.CreateRepo(repo3))\n\n\tfor _, perm := range []*model.Perm{\n\t\t{UserID: user.ID, RepoID: repo1.ID},\n\t\t{UserID: user.ID, RepoID: repo2.ID},\n\t} {\n\t\tassert.NoError(t, store.PermUpsert(perm))\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tfilter   *model.RepoFilter\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\tname:     \"no filter\",\n\t\t\tfilter:   nil,\n\t\t\texpected: []string{\"test\", \"test\"},\n\t\t},\n\t\t{\n\t\t\tname: \"filter by name 'test'\",\n\t\t\tfilter: &model.RepoFilter{\n\t\t\t\tName: \"test\",\n\t\t\t},\n\t\t\texpected: []string{\"test\", \"test\"},\n\t\t},\n\t\t{\n\t\t\tname: \"filter by name 'hello-world'\",\n\t\t\tfilter: &model.RepoFilter{\n\t\t\t\tName: \"hello-world\",\n\t\t\t},\n\t\t\texpected: []string{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trepos, err := store.RepoList(user, false, false, tt.filter)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Len(t, repos, len(tt.expected))\n\n\t\t\tnames := []string{}\n\t\t\tfor _, repo := range repos {\n\t\t\t\tnames = append(names, repo.Name)\n\t\t\t}\n\t\t\tassert.ElementsMatch(t, tt.expected, names)\n\t\t})\n\t}\n}\n\nfunc TestOwnedRepoList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org))\n\tdefer closer()\n\n\tuser := &model.User{\n\t\tLogin:       \"joe\",\n\t\tEmail:       \"foo@bar.com\",\n\t\tAccessToken: \"e42080dddf012c718e476da161d21ad5\",\n\t}\n\tassert.NoError(t, store.CreateUser(user))\n\n\trepo1 := &model.Repo{\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tForgeRemoteID: \"1\",\n\t}\n\trepo2 := &model.Repo{\n\t\tOwner:         \"test\",\n\t\tName:          \"test\",\n\t\tFullName:      \"test/test\",\n\t\tForgeRemoteID: \"2\",\n\t}\n\trepo3 := &model.Repo{\n\t\tOwner:         \"octocat\",\n\t\tName:          \"hello-world\",\n\t\tFullName:      \"octocat/hello-world\",\n\t\tForgeRemoteID: \"3\",\n\t}\n\trepo4 := &model.Repo{\n\t\tOwner:         \"demo\",\n\t\tName:          \"demo\",\n\t\tFullName:      \"demo/demo\",\n\t\tForgeRemoteID: \"4\",\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\tassert.NoError(t, store.CreateRepo(repo3))\n\tassert.NoError(t, store.CreateRepo(repo4))\n\n\tfor _, perm := range []*model.Perm{\n\t\t{UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false},\n\t\t{UserID: user.ID, RepoID: repo2.ID, Push: false, Admin: true},\n\t\t{UserID: user.ID, RepoID: repo3.ID},\n\t\t{UserID: user.ID, RepoID: repo4.ID},\n\t} {\n\t\tassert.NoError(t, store.PermUpsert(perm))\n\t}\n\n\trepos, err := store.RepoList(user, true, false, nil)\n\tassert.NoError(t, err)\n\tassert.Len(t, repos, 2)\n\tassert.Equal(t, repo1.ID, repos[0].ID)\n\tassert.Equal(t, repo2.ID, repos[1].ID)\n}\n\nfunc TestRepoCount(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Repo))\n\tdefer closer()\n\n\trepo1 := &model.Repo{\n\t\tForgeRemoteID: \"A\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tIsActive:      true,\n\t}\n\trepo2 := &model.Repo{\n\t\tForgeRemoteID: \"B\",\n\t\tOwner:         \"test\",\n\t\tName:          \"test\",\n\t\tFullName:      \"test/test\",\n\t\tIsActive:      true,\n\t}\n\trepo3 := &model.Repo{\n\t\tForgeRemoteID: \"C\",\n\t\tOwner:         \"test\",\n\t\tName:          \"test-ui\",\n\t\tFullName:      \"test/test-ui\",\n\t\tIsActive:      false,\n\t}\n\tassert.NoError(t, store.CreateRepo(repo1))\n\tassert.NoError(t, store.CreateRepo(repo2))\n\tassert.NoError(t, store.CreateRepo(repo3))\n\n\tcount, err := store.GetRepoCount()\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 2, count)\n}\n\nfunc TestRepoCrud(t *testing.T) {\n\tstore, closer := newTestStore(t,\n\t\tnew(model.Repo),\n\t\tnew(model.User),\n\t\tnew(model.Perm),\n\t\tnew(model.Pipeline),\n\t\tnew(model.PipelineConfig),\n\t\tnew(model.LogEntry),\n\t\tnew(model.Step),\n\t\tnew(model.Secret),\n\t\tnew(model.Registry),\n\t\tnew(model.Config),\n\t\tnew(model.Redirection),\n\t\tnew(model.Workflow))\n\tdefer closer()\n\n\trepo := model.Repo{\n\t\tForgeID:       1,\n\t\tForgeRemoteID: \"bradrydzewskitest\",\n\t\tUserID:        1,\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t}\n\tassert.NoError(t, store.CreateRepo(&repo))\n\tpipeline := model.Pipeline{\n\t\tRepoID: repo.ID,\n\t}\n\tstep := model.Step{\n\t\tName: \"a step\",\n\t}\n\tassert.NoError(t, store.CreatePipeline(&pipeline, &step))\n\n\t// create unrelated\n\trepoUnrelated := model.Repo{\n\t\tForgeRemoteID: \"xx\",\n\t\tForgeID:       1,\n\t\tUserID:        2,\n\t\tFullName:      \"x/x\",\n\t\tOwner:         \"x\",\n\t\tName:          \"x\",\n\t}\n\tassert.NoError(t, store.CreateRepo(&repoUnrelated))\n\tpipelineUnrelated := model.Pipeline{\n\t\tRepoID: repoUnrelated.ID,\n\t}\n\tstepUnrelated := model.Step{\n\t\tUUID: \"44c0de71-a6be-41c9-b860-e3716d1dfcef\",\n\t\tName: \"a unrelated step\",\n\t}\n\tassert.NoError(t, store.CreatePipeline(&pipelineUnrelated, &stepUnrelated))\n\n\t_, err := store.GetRepo(repo.ID)\n\tassert.NoError(t, err)\n\tassert.NoError(t, store.DeleteRepo(&repo))\n\t_, err = store.GetRepo(repo.ID)\n\tassert.Error(t, err)\n\n\tstepCount, err := store.engine.Count(new(model.Step))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, stepCount)\n\tpipelineCount, err := store.engine.Count(new(model.Pipeline))\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, pipelineCount)\n}\n\nfunc TestRepoRedirection(t *testing.T) {\n\tstore, closer := newTestStore(t,\n\t\tnew(model.Repo),\n\t\tnew(model.Redirection))\n\tdefer closer()\n\n\trepo := model.Repo{\n\t\tUserID:        1,\n\t\tForgeID:       1,\n\t\tForgeRemoteID: \"1\",\n\t\tFullName:      \"bradrydzewski/test\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test\",\n\t}\n\tassert.NoError(t, store.CreateRepo(&repo))\n\n\trepoUpdated := model.Repo{\n\t\tID:            repo.ID,\n\t\tForgeRemoteID: \"1\",\n\t\tFullName:      \"bradrydzewski/test-renamed\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test-renamed\",\n\t}\n\n\tassert.NoError(t, store.UpdateRepo(&repoUpdated))\n\tassert.NoError(t, store.CreateRedirection(&model.Redirection{\n\t\tRepoID:   repo.ID,\n\t\tFullName: repo.FullName,\n\t}))\n\n\t// test redirection from old repo name\n\trepoFromStore, err := store.GetRepoNameFallback(0, \"1\", \"bradrydzewski/test\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, repoFromStore.FullName, repoUpdated.FullName)\n\n\t// test getting repo without forge ID (use name fallback)\n\trepo = model.Repo{\n\t\tUserID:        1,\n\t\tForgeRemoteID: \"bradrydzewski/test-no-forge-id\",\n\t\tFullName:      \"bradrydzewski/test-no-forge-id\",\n\t\tOwner:         \"bradrydzewski\",\n\t\tName:          \"test-no-forge-id\",\n\t}\n\tassert.NoError(t, store.CreateRepo(&repo))\n\n\trepoFromStore, err = store.GetRepoNameFallback(0, \"\", \"bradrydzewski/test-no-forge-id\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, repoFromStore.FullName, repo.FullName)\n}\n"
  },
  {
    "path": "server/store/datastore/secret.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"xorm.io/builder\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nconst orderSecretsBy = \"name\"\n\nfunc (s storage) SecretFind(repo *model.Repo, name string) (*model.Secret, error) {\n\tsecret := new(model.Secret)\n\treturn secret, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"repo_id\": repo.ID, \"name\": name},\n\t).Get(secret))\n}\n\nfunc (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool, p *model.ListOptions) ([]*model.Secret, error) {\n\tvar secrets []*model.Secret\n\tvar cond builder.Cond = builder.Eq{\"repo_id\": repo.ID}\n\tif includeGlobalAndOrgSecrets {\n\t\tcond = cond.Or(builder.Eq{\"org_id\": repo.OrgID}).\n\t\t\tOr(builder.And(builder.Eq{\"org_id\": 0}, builder.Eq{\"repo_id\": 0}))\n\t}\n\treturn secrets, s.paginate(p).Where(cond).OrderBy(orderSecretsBy).Find(&secrets)\n}\n\nfunc (s storage) SecretListAll() ([]*model.Secret, error) {\n\tvar secrets []*model.Secret\n\treturn secrets, s.engine.Find(&secrets)\n}\n\nfunc (s storage) SecretCreate(secret *model.Secret) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(secret))\n}\n\nfunc (s storage) SecretUpdate(secret *model.Secret) error {\n\t_, err := s.engine.ID(secret.ID).AllCols().Update(secret)\n\treturn err\n}\n\nfunc (s storage) SecretDelete(secret *model.Secret) error {\n\treturn wrapDelete(s.engine.ID(secret.ID).Delete(new(model.Secret)))\n}\n\nfunc (s storage) OrgSecretFind(orgID int64, name string) (*model.Secret, error) {\n\tsecret := new(model.Secret)\n\treturn secret, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"org_id\": orgID, \"name\": name},\n\t).Get(secret))\n}\n\nfunc (s storage) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) {\n\tsecrets := make([]*model.Secret, 0)\n\treturn secrets, s.paginate(p).Where(\"org_id = ?\", orgID).OrderBy(orderSecretsBy).Find(&secrets)\n}\n\nfunc (s storage) GlobalSecretFind(name string) (*model.Secret, error) {\n\tsecret := new(model.Secret)\n\treturn secret, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"org_id\": 0, \"repo_id\": 0, \"name\": name},\n\t).Get(secret))\n}\n\nfunc (s storage) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {\n\tsecrets := make([]*model.Secret, 0)\n\treturn secrets, s.paginate(p).Where(\n\t\tbuilder.Eq{\"org_id\": 0, \"repo_id\": 0},\n\t).OrderBy(orderSecretsBy).Find(&secrets)\n}\n"
  },
  {
    "path": "server/store/datastore/secret_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestSecretFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\terr := store.SecretCreate(&model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"password\",\n\t\tValue:  \"correct-horse-battery-staple\",\n\t\tImages: []string{\"golang\", \"node\"},\n\t\tEvents: []model.WebhookEvent{\"push\", \"tag\"},\n\t})\n\tassert.NoError(t, err)\n\n\tsecret, err := store.SecretFind(&model.Repo{ID: 1}, \"password\")\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, secret.RepoID)\n\tassert.Equal(t, \"password\", secret.Name)\n\tassert.Equal(t, \"correct-horse-battery-staple\", secret.Value)\n\tassert.Equal(t, model.EventPush, secret.Events[0])\n\tassert.Equal(t, model.EventTag, secret.Events[1])\n\tassert.Equal(t, \"golang\", secret.Images[0])\n\tassert.Equal(t, \"node\", secret.Images[1])\n}\n\nfunc TestSecretList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tcreateTestSecrets(t, store)\n\n\tlist, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, false, &model.ListOptions{Page: 1, PerPage: 50})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 2)\n}\n\nfunc TestSecretListAll(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tcreateTestSecrets(t, store)\n\n\tlist, err := store.SecretListAll()\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 4)\n}\n\nfunc TestSecretPipelineList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tcreateTestSecrets(t, store)\n\n\tlist, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, true, &model.ListOptions{Page: 1, PerPage: 50})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 4)\n}\n\nfunc TestSecretUpdate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tsecret := &model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"foo\",\n\t\tValue:  \"baz\",\n\t}\n\tassert.NoError(t, store.SecretCreate(secret))\n\tsecret.Value = \"qux\"\n\tassert.EqualValues(t, 1, secret.ID)\n\tassert.NoError(t, store.SecretUpdate(secret))\n\tupdated, err := store.SecretFind(&model.Repo{ID: 1}, \"foo\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"qux\", updated.Value)\n}\n\nfunc TestSecretDelete(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tsecret := &model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"foo\",\n\t\tValue:  \"baz\",\n\t}\n\tassert.NoError(t, store.SecretCreate(secret))\n\n\tassert.NoError(t, store.SecretDelete(secret))\n\t_, err := store.SecretFind(&model.Repo{ID: 1}, \"foo\")\n\tassert.Error(t, err)\n}\n\nfunc TestSecretIndexes(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tassert.NoError(t, store.SecretCreate(&model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"foo\",\n\t\tValue:  \"bar\",\n\t}))\n\n\t// fail due to duplicate name\n\tassert.Error(t, store.SecretCreate(&model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"foo\",\n\t\tValue:  \"baz\",\n\t}))\n}\n\nfunc createTestSecrets(t *testing.T, store *storage) {\n\tassert.NoError(t, store.SecretCreate(&model.Secret{\n\t\tOrgID: 12,\n\t\tName:  \"usr\",\n\t\tValue: \"sec\",\n\t}))\n\tassert.NoError(t, store.SecretCreate(&model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"foo\",\n\t\tValue:  \"bar\",\n\t}))\n\tassert.NoError(t, store.SecretCreate(&model.Secret{\n\t\tRepoID: 1,\n\t\tName:   \"baz\",\n\t\tValue:  \"qux\",\n\t}))\n\tassert.NoError(t, store.SecretCreate(&model.Secret{\n\t\tName:  \"global\",\n\t\tValue: \"val\",\n\t}))\n}\n\nfunc TestOrgSecretFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\terr := store.SecretCreate(&model.Secret{\n\t\tOrgID:  12,\n\t\tName:   \"password\",\n\t\tValue:  \"correct-horse-battery-staple\",\n\t\tImages: []string{\"golang\", \"node\"},\n\t\tEvents: []model.WebhookEvent{\"push\", \"tag\"},\n\t})\n\tassert.NoError(t, err)\n\n\tsecret, err := store.OrgSecretFind(12, \"password\")\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 12, secret.OrgID)\n\tassert.Equal(t, \"password\", secret.Name)\n\tassert.Equal(t, \"correct-horse-battery-staple\", secret.Value)\n\tassert.Equal(t, model.EventPush, secret.Events[0])\n\tassert.Equal(t, model.EventTag, secret.Events[1])\n\tassert.Equal(t, \"golang\", secret.Images[0])\n\tassert.Equal(t, \"node\", secret.Images[1])\n}\n\nfunc TestOrgSecretList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tcreateTestSecrets(t, store)\n\n\tlist, err := store.OrgSecretList(12, &model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 1)\n\n\tassert.True(t, list[0].IsOrganization())\n}\n\nfunc TestGlobalSecretFind(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\terr := store.SecretCreate(&model.Secret{\n\t\tName:   \"password\",\n\t\tValue:  \"correct-horse-battery-staple\",\n\t\tImages: []string{\"golang\", \"node\"},\n\t\tEvents: []model.WebhookEvent{\"push\", \"tag\"},\n\t})\n\tassert.NoError(t, err)\n\n\tsecret, err := store.GlobalSecretFind(\"password\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"password\", secret.Name)\n\tassert.Equal(t, \"correct-horse-battery-staple\", secret.Value)\n\tassert.Equal(t, model.EventPush, secret.Events[0])\n\tassert.Equal(t, model.EventTag, secret.Events[1])\n\tassert.Equal(t, \"golang\", secret.Images[0])\n\tassert.Equal(t, \"node\", secret.Images[1])\n}\n\nfunc TestGlobalSecretList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Secret))\n\tdefer closer()\n\n\tcreateTestSecrets(t, store)\n\n\tlist, err := store.GlobalSecretList(&model.ListOptions{All: true})\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 1)\n\n\tassert.True(t, list[0].IsGlobal())\n}\n"
  },
  {
    "path": "server/store/datastore/server_config.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport \"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\nfunc (s storage) ServerConfigGet(key string) (string, error) {\n\tconfig := new(model.ServerConfig)\n\terr := wrapGet(s.engine.ID(key).Get(config))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn config.Value, nil\n}\n\nfunc (s storage) ServerConfigSet(key, value string) error {\n\tconfig := &model.ServerConfig{\n\t\tKey: key,\n\t}\n\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tcount, err := sess.Count(config)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tconfig.Value = value\n\n\tif count == 0 {\n\t\terr = wrapInsert(sess.Insert(config))\n\t} else {\n\t\t_, err = sess.Where(\"`key` = ?\", config.Key).Cols(\"value\").Update(config)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn sess.Commit()\n}\n\nfunc (s storage) ServerConfigDelete(key string) error {\n\tconfig := &model.ServerConfig{\n\t\tKey: key,\n\t}\n\n\treturn wrapDelete(s.engine.Delete(config))\n}\n"
  },
  {
    "path": "server/store/datastore/server_config_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestServerConfigGetSet(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.ServerConfig))\n\tdefer closer()\n\n\tserverConfig := &model.ServerConfig{\n\t\tKey:   \"test\",\n\t\tValue: \"wonderland\",\n\t}\n\tassert.NoError(t, store.ServerConfigSet(serverConfig.Key, serverConfig.Value))\n\n\tvalue, err := store.ServerConfigGet(serverConfig.Key)\n\tassert.NoError(t, err)\n\tassert.Equal(t, serverConfig.Value, value)\n\n\tserverConfig.Value = \"new-wonderland\"\n\tassert.NoError(t, store.ServerConfigSet(serverConfig.Key, serverConfig.Value))\n\n\tvalue, err = store.ServerConfigGet(serverConfig.Key)\n\tassert.NoError(t, err)\n\tassert.Equal(t, serverConfig.Value, value)\n\n\tvalue, err = store.ServerConfigGet(\"config_not_exist\")\n\tassert.Error(t, err)\n\tassert.Empty(t, value)\n}\n"
  },
  {
    "path": "server/store/datastore/step.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"xorm.io/builder\"\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) StepLoad(pipelineID, stepID int64) (*model.Step, error) {\n\tstep := new(model.Step)\n\treturn step, wrapGet(s.engine.ID(stepID).Where(builder.Eq{\"pipeline_id\": pipelineID}).Get(step))\n}\n\nfunc (s storage) StepByUUID(uuid string) (*model.Step, error) {\n\tstep := new(model.Step)\n\treturn step, wrapGet(s.engine.Where(\n\t\tbuilder.Eq{\"uuid\": uuid},\n\t).Get(step))\n}\n\nfunc (s storage) StepList(pipelineID int64) ([]*model.Step, error) {\n\tstepList := make([]*model.Step, 0)\n\treturn stepList, s.engine.\n\t\tWhere(\"pipeline_id = ?\", pipelineID).\n\t\tOrderBy(\"pid\").\n\t\tFind(&stepList)\n}\n\nfunc (s storage) StepListFromWorkflowFind(workflow *model.Workflow) ([]*model.Step, error) {\n\treturn s.stepListWorkflow(s.engine.NewSession(), workflow)\n}\n\nfunc (s storage) stepListWorkflow(sess *xorm.Session, workflow *model.Workflow) ([]*model.Step, error) {\n\tstepList := make([]*model.Step, 0)\n\treturn stepList, sess.\n\t\tWhere(\"pipeline_id = ?\", workflow.PipelineID).\n\t\tWhere(\"ppid = ?\", workflow.PID).\n\t\tOrderBy(\"pid\").\n\t\tFind(&stepList)\n}\n\nfunc (s storage) stepCreate(sess *xorm.Session, steps []*model.Step) error {\n\tfor i := range steps {\n\t\t// only Insert on single object ref set auto created ID back to object\n\t\tif err := wrapInsert(sess.Insert(steps[i])); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s storage) StepUpdate(step *model.Step) error {\n\t_, err := s.engine.ID(step.ID).AllCols().Update(step)\n\treturn err\n}\n\nfunc deleteStep(sess *xorm.Session, stepID int64) error {\n\tif err := logDelete(sess, stepID); err != nil {\n\t\treturn err\n\t}\n\treturn wrapDelete(sess.ID(stepID).Delete(new(model.Step)))\n}\n"
  },
  {
    "path": "server/store/datastore/step_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc TestStepList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tsess := store.engine.NewSession()\n\terr := store.stepCreate(sess, []*model.Step{\n\t\t{\n\t\t\tUUID:       \"2bf387f7-2913-4907-814c-c9ada88707c0\",\n\t\t\tPipelineID: 2,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"success\",\n\t\t},\n\t\t{\n\t\t\tUUID:       \"4b04073c-1827-4aa4-a5f5-c7b21c5e44a6\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"success\",\n\t\t},\n\t\t{\n\t\t\tUUID:       \"40aab045-970b-4892-b6df-6f825a7ec97a\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        2,\n\t\t\tPPID:       1,\n\t\t\tName:       \"build\",\n\t\t\tState:      \"success\",\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\n\t_ = sess.Commit()\n\tsteps, err := store.StepList(1)\n\tassert.NoError(t, err)\n\tassert.Len(t, steps, 2)\n}\n\nfunc TestStepUpdate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tuuid := \"fc7c7fd6-553e-480b-8ed7-30d8563d0b79\"\n\tstep := &model.Step{\n\t\tUUID:       uuid,\n\t\tPipelineID: 1,\n\t\tPID:        1,\n\t\tPPID:       2,\n\t\tName:       \"build\",\n\t\tState:      \"pending\",\n\t\tError:      \"pc load letter\",\n\t\tExitCode:   255,\n\t}\n\tsess := store.engine.NewSession()\n\tassert.NoError(t, store.stepCreate(sess, []*model.Step{step}))\n\t_ = sess.Commit()\n\tstep.State = \"running\"\n\tassert.NoError(t, store.StepUpdate(step))\n\tupdated, err := store.StepByUUID(uuid)\n\tassert.NoError(t, err)\n\tassert.Equal(t, model.StatusRunning, updated.State)\n}\n\nfunc TestStepIndexes(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tsess := store.engine.NewSession()\n\tdefer sess.Close()\n\n\tassert.NoError(t, store.stepCreate(sess, []*model.Step{\n\t\t{\n\t\t\tUUID:       \"4db7e5fc-5312-4d02-9e14-b51b9e3242cc\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"running\",\n\t\t\tName:       \"build\",\n\t\t},\n\t}))\n\n\t// fail due to duplicate pid\n\tassert.Error(t, store.stepCreate(sess, []*model.Step{\n\t\t{\n\t\t\tUUID:       \"c1f33a9e-2a02-4579-95ec-90255d785a12\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"success\",\n\t\t\tName:       \"clone\",\n\t\t},\n\t}))\n}\n\nfunc TestStepByUUID(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline))\n\tdefer closer()\n\n\tsess := store.engine.NewSession()\n\tassert.NoError(t, store.stepCreate(sess, []*model.Step{\n\t\t{\n\t\t\tUUID:       \"4db7e5fc-5312-4d02-9e14-b51b9e3242cc\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"running\",\n\t\t\tName:       \"build\",\n\t\t},\n\t\t{\n\t\t\tUUID:       \"fc7c7fd6-553e-480b-8ed7-30d8563d0b79\",\n\t\t\tPipelineID: 4,\n\t\t\tPID:        6,\n\t\t\tPPID:       7,\n\t\t\tName:       \"build\",\n\t\t\tState:      \"pending\",\n\t\t\tError:      \"pc load letter\",\n\t\t\tExitCode:   255,\n\t\t},\n\t}))\n\t_ = sess.Close()\n\n\tstep, err := store.StepByUUID(\"4db7e5fc-5312-4d02-9e14-b51b9e3242cc\")\n\tassert.NoError(t, err)\n\tassert.NotEmpty(t, step)\n\n\tstep, err = store.StepByUUID(\"52feb6f5-8ce2-40c0-9937-9d0e3349c98c\")\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\tassert.Empty(t, step)\n}\n\nfunc TestStepLoad(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step))\n\tdefer closer()\n\n\tsess := store.engine.NewSession()\n\tassert.NoError(t, store.stepCreate(sess, []*model.Step{\n\t\t{\n\t\t\tUUID:       \"4db7e5fc-5312-4d02-9e14-b51b9e3242cc\",\n\t\t\tPipelineID: 1,\n\t\t\tPID:        1,\n\t\t\tPPID:       1,\n\t\t\tState:      \"running\",\n\t\t\tName:       \"build\",\n\t\t},\n\t\t{\n\t\t\tUUID:       \"fc7c7fd6-553e-480b-8ed7-30d8563d0b79\",\n\t\t\tPipelineID: 4,\n\t\t\tPID:        6,\n\t\t\tPPID:       7,\n\t\t\tName:       \"build\",\n\t\t\tState:      \"pending\",\n\t\t\tError:      \"pc load letter\",\n\t\t\tExitCode:   255,\n\t\t},\n\t}))\n\t_ = sess.Close()\n\n\tstep, err := store.StepLoad(1, 1)\n\tassert.NoError(t, err)\n\tassert.NotEmpty(t, step)\n\tassert.Equal(t, step.UUID, \"4db7e5fc-5312-4d02-9e14-b51b9e3242cc\")\n\n\tstep, err = store.StepLoad(1, 2)\n\tassert.ErrorIs(t, err, types.ErrRecordNotExist)\n\tassert.Empty(t, step)\n}\n"
  },
  {
    "path": "server/store/datastore/task.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) TaskList() ([]*model.Task, error) {\n\ttasks := make([]*model.Task, 0, perPage)\n\treturn tasks, s.engine.Find(&tasks)\n}\n\nfunc (s storage) TaskInsert(task *model.Task) error {\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(s.engine.Insert(task))\n}\n\nfunc (s storage) TaskDelete(id string) error {\n\treturn wrapDelete(s.engine.Where(\"id = ?\", id).Delete(new(model.Task)))\n}\n"
  },
  {
    "path": "server/store/datastore/task_test.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestTaskList(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Task))\n\tdefer closer()\n\n\tassert.NoError(t, store.TaskInsert(&model.Task{\n\t\tID:        \"some_random_id\",\n\t\tData:      []byte(\"foo\"),\n\t\tLabels:    map[string]string{\"foo\": \"bar\"},\n\t\tDepStatus: map[string]model.StatusValue{\"test\": \"dep\"},\n\t}))\n\n\tlist, err := store.TaskList()\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 1, \"Expected one task in list\")\n\tassert.Equal(t, \"some_random_id\", list[0].ID)\n\tassert.Equal(t, \"foo\", string(list[0].Data))\n\tassert.EqualValues(t, map[string]model.StatusValue{\"test\": \"dep\"}, list[0].DepStatus)\n\n\tassert.NoError(t, store.TaskDelete(\"some_random_id\"))\n\n\tlist, err = store.TaskList()\n\tassert.NoError(t, err)\n\tassert.Len(t, list, 0, \"Want empty task list after delete\")\n}\n"
  },
  {
    "path": "server/store/datastore/user.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/store/types\"\n)\n\nfunc (s storage) GetUser(id int64) (*model.User, error) {\n\tuser := new(model.User)\n\treturn user, wrapGet(s.engine.ID(id).Get(user))\n}\n\nfunc (s storage) GetUserByRemoteID(forgeID int64, userRemoteID model.ForgeRemoteID) (*model.User, error) {\n\tsess := s.engine.NewSession()\n\tuser := new(model.User)\n\treturn user, wrapGet(sess.Where(\"forge_id = ? AND forge_remote_id = ?\", forgeID, userRemoteID).Get(user))\n}\n\nfunc (s storage) GetUserByLogin(forgeID int64, login string) (*model.User, error) {\n\tsess := s.engine.NewSession()\n\tuser := new(model.User)\n\treturn user, wrapGet(sess.Where(\"forge_id = ? AND login=?\", forgeID, login).Get(user))\n}\n\nfunc (s storage) GetUserList(p *model.ListOptions) ([]*model.User, error) {\n\tvar users []*model.User\n\treturn users, s.paginate(p).OrderBy(\"login\").Find(&users)\n}\n\nfunc (s storage) GetUserCount() (int64, error) {\n\treturn s.engine.Count(new(model.User))\n}\n\nfunc (s storage) CreateUser(user *model.User) error {\n\tsess := s.engine.NewSession()\n\torg := &model.Org{\n\t\tName:    user.Login,\n\t\tForgeID: user.ForgeID,\n\t\tIsUser:  true,\n\t}\n\n\texistingOrg, err := s.orgFindByName(sess, org.Name, user.ForgeID)\n\tif err != nil && !errors.Is(err, types.ErrRecordNotExist) {\n\t\treturn fmt.Errorf(\"failed to check if org exists: %w\", err)\n\t}\n\n\tif !errors.Is(err, types.ErrRecordNotExist) {\n\t\torg = existingOrg\n\t\torg.IsUser = true\n\t\torg.Name = user.Login\n\t\terr = s.orgUpdate(sess, org)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to update existing org: %w\", err)\n\t\t}\n\t} else {\n\t\terr = s.orgCreate(org, sess)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create new org: %w\", err)\n\t\t}\n\t}\n\tuser.OrgID = org.ID\n\t// only Insert set auto created ID back to object\n\treturn wrapInsert(sess.Insert(user))\n}\n\nfunc (s storage) UpdateUser(user *model.User) error {\n\t_, err := s.engine.ID(user.ID).AllCols().Update(user)\n\treturn err\n}\n\nfunc (s storage) DeleteUser(user *model.User) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.orgDelete(sess, user.OrgID); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete org: %w\", err)\n\t}\n\n\tif err := wrapDelete(sess.ID(user.ID).Delete(new(model.User))); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete user: %w\", err)\n\t}\n\n\tif _, err := sess.Where(\"user_id = ?\", user.ID).Delete(new(model.Perm)); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete perms: %w\", err)\n\t}\n\n\treturn sess.Commit()\n}\n"
  },
  {
    "path": "server/store/datastore/user_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestUsers(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.User), new(model.Org), new(model.Secret), new(model.Repo), new(model.Perm))\n\tdefer closer()\n\n\tcount, err := store.GetUserCount()\n\tassert.NoError(t, err)\n\tassert.Zero(t, count)\n\n\tuser := model.User{\n\t\tLogin:         \"joe\",\n\t\tForgeRemoteID: \"joe\",\n\t\tAccessToken:   \"f0b461ca586c27872b43a0685cbc2847\",\n\t\tRefreshToken:  \"976f22a5eef7caacb7e678d6c52f49b1\",\n\t\tEmail:         \"foo@bar.com\",\n\t\tAvatar:        \"b9015b0857e16ac4d94a0ffd9a0b79c8\",\n\t}\n\terr = store.CreateUser(&user)\n\tassert.NoError(t, err)\n\tassert.NotZero(t, user.ID)\n\n\terr2 := store.UpdateUser(&user)\n\tassert.NoError(t, err2)\n\n\tgetUser, err := store.GetUser(user.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, user.ID, getUser.ID)\n\tassert.Equal(t, user.Login, getUser.Login)\n\tassert.Equal(t, user.AccessToken, getUser.AccessToken)\n\tassert.Equal(t, user.RefreshToken, getUser.RefreshToken)\n\tassert.Equal(t, user.Email, getUser.Email)\n\tassert.Equal(t, user.Avatar, getUser.Avatar)\n\n\tgetUser, err = store.GetUserByLogin(user.ForgeID, user.Login)\n\tassert.NoError(t, err)\n\tassert.Equal(t, user.ID, getUser.ID)\n\tassert.Equal(t, user.Login, getUser.Login)\n\n\t// check unique login\n\tuser2 := model.User{\n\t\tLogin:         \"Joe\",\n\t\tForgeRemoteID: \"joe\",\n\t\tEmail:         \"foo2@bar.com\",\n\t\tAccessToken:   \"ab20g0ddaf012c744e136da16aa21ad9\",\n\t}\n\terr2 = store.CreateUser(&user2)\n\tassert.Error(t, err2)\n\n\tuser2 = model.User{\n\t\tLogin:         \"jane\",\n\t\tForgeRemoteID: \"jane\",\n\t\tEmail:         \"foo@bar.com\",\n\t\tAccessToken:   \"ab20g0ddaf012c744e136da16aa21ad9\",\n\t\tHash:          \"A\",\n\t}\n\tassert.NoError(t, store.CreateUser(&user2))\n\tusers, err := store.GetUserList(&model.ListOptions{Page: 1, PerPage: 50})\n\tassert.NoError(t, err)\n\tassert.Len(t, users, 2)\n\t// \"jane\" user is first due to alphabetic sorting\n\tassert.Equal(t, user2.Login, users[0].Login)\n\tassert.Equal(t, user2.Email, users[0].Email)\n\tassert.Equal(t, user2.AccessToken, users[0].AccessToken)\n\n\tcount, err = store.GetUserCount()\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 2, count)\n\n\tgetUser, err1 := store.GetUser(user.ID)\n\tassert.NoError(t, err1)\n\terr2 = store.DeleteUser(getUser)\n\tassert.NoError(t, err2)\n\t_, err3 := store.GetUser(getUser.ID)\n\tassert.Error(t, err3)\n}\n\nfunc TestCreateUserWithExistingOrg(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.User), new(model.Org), new(model.Perm))\n\tdefer closer()\n\n\texistingOrg := &model.Org{\n\t\tForgeID: 1,\n\t\tIsUser:  true,\n\t\tName:    \"existingOrg\",\n\t\tPrivate: false,\n\t}\n\n\terr := store.OrgCreate(existingOrg)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, \"existingOrg\", existingOrg.Name)\n\n\t// Create a new user with the same name as the existing organization\n\tnewUser := &model.User{\n\t\tLogin:         \"existingOrg\",\n\t\tForgeRemoteID: \"A\",\n\t\tHash:          \"A\",\n\t\tForgeID:       1,\n\t}\n\terr = store.CreateUser(newUser)\n\tassert.NoError(t, err)\n\n\tupdatedOrg, err := store.OrgGet(existingOrg.ID)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"existingOrg\", updatedOrg.Name)\n\n\tnewUser2 := &model.User{\n\t\tLogin:         \"new-user\",\n\t\tForgeRemoteID: \"B\",\n\t\tForgeID:       1,\n\t\tHash:          \"B\",\n\t}\n\terr = store.CreateUser(newUser2)\n\tassert.NoError(t, err)\n\n\tnewOrg, err := store.OrgFindByName(\"new-user\", 1)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"new-user\", newOrg.Name)\n}\n"
  },
  {
    "path": "server/store/datastore/workflow.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"xorm.io/xorm\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc (s storage) WorkflowGetTree(pipeline *model.Pipeline) ([]*model.Workflow, error) {\n\tsess := s.engine.NewSession()\n\twfList, err := s.workflowList(sess, pipeline)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, wf := range wfList {\n\t\twf.Children, err = s.stepListWorkflow(sess, wf)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn wfList, sess.Commit()\n}\n\nfunc (s storage) WorkflowsCreate(workflows []*model.Workflow) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.workflowsCreate(sess, workflows); err != nil {\n\t\treturn err\n\t}\n\n\treturn sess.Commit()\n}\n\nfunc (s storage) workflowsCreate(sess *xorm.Session, workflows []*model.Workflow) error {\n\tfor i := range workflows {\n\t\t// only Insert on single object ref set auto created ID back to object\n\t\tif err := s.stepCreate(sess, workflows[i].Children); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := wrapInsert(sess.Insert(workflows[i])); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// WorkflowsReplace performs an atomic replacement of workflows and associated steps by deleting all existing workflows and steps and inserting the new ones.\nfunc (s storage) WorkflowsReplace(pipeline *model.Pipeline, workflows []*model.Workflow) error {\n\tsess := s.engine.NewSession()\n\tdefer sess.Close()\n\tif err := sess.Begin(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.workflowsDelete(sess, pipeline.ID); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.workflowsCreate(sess, workflows); err != nil {\n\t\treturn err\n\t}\n\n\treturn sess.Commit()\n}\n\nfunc (s storage) workflowsDelete(sess *xorm.Session, pipelineID int64) error {\n\t// delete related steps\n\tfor {\n\t\tstepIDs := make([]int64, 0, perPage)\n\t\tif err := sess.Limit(perPage).Table(\"steps\").Cols(\"id\").Where(\"pipeline_id = ?\", pipelineID).Find(&stepIDs); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(stepIDs) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tfor i := range stepIDs {\n\t\t\tif err := deleteStep(sess, stepIDs[i]); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\t_, err := sess.Where(\"pipeline_id = ?\", pipelineID).Delete(new(model.Workflow))\n\treturn err\n}\n\nfunc (s storage) WorkflowList(pipeline *model.Pipeline) ([]*model.Workflow, error) {\n\treturn s.workflowList(s.engine.NewSession(), pipeline)\n}\n\n// workflowList lists workflows without child steps.\nfunc (s storage) workflowList(sess *xorm.Session, pipeline *model.Pipeline) ([]*model.Workflow, error) {\n\tvar wfList []*model.Workflow\n\terr := sess.Where(\"pipeline_id = ?\", pipeline.ID).\n\t\tOrderBy(\"pid\").\n\t\tFind(&wfList)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn wfList, nil\n}\n\nfunc (s storage) WorkflowLoad(id int64) (*model.Workflow, error) {\n\tworkflow := new(model.Workflow)\n\treturn workflow, wrapGet(s.engine.ID(id).Get(workflow))\n}\n\nfunc (s storage) WorkflowUpdate(workflow *model.Workflow) error {\n\t_, err := s.engine.ID(workflow.ID).AllCols().Update(workflow)\n\treturn err\n}\n"
  },
  {
    "path": "server/store/datastore/workflow_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\nfunc TestWorkflowLoad(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow))\n\tdefer closer()\n\n\twf := &model.Workflow{\n\t\tPipelineID: 1,\n\t\tPID:        1,\n\t\tName:       \"woodpecker\",\n\t\tChildren: []*model.Step{\n\t\t\t{\n\t\t\t\tUUID:       \"ea6d4008-8ace-4f8a-ad03-53f1756465d9\",\n\t\t\t\tPipelineID: 1,\n\t\t\t\tPID:        2,\n\t\t\t\tPPID:       1,\n\t\t\t\tState:      \"success\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tUUID:       \"2bf387f7-2913-4907-814c-c9ada88707c0\",\n\t\t\t\tPipelineID: 1,\n\t\t\t\tPID:        3,\n\t\t\t\tPPID:       1,\n\t\t\t\tName:       \"build\",\n\t\t\t\tState:      \"success\",\n\t\t\t},\n\t\t},\n\t}\n\tassert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf}))\n\tworkflowGet, err := store.WorkflowLoad(1)\n\tassert.NoError(t, err)\n\tassert.EqualValues(t, 1, workflowGet.PipelineID)\n\tassert.Equal(t, 1, workflowGet.PID)\n\tassert.Len(t, workflowGet.Children, 0)\n}\n\nfunc TestWorkflowGetTree(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow))\n\tdefer closer()\n\n\twf := &model.Workflow{\n\t\tPipelineID: 1,\n\t\tPID:        1,\n\t\tName:       \"woodpecker\",\n\t\tChildren: []*model.Step{\n\t\t\t{\n\t\t\t\tUUID:       \"ea6d4008-8ace-4f8a-ad03-53f1756465d9\",\n\t\t\t\tPipelineID: 1,\n\t\t\t\tPID:        2,\n\t\t\t\tPPID:       1,\n\t\t\t\tState:      \"success\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tUUID:       \"2bf387f7-2913-4907-814c-c9ada88707c0\",\n\t\t\t\tPipelineID: 1,\n\t\t\t\tPID:        3,\n\t\t\t\tPPID:       1,\n\t\t\t\tName:       \"build\",\n\t\t\t\tState:      \"success\",\n\t\t\t},\n\t\t},\n\t}\n\tassert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf}))\n\n\tworkflowsGet, err := store.WorkflowGetTree(&model.Pipeline{ID: 1})\n\tassert.NoError(t, err)\n\tassert.Len(t, workflowsGet, 1)\n\tworkflowGet := workflowsGet[0]\n\tassert.Equal(t, \"woodpecker\", workflowGet.Name)\n\tassert.Len(t, workflowGet.Children, 2)\n\tassert.Equal(t, 2, workflowGet.Children[0].PID)\n\tassert.Equal(t, 3, workflowGet.Children[1].PID)\n}\n\nfunc TestWorkflowUpdate(t *testing.T) {\n\tstore, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow))\n\tdefer closer()\n\n\twf := &model.Workflow{\n\t\tPipelineID: 1,\n\t\tPID:        1,\n\t\tName:       \"woodpecker\",\n\t\tState:      \"pending\",\n\t}\n\tassert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf}))\n\tworkflowGet, err := store.WorkflowLoad(1)\n\tassert.NoError(t, err)\n\n\tassert.Equal(t, model.StatusValue(\"pending\"), workflowGet.State)\n\n\twf.State = \"success\"\n\n\tassert.NoError(t, store.WorkflowUpdate(wf))\n\tworkflowGet, err = store.WorkflowLoad(1)\n\tassert.NoError(t, err)\n\tassert.Equal(t, model.StatusValue(\"success\"), workflowGet.State)\n}\n"
  },
  {
    "path": "server/store/datastore/xorm.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage datastore\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\txlog \"xorm.io/xorm/log\"\n)\n\nfunc newXORMLogger(level xlog.LogLevel) xlog.Logger {\n\treturn &xormLogger{\n\t\tlogger: log.With().Str(\"component\", \"xorm\").Logger(),\n\t\tlevel:  level,\n\t}\n}\n\n// xormLogger custom log implementation for ILogger.\ntype xormLogger struct {\n\tlogger  zerolog.Logger\n\tlevel   xlog.LogLevel\n\tshowSQL bool\n}\n\n// Error implement ILogger.\nfunc (x *xormLogger) Error(v ...any) {\n\tif x.level <= xlog.LOG_ERR {\n\t\tx.logger.Error().Msg(fmt.Sprintln(v...))\n\t}\n}\n\n// Errorf implement ILogger.\nfunc (x *xormLogger) Errorf(format string, v ...any) {\n\tif x.level <= xlog.LOG_ERR {\n\t\tx.logger.Error().Msg(fmt.Sprintf(format, v...))\n\t}\n}\n\n// Debug implement ILogger.\nfunc (x *xormLogger) Debug(v ...any) {\n\tif x.level <= xlog.LOG_DEBUG {\n\t\tx.logger.Debug().Msg(fmt.Sprintln(v...))\n\t}\n}\n\n// Debugf implement ILogger.\nfunc (x *xormLogger) Debugf(format string, v ...any) {\n\tif x.level <= xlog.LOG_DEBUG {\n\t\tx.logger.Debug().Msg(fmt.Sprintf(format, v...))\n\t}\n}\n\n// Info implement ILogger.\nfunc (x *xormLogger) Info(v ...any) {\n\tif x.level <= xlog.LOG_INFO {\n\t\tx.logger.Info().Msg(fmt.Sprintln(v...))\n\t}\n}\n\n// Infof implement ILogger.\nfunc (x *xormLogger) Infof(format string, v ...any) {\n\tif x.level <= xlog.LOG_INFO {\n\t\tx.logger.Info().Msg(fmt.Sprintf(format, v...))\n\t}\n}\n\n// Warn implement ILogger.\nfunc (x *xormLogger) Warn(v ...any) {\n\tif x.level <= xlog.LOG_WARNING {\n\t\tx.logger.Warn().Msg(fmt.Sprintln(v...))\n\t}\n}\n\n// Warnf implement ILogger.\nfunc (x *xormLogger) Warnf(format string, v ...any) {\n\tif x.level <= xlog.LOG_WARNING {\n\t\tx.logger.Warn().Msg(fmt.Sprintf(format, v...))\n\t}\n}\n\n// Level implement ILogger.\nfunc (x *xormLogger) Level() xlog.LogLevel {\n\treturn xlog.LOG_INFO\n}\n\n// SetLevel implement ILogger.\nfunc (x *xormLogger) SetLevel(l xlog.LogLevel) {\n\tx.level = l\n}\n\n// ShowSQL implement ILogger.\nfunc (x *xormLogger) ShowSQL(show ...bool) {\n\tif len(show) == 0 {\n\t\tx.showSQL = true\n\t\treturn\n\t}\n\tx.showSQL = show[0]\n}\n\n// IsShowSQL implement ILogger.\nfunc (x *xormLogger) IsShowSQL() bool {\n\treturn x.showSQL\n}\n"
  },
  {
    "path": "server/store/mocks/mock_Store.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"context\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockStore(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockStore {\n\tmock := &MockStore{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockStore is an autogenerated mock type for the Store type\ntype MockStore struct {\n\tmock.Mock\n}\n\ntype MockStore_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockStore) EXPECT() *MockStore_Expecter {\n\treturn &MockStore_Expecter{mock: &_m.Mock}\n}\n\n// AgentCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentCreate(agent *model.Agent) error {\n\tret := _mock.Called(agent)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok {\n\t\tr0 = returnFunc(agent)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_AgentCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentCreate'\ntype MockStore_AgentCreate_Call struct {\n\t*mock.Call\n}\n\n// AgentCreate is a helper method to define mock.On call\n//   - agent *model.Agent\nfunc (_e *MockStore_Expecter) AgentCreate(agent interface{}) *MockStore_AgentCreate_Call {\n\treturn &MockStore_AgentCreate_Call{Call: _e.mock.On(\"AgentCreate\", agent)}\n}\n\nfunc (_c *MockStore_AgentCreate_Call) Run(run func(agent *model.Agent)) *MockStore_AgentCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Agent\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Agent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentCreate_Call) Return(err error) *MockStore_AgentCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentCreate_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentDelete(agent *model.Agent) error {\n\tret := _mock.Called(agent)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok {\n\t\tr0 = returnFunc(agent)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_AgentDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentDelete'\ntype MockStore_AgentDelete_Call struct {\n\t*mock.Call\n}\n\n// AgentDelete is a helper method to define mock.On call\n//   - agent *model.Agent\nfunc (_e *MockStore_Expecter) AgentDelete(agent interface{}) *MockStore_AgentDelete_Call {\n\treturn &MockStore_AgentDelete_Call{Call: _e.mock.On(\"AgentDelete\", agent)}\n}\n\nfunc (_c *MockStore_AgentDelete_Call) Run(run func(agent *model.Agent)) *MockStore_AgentDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Agent\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Agent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentDelete_Call) Return(err error) *MockStore_AgentDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentDelete_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentFind(n int64) (*model.Agent, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentFind\")\n\t}\n\n\tvar r0 *model.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Agent, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Agent); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_AgentFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentFind'\ntype MockStore_AgentFind_Call struct {\n\t*mock.Call\n}\n\n// AgentFind is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) AgentFind(n interface{}) *MockStore_AgentFind_Call {\n\treturn &MockStore_AgentFind_Call{Call: _e.mock.On(\"AgentFind\", n)}\n}\n\nfunc (_c *MockStore_AgentFind_Call) Run(run func(n int64)) *MockStore_AgentFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentFind_Call) Return(agent *model.Agent, err error) *MockStore_AgentFind_Call {\n\t_c.Call.Return(agent, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentFind_Call) RunAndReturn(run func(n int64) (*model.Agent, error)) *MockStore_AgentFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentFindByToken provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentFindByToken(s string) (*model.Agent, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentFindByToken\")\n\t}\n\n\tvar r0 *model.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Agent, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Agent); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_AgentFindByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentFindByToken'\ntype MockStore_AgentFindByToken_Call struct {\n\t*mock.Call\n}\n\n// AgentFindByToken is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) AgentFindByToken(s interface{}) *MockStore_AgentFindByToken_Call {\n\treturn &MockStore_AgentFindByToken_Call{Call: _e.mock.On(\"AgentFindByToken\", s)}\n}\n\nfunc (_c *MockStore_AgentFindByToken_Call) Run(run func(s string)) *MockStore_AgentFindByToken_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentFindByToken_Call) Return(agent *model.Agent, err error) *MockStore_AgentFindByToken_Call {\n\t_c.Call.Return(agent, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentFindByToken_Call) RunAndReturn(run func(s string) (*model.Agent, error)) *MockStore_AgentFindByToken_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentList provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentList(p *model.ListOptions) ([]*model.Agent, error) {\n\tret := _mock.Called(p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentList\")\n\t}\n\n\tvar r0 []*model.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Agent, error)); ok {\n\t\treturn returnFunc(p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Agent); ok {\n\t\tr0 = returnFunc(p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_AgentList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentList'\ntype MockStore_AgentList_Call struct {\n\t*mock.Call\n}\n\n// AgentList is a helper method to define mock.On call\n//   - p *model.ListOptions\nfunc (_e *MockStore_Expecter) AgentList(p interface{}) *MockStore_AgentList_Call {\n\treturn &MockStore_AgentList_Call{Call: _e.mock.On(\"AgentList\", p)}\n}\n\nfunc (_c *MockStore_AgentList_Call) Run(run func(p *model.ListOptions)) *MockStore_AgentList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentList_Call) Return(agents []*model.Agent, err error) *MockStore_AgentList_Call {\n\t_c.Call.Return(agents, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.Agent, error)) *MockStore_AgentList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentListForOrg provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error) {\n\tret := _mock.Called(orgID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentListForOrg\")\n\t}\n\n\tvar r0 []*model.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Agent, error)); ok {\n\t\treturn returnFunc(orgID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Agent); ok {\n\t\tr0 = returnFunc(orgID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(orgID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_AgentListForOrg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentListForOrg'\ntype MockStore_AgentListForOrg_Call struct {\n\t*mock.Call\n}\n\n// AgentListForOrg is a helper method to define mock.On call\n//   - orgID int64\n//   - opt *model.ListOptions\nfunc (_e *MockStore_Expecter) AgentListForOrg(orgID interface{}, opt interface{}) *MockStore_AgentListForOrg_Call {\n\treturn &MockStore_AgentListForOrg_Call{Call: _e.mock.On(\"AgentListForOrg\", orgID, opt)}\n}\n\nfunc (_c *MockStore_AgentListForOrg_Call) Run(run func(orgID int64, opt *model.ListOptions)) *MockStore_AgentListForOrg_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentListForOrg_Call) Return(agents []*model.Agent, err error) *MockStore_AgentListForOrg_Call {\n\t_c.Call.Return(agents, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentListForOrg_Call) RunAndReturn(run func(orgID int64, opt *model.ListOptions) ([]*model.Agent, error)) *MockStore_AgentListForOrg_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) AgentUpdate(agent *model.Agent) error {\n\tret := _mock.Called(agent)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok {\n\t\tr0 = returnFunc(agent)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_AgentUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentUpdate'\ntype MockStore_AgentUpdate_Call struct {\n\t*mock.Call\n}\n\n// AgentUpdate is a helper method to define mock.On call\n//   - agent *model.Agent\nfunc (_e *MockStore_Expecter) AgentUpdate(agent interface{}) *MockStore_AgentUpdate_Call {\n\treturn &MockStore_AgentUpdate_Call{Call: _e.mock.On(\"AgentUpdate\", agent)}\n}\n\nfunc (_c *MockStore_AgentUpdate_Call) Run(run func(agent *model.Agent)) *MockStore_AgentUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Agent\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Agent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentUpdate_Call) Return(err error) *MockStore_AgentUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_AgentUpdate_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Close provides a mock function for the type MockStore\nfunc (_mock *MockStore) Close() error {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Close\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func() error); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'\ntype MockStore_Close_Call struct {\n\t*mock.Call\n}\n\n// Close is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) Close() *MockStore_Close_Call {\n\treturn &MockStore_Close_Call{Call: _e.mock.On(\"Close\")}\n}\n\nfunc (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ConfigPersist provides a mock function for the type MockStore\nfunc (_mock *MockStore) ConfigPersist(config *model.Config) (*model.Config, error) {\n\tret := _mock.Called(config)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ConfigPersist\")\n\t}\n\n\tvar r0 *model.Config\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Config) (*model.Config, error)); ok {\n\t\treturn returnFunc(config)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Config) *model.Config); ok {\n\t\tr0 = returnFunc(config)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Config)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Config) error); ok {\n\t\tr1 = returnFunc(config)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_ConfigPersist_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigPersist'\ntype MockStore_ConfigPersist_Call struct {\n\t*mock.Call\n}\n\n// ConfigPersist is a helper method to define mock.On call\n//   - config *model.Config\nfunc (_e *MockStore_Expecter) ConfigPersist(config interface{}) *MockStore_ConfigPersist_Call {\n\treturn &MockStore_ConfigPersist_Call{Call: _e.mock.On(\"ConfigPersist\", config)}\n}\n\nfunc (_c *MockStore_ConfigPersist_Call) Run(run func(config *model.Config)) *MockStore_ConfigPersist_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Config\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Config)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ConfigPersist_Call) Return(config1 *model.Config, err error) *MockStore_ConfigPersist_Call {\n\t_c.Call.Return(config1, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ConfigPersist_Call) RunAndReturn(run func(config *model.Config) (*model.Config, error)) *MockStore_ConfigPersist_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ConfigsForPipeline provides a mock function for the type MockStore\nfunc (_mock *MockStore) ConfigsForPipeline(pipelineID int64) ([]*model.Config, error) {\n\tret := _mock.Called(pipelineID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ConfigsForPipeline\")\n\t}\n\n\tvar r0 []*model.Config\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) ([]*model.Config, error)); ok {\n\t\treturn returnFunc(pipelineID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) []*model.Config); ok {\n\t\tr0 = returnFunc(pipelineID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Config)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(pipelineID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_ConfigsForPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigsForPipeline'\ntype MockStore_ConfigsForPipeline_Call struct {\n\t*mock.Call\n}\n\n// ConfigsForPipeline is a helper method to define mock.On call\n//   - pipelineID int64\nfunc (_e *MockStore_Expecter) ConfigsForPipeline(pipelineID interface{}) *MockStore_ConfigsForPipeline_Call {\n\treturn &MockStore_ConfigsForPipeline_Call{Call: _e.mock.On(\"ConfigsForPipeline\", pipelineID)}\n}\n\nfunc (_c *MockStore_ConfigsForPipeline_Call) Run(run func(pipelineID int64)) *MockStore_ConfigsForPipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ConfigsForPipeline_Call) Return(configs []*model.Config, err error) *MockStore_ConfigsForPipeline_Call {\n\t_c.Call.Return(configs, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ConfigsForPipeline_Call) RunAndReturn(run func(pipelineID int64) ([]*model.Config, error)) *MockStore_ConfigsForPipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CreatePipeline provides a mock function for the type MockStore\nfunc (_mock *MockStore) CreatePipeline(pipeline *model.Pipeline, steps ...*model.Step) error {\n\tvar tmpRet mock.Arguments\n\tif len(steps) > 0 {\n\t\ttmpRet = _mock.Called(pipeline, steps)\n\t} else {\n\t\ttmpRet = _mock.Called(pipeline)\n\t}\n\tret := tmpRet\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CreatePipeline\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline, ...*model.Step) error); ok {\n\t\tr0 = returnFunc(pipeline, steps...)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CreatePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatePipeline'\ntype MockStore_CreatePipeline_Call struct {\n\t*mock.Call\n}\n\n// CreatePipeline is a helper method to define mock.On call\n//   - pipeline *model.Pipeline\n//   - steps ...*model.Step\nfunc (_e *MockStore_Expecter) CreatePipeline(pipeline interface{}, steps ...interface{}) *MockStore_CreatePipeline_Call {\n\treturn &MockStore_CreatePipeline_Call{Call: _e.mock.On(\"CreatePipeline\",\n\t\tappend([]interface{}{pipeline}, steps...)...)}\n}\n\nfunc (_c *MockStore_CreatePipeline_Call) Run(run func(pipeline *model.Pipeline, steps ...*model.Step)) *MockStore_CreatePipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Pipeline\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Pipeline)\n\t\t}\n\t\tvar arg1 []*model.Step\n\t\tvar variadicArgs []*model.Step\n\t\tif len(args) > 1 {\n\t\t\tvariadicArgs = args[1].([]*model.Step)\n\t\t}\n\t\targ1 = variadicArgs\n\t\trun(\n\t\t\targ0,\n\t\t\targ1...,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CreatePipeline_Call) Return(err error) *MockStore_CreatePipeline_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CreatePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline, steps ...*model.Step) error) *MockStore_CreatePipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CreateRedirection provides a mock function for the type MockStore\nfunc (_mock *MockStore) CreateRedirection(redirection *model.Redirection) error {\n\tret := _mock.Called(redirection)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CreateRedirection\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Redirection) error); ok {\n\t\tr0 = returnFunc(redirection)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CreateRedirection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRedirection'\ntype MockStore_CreateRedirection_Call struct {\n\t*mock.Call\n}\n\n// CreateRedirection is a helper method to define mock.On call\n//   - redirection *model.Redirection\nfunc (_e *MockStore_Expecter) CreateRedirection(redirection interface{}) *MockStore_CreateRedirection_Call {\n\treturn &MockStore_CreateRedirection_Call{Call: _e.mock.On(\"CreateRedirection\", redirection)}\n}\n\nfunc (_c *MockStore_CreateRedirection_Call) Run(run func(redirection *model.Redirection)) *MockStore_CreateRedirection_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Redirection\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Redirection)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateRedirection_Call) Return(err error) *MockStore_CreateRedirection_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateRedirection_Call) RunAndReturn(run func(redirection *model.Redirection) error) *MockStore_CreateRedirection_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CreateRepo provides a mock function for the type MockStore\nfunc (_mock *MockStore) CreateRepo(repo *model.Repo) error {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CreateRepo\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CreateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRepo'\ntype MockStore_CreateRepo_Call struct {\n\t*mock.Call\n}\n\n// CreateRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockStore_Expecter) CreateRepo(repo interface{}) *MockStore_CreateRepo_Call {\n\treturn &MockStore_CreateRepo_Call{Call: _e.mock.On(\"CreateRepo\", repo)}\n}\n\nfunc (_c *MockStore_CreateRepo_Call) Run(run func(repo *model.Repo)) *MockStore_CreateRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateRepo_Call) Return(err error) *MockStore_CreateRepo_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_CreateRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CreateUser provides a mock function for the type MockStore\nfunc (_mock *MockStore) CreateUser(user *model.User) error {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CreateUser\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) error); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser'\ntype MockStore_CreateUser_Call struct {\n\t*mock.Call\n}\n\n// CreateUser is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockStore_Expecter) CreateUser(user interface{}) *MockStore_CreateUser_Call {\n\treturn &MockStore_CreateUser_Call{Call: _e.mock.On(\"CreateUser\", user)}\n}\n\nfunc (_c *MockStore_CreateUser_Call) Run(run func(user *model.User)) *MockStore_CreateUser_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateUser_Call) Return(err error) *MockStore_CreateUser_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CreateUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_CreateUser_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronCreate(cron *model.Cron) error {\n\tret := _mock.Called(cron)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Cron) error); ok {\n\t\tr0 = returnFunc(cron)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CronCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronCreate'\ntype MockStore_CronCreate_Call struct {\n\t*mock.Call\n}\n\n// CronCreate is a helper method to define mock.On call\n//   - cron *model.Cron\nfunc (_e *MockStore_Expecter) CronCreate(cron interface{}) *MockStore_CronCreate_Call {\n\treturn &MockStore_CronCreate_Call{Call: _e.mock.On(\"CronCreate\", cron)}\n}\n\nfunc (_c *MockStore_CronCreate_Call) Run(run func(cron *model.Cron)) *MockStore_CronCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Cron\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Cron)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronCreate_Call) Return(err error) *MockStore_CronCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronCreate_Call) RunAndReturn(run func(cron *model.Cron) error) *MockStore_CronCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronDelete(repo *model.Repo, n int64) error {\n\tret := _mock.Called(repo, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) error); ok {\n\t\tr0 = returnFunc(repo, n)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CronDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronDelete'\ntype MockStore_CronDelete_Call struct {\n\t*mock.Call\n}\n\n// CronDelete is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - n int64\nfunc (_e *MockStore_Expecter) CronDelete(repo interface{}, n interface{}) *MockStore_CronDelete_Call {\n\treturn &MockStore_CronDelete_Call{Call: _e.mock.On(\"CronDelete\", repo, n)}\n}\n\nfunc (_c *MockStore_CronDelete_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_CronDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronDelete_Call) Return(err error) *MockStore_CronDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronDelete_Call) RunAndReturn(run func(repo *model.Repo, n int64) error) *MockStore_CronDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronFind(repo *model.Repo, n int64) (*model.Cron, error) {\n\tret := _mock.Called(repo, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronFind\")\n\t}\n\n\tvar r0 *model.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Cron, error)); ok {\n\t\treturn returnFunc(repo, n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) *model.Cron); ok {\n\t\tr0 = returnFunc(repo, n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, int64) error); ok {\n\t\tr1 = returnFunc(repo, n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_CronFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronFind'\ntype MockStore_CronFind_Call struct {\n\t*mock.Call\n}\n\n// CronFind is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - n int64\nfunc (_e *MockStore_Expecter) CronFind(repo interface{}, n interface{}) *MockStore_CronFind_Call {\n\treturn &MockStore_CronFind_Call{Call: _e.mock.On(\"CronFind\", repo, n)}\n}\n\nfunc (_c *MockStore_CronFind_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_CronFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronFind_Call) Return(cron *model.Cron, err error) *MockStore_CronFind_Call {\n\t_c.Call.Return(cron, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronFind_Call) RunAndReturn(run func(repo *model.Repo, n int64) (*model.Cron, error)) *MockStore_CronFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronGetLock provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronGetLock(cron *model.Cron, n int64) (bool, error) {\n\tret := _mock.Called(cron, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronGetLock\")\n\t}\n\n\tvar r0 bool\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Cron, int64) (bool, error)); ok {\n\t\treturn returnFunc(cron, n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Cron, int64) bool); ok {\n\t\tr0 = returnFunc(cron, n)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Cron, int64) error); ok {\n\t\tr1 = returnFunc(cron, n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_CronGetLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronGetLock'\ntype MockStore_CronGetLock_Call struct {\n\t*mock.Call\n}\n\n// CronGetLock is a helper method to define mock.On call\n//   - cron *model.Cron\n//   - n int64\nfunc (_e *MockStore_Expecter) CronGetLock(cron interface{}, n interface{}) *MockStore_CronGetLock_Call {\n\treturn &MockStore_CronGetLock_Call{Call: _e.mock.On(\"CronGetLock\", cron, n)}\n}\n\nfunc (_c *MockStore_CronGetLock_Call) Run(run func(cron *model.Cron, n int64)) *MockStore_CronGetLock_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Cron\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Cron)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronGetLock_Call) Return(b bool, err error) *MockStore_CronGetLock_Call {\n\t_c.Call.Return(b, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronGetLock_Call) RunAndReturn(run func(cron *model.Cron, n int64) (bool, error)) *MockStore_CronGetLock_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronList provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Cron, error) {\n\tret := _mock.Called(repo, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronList\")\n\t}\n\n\tvar r0 []*model.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Cron, error)); ok {\n\t\treturn returnFunc(repo, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Cron); ok {\n\t\tr0 = returnFunc(repo, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(repo, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_CronList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronList'\ntype MockStore_CronList_Call struct {\n\t*mock.Call\n}\n\n// CronList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) CronList(repo interface{}, listOptions interface{}) *MockStore_CronList_Call {\n\treturn &MockStore_CronList_Call{Call: _e.mock.On(\"CronList\", repo, listOptions)}\n}\n\nfunc (_c *MockStore_CronList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockStore_CronList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronList_Call) Return(crons []*model.Cron, err error) *MockStore_CronList_Call {\n\t_c.Call.Return(crons, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Cron, error)) *MockStore_CronList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronListNextExecute provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronListNextExecute(n int64, n1 int64) ([]*model.Cron, error) {\n\tret := _mock.Called(n, n1)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronListNextExecute\")\n\t}\n\n\tvar r0 []*model.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) ([]*model.Cron, error)); ok {\n\t\treturn returnFunc(n, n1)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) []*model.Cron); ok {\n\t\tr0 = returnFunc(n, n1)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(n, n1)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_CronListNextExecute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronListNextExecute'\ntype MockStore_CronListNextExecute_Call struct {\n\t*mock.Call\n}\n\n// CronListNextExecute is a helper method to define mock.On call\n//   - n int64\n//   - n1 int64\nfunc (_e *MockStore_Expecter) CronListNextExecute(n interface{}, n1 interface{}) *MockStore_CronListNextExecute_Call {\n\treturn &MockStore_CronListNextExecute_Call{Call: _e.mock.On(\"CronListNextExecute\", n, n1)}\n}\n\nfunc (_c *MockStore_CronListNextExecute_Call) Run(run func(n int64, n1 int64)) *MockStore_CronListNextExecute_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronListNextExecute_Call) Return(crons []*model.Cron, err error) *MockStore_CronListNextExecute_Call {\n\t_c.Call.Return(crons, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronListNextExecute_Call) RunAndReturn(run func(n int64, n1 int64) ([]*model.Cron, error)) *MockStore_CronListNextExecute_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) CronUpdate(repo *model.Repo, cron *model.Cron) error {\n\tret := _mock.Called(repo, cron)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Cron) error); ok {\n\t\tr0 = returnFunc(repo, cron)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_CronUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronUpdate'\ntype MockStore_CronUpdate_Call struct {\n\t*mock.Call\n}\n\n// CronUpdate is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - cron *model.Cron\nfunc (_e *MockStore_Expecter) CronUpdate(repo interface{}, cron interface{}) *MockStore_CronUpdate_Call {\n\treturn &MockStore_CronUpdate_Call{Call: _e.mock.On(\"CronUpdate\", repo, cron)}\n}\n\nfunc (_c *MockStore_CronUpdate_Call) Run(run func(repo *model.Repo, cron *model.Cron)) *MockStore_CronUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.Cron\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Cron)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_CronUpdate_Call) Return(err error) *MockStore_CronUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_CronUpdate_Call) RunAndReturn(run func(repo *model.Repo, cron *model.Cron) error) *MockStore_CronUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// DeletePipeline provides a mock function for the type MockStore\nfunc (_mock *MockStore) DeletePipeline(pipeline *model.Pipeline) error {\n\tret := _mock.Called(pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for DeletePipeline\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline) error); ok {\n\t\tr0 = returnFunc(pipeline)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_DeletePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeletePipeline'\ntype MockStore_DeletePipeline_Call struct {\n\t*mock.Call\n}\n\n// DeletePipeline is a helper method to define mock.On call\n//   - pipeline *model.Pipeline\nfunc (_e *MockStore_Expecter) DeletePipeline(pipeline interface{}) *MockStore_DeletePipeline_Call {\n\treturn &MockStore_DeletePipeline_Call{Call: _e.mock.On(\"DeletePipeline\", pipeline)}\n}\n\nfunc (_c *MockStore_DeletePipeline_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_DeletePipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Pipeline\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Pipeline)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_DeletePipeline_Call) Return(err error) *MockStore_DeletePipeline_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_DeletePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline) error) *MockStore_DeletePipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// DeleteRepo provides a mock function for the type MockStore\nfunc (_mock *MockStore) DeleteRepo(repo *model.Repo) error {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for DeleteRepo\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_DeleteRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepo'\ntype MockStore_DeleteRepo_Call struct {\n\t*mock.Call\n}\n\n// DeleteRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockStore_Expecter) DeleteRepo(repo interface{}) *MockStore_DeleteRepo_Call {\n\treturn &MockStore_DeleteRepo_Call{Call: _e.mock.On(\"DeleteRepo\", repo)}\n}\n\nfunc (_c *MockStore_DeleteRepo_Call) Run(run func(repo *model.Repo)) *MockStore_DeleteRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_DeleteRepo_Call) Return(err error) *MockStore_DeleteRepo_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_DeleteRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_DeleteRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// DeleteUser provides a mock function for the type MockStore\nfunc (_mock *MockStore) DeleteUser(user *model.User) error {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for DeleteUser\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) error); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_DeleteUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUser'\ntype MockStore_DeleteUser_Call struct {\n\t*mock.Call\n}\n\n// DeleteUser is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockStore_Expecter) DeleteUser(user interface{}) *MockStore_DeleteUser_Call {\n\treturn &MockStore_DeleteUser_Call{Call: _e.mock.On(\"DeleteUser\", user)}\n}\n\nfunc (_c *MockStore_DeleteUser_Call) Run(run func(user *model.User)) *MockStore_DeleteUser_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_DeleteUser_Call) Return(err error) *MockStore_DeleteUser_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_DeleteUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_DeleteUser_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) ForgeCreate(forge *model.Forge) error {\n\tret := _mock.Called(forge)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok {\n\t\tr0 = returnFunc(forge)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_ForgeCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeCreate'\ntype MockStore_ForgeCreate_Call struct {\n\t*mock.Call\n}\n\n// ForgeCreate is a helper method to define mock.On call\n//   - forge *model.Forge\nfunc (_e *MockStore_Expecter) ForgeCreate(forge interface{}) *MockStore_ForgeCreate_Call {\n\treturn &MockStore_ForgeCreate_Call{Call: _e.mock.On(\"ForgeCreate\", forge)}\n}\n\nfunc (_c *MockStore_ForgeCreate_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Forge\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Forge)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeCreate_Call) Return(err error) *MockStore_ForgeCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeCreate_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) ForgeDelete(forge *model.Forge) error {\n\tret := _mock.Called(forge)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok {\n\t\tr0 = returnFunc(forge)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_ForgeDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeDelete'\ntype MockStore_ForgeDelete_Call struct {\n\t*mock.Call\n}\n\n// ForgeDelete is a helper method to define mock.On call\n//   - forge *model.Forge\nfunc (_e *MockStore_Expecter) ForgeDelete(forge interface{}) *MockStore_ForgeDelete_Call {\n\treturn &MockStore_ForgeDelete_Call{Call: _e.mock.On(\"ForgeDelete\", forge)}\n}\n\nfunc (_c *MockStore_ForgeDelete_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Forge\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Forge)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeDelete_Call) Return(err error) *MockStore_ForgeDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeDelete_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeGet provides a mock function for the type MockStore\nfunc (_mock *MockStore) ForgeGet(n int64) (*model.Forge, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeGet\")\n\t}\n\n\tvar r0 *model.Forge\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Forge, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Forge); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Forge)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_ForgeGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeGet'\ntype MockStore_ForgeGet_Call struct {\n\t*mock.Call\n}\n\n// ForgeGet is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) ForgeGet(n interface{}) *MockStore_ForgeGet_Call {\n\treturn &MockStore_ForgeGet_Call{Call: _e.mock.On(\"ForgeGet\", n)}\n}\n\nfunc (_c *MockStore_ForgeGet_Call) Run(run func(n int64)) *MockStore_ForgeGet_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeGet_Call) Return(forge *model.Forge, err error) *MockStore_ForgeGet_Call {\n\t_c.Call.Return(forge, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeGet_Call) RunAndReturn(run func(n int64) (*model.Forge, error)) *MockStore_ForgeGet_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeList provides a mock function for the type MockStore\nfunc (_mock *MockStore) ForgeList(p *model.ListOptions) ([]*model.Forge, error) {\n\tret := _mock.Called(p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeList\")\n\t}\n\n\tvar r0 []*model.Forge\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Forge, error)); ok {\n\t\treturn returnFunc(p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Forge); ok {\n\t\tr0 = returnFunc(p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Forge)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_ForgeList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeList'\ntype MockStore_ForgeList_Call struct {\n\t*mock.Call\n}\n\n// ForgeList is a helper method to define mock.On call\n//   - p *model.ListOptions\nfunc (_e *MockStore_Expecter) ForgeList(p interface{}) *MockStore_ForgeList_Call {\n\treturn &MockStore_ForgeList_Call{Call: _e.mock.On(\"ForgeList\", p)}\n}\n\nfunc (_c *MockStore_ForgeList_Call) Run(run func(p *model.ListOptions)) *MockStore_ForgeList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeList_Call) Return(forges []*model.Forge, err error) *MockStore_ForgeList_Call {\n\t_c.Call.Return(forges, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.Forge, error)) *MockStore_ForgeList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ForgeUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) ForgeUpdate(forge *model.Forge) error {\n\tret := _mock.Called(forge)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ForgeUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok {\n\t\tr0 = returnFunc(forge)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_ForgeUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeUpdate'\ntype MockStore_ForgeUpdate_Call struct {\n\t*mock.Call\n}\n\n// ForgeUpdate is a helper method to define mock.On call\n//   - forge *model.Forge\nfunc (_e *MockStore_Expecter) ForgeUpdate(forge interface{}) *MockStore_ForgeUpdate_Call {\n\treturn &MockStore_ForgeUpdate_Call{Call: _e.mock.On(\"ForgeUpdate\", forge)}\n}\n\nfunc (_c *MockStore_ForgeUpdate_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Forge\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Forge)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeUpdate_Call) Return(err error) *MockStore_ForgeUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ForgeUpdate_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetActivePipelineList provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error) {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetActivePipelineList\")\n\t}\n\n\tvar r0 []*model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) ([]*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) []*model.Pipeline); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok {\n\t\tr1 = returnFunc(repo)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetActivePipelineList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActivePipelineList'\ntype MockStore_GetActivePipelineList_Call struct {\n\t*mock.Call\n}\n\n// GetActivePipelineList is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockStore_Expecter) GetActivePipelineList(repo interface{}) *MockStore_GetActivePipelineList_Call {\n\treturn &MockStore_GetActivePipelineList_Call{Call: _e.mock.On(\"GetActivePipelineList\", repo)}\n}\n\nfunc (_c *MockStore_GetActivePipelineList_Call) Run(run func(repo *model.Repo)) *MockStore_GetActivePipelineList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetActivePipelineList_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetActivePipelineList_Call {\n\t_c.Call.Return(pipelines, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetActivePipelineList_Call) RunAndReturn(run func(repo *model.Repo) ([]*model.Pipeline, error)) *MockStore_GetActivePipelineList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipeline provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipeline(n int64) (*model.Pipeline, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipeline\")\n\t}\n\n\tvar r0 *model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Pipeline, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Pipeline); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipeline'\ntype MockStore_GetPipeline_Call struct {\n\t*mock.Call\n}\n\n// GetPipeline is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) GetPipeline(n interface{}) *MockStore_GetPipeline_Call {\n\treturn &MockStore_GetPipeline_Call{Call: _e.mock.On(\"GetPipeline\", n)}\n}\n\nfunc (_c *MockStore_GetPipeline_Call) Run(run func(n int64)) *MockStore_GetPipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipeline_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipeline_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipeline_Call) RunAndReturn(run func(n int64) (*model.Pipeline, error)) *MockStore_GetPipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineBadge provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineBadge(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error) {\n\tret := _mock.Called(repo, s, webhookEvents)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineBadge\")\n\t}\n\n\tvar r0 *model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo, s, webhookEvents)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) *model.Pipeline); ok {\n\t\tr0 = returnFunc(repo, s, webhookEvents)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string, []model.WebhookEvent) error); ok {\n\t\tr1 = returnFunc(repo, s, webhookEvents)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineBadge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineBadge'\ntype MockStore_GetPipelineBadge_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineBadge is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\n//   - webhookEvents []model.WebhookEvent\nfunc (_e *MockStore_Expecter) GetPipelineBadge(repo interface{}, s interface{}, webhookEvents interface{}) *MockStore_GetPipelineBadge_Call {\n\treturn &MockStore_GetPipelineBadge_Call{Call: _e.mock.On(\"GetPipelineBadge\", repo, s, webhookEvents)}\n}\n\nfunc (_c *MockStore_GetPipelineBadge_Call) Run(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent)) *MockStore_GetPipelineBadge_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 []model.WebhookEvent\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].([]model.WebhookEvent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineBadge_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineBadge_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineBadge_Call) RunAndReturn(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error)) *MockStore_GetPipelineBadge_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineCount provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineCount() (int64, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineCount\")\n\t}\n\n\tvar r0 int64\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (int64, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() int64); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(int64)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineCount'\ntype MockStore_GetPipelineCount_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineCount is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) GetPipelineCount() *MockStore_GetPipelineCount_Call {\n\treturn &MockStore_GetPipelineCount_Call{Call: _e.mock.On(\"GetPipelineCount\")}\n}\n\nfunc (_c *MockStore_GetPipelineCount_Call) Run(run func()) *MockStore_GetPipelineCount_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineCount_Call) Return(n int64, err error) *MockStore_GetPipelineCount_Call {\n\t_c.Call.Return(n, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetPipelineCount_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineLastBefore provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineLastBefore(repo *model.Repo, s string, n int64) (*model.Pipeline, error) {\n\tret := _mock.Called(repo, s, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineLastBefore\")\n\t}\n\n\tvar r0 *model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string, int64) (*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo, s, n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string, int64) *model.Pipeline); ok {\n\t\tr0 = returnFunc(repo, s, n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string, int64) error); ok {\n\t\tr1 = returnFunc(repo, s, n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineLastBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineLastBefore'\ntype MockStore_GetPipelineLastBefore_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineLastBefore is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\n//   - n int64\nfunc (_e *MockStore_Expecter) GetPipelineLastBefore(repo interface{}, s interface{}, n interface{}) *MockStore_GetPipelineLastBefore_Call {\n\treturn &MockStore_GetPipelineLastBefore_Call{Call: _e.mock.On(\"GetPipelineLastBefore\", repo, s, n)}\n}\n\nfunc (_c *MockStore_GetPipelineLastBefore_Call) Run(run func(repo *model.Repo, s string, n int64)) *MockStore_GetPipelineLastBefore_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\tvar arg2 int64\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineLastBefore_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineLastBefore_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineLastBefore_Call) RunAndReturn(run func(repo *model.Repo, s string, n int64) (*model.Pipeline, error)) *MockStore_GetPipelineLastBefore_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineLastByBranch provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineLastByBranch(repo *model.Repo, s string) (*model.Pipeline, error) {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineLastByBranch\")\n\t}\n\n\tvar r0 *model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Pipeline); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok {\n\t\tr1 = returnFunc(repo, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineLastByBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineLastByBranch'\ntype MockStore_GetPipelineLastByBranch_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineLastByBranch is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockStore_Expecter) GetPipelineLastByBranch(repo interface{}, s interface{}) *MockStore_GetPipelineLastByBranch_Call {\n\treturn &MockStore_GetPipelineLastByBranch_Call{Call: _e.mock.On(\"GetPipelineLastByBranch\", repo, s)}\n}\n\nfunc (_c *MockStore_GetPipelineLastByBranch_Call) Run(run func(repo *model.Repo, s string)) *MockStore_GetPipelineLastByBranch_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineLastByBranch_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineLastByBranch_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineLastByBranch_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Pipeline, error)) *MockStore_GetPipelineLastByBranch_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineList provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineList(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter) ([]*model.Pipeline, error) {\n\tret := _mock.Called(repo, listOptions, pipelineFilter)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineList\")\n\t}\n\n\tvar r0 []*model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) ([]*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo, listOptions, pipelineFilter)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) []*model.Pipeline); ok {\n\t\tr0 = returnFunc(repo, listOptions, pipelineFilter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) error); ok {\n\t\tr1 = returnFunc(repo, listOptions, pipelineFilter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineList'\ntype MockStore_GetPipelineList_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - listOptions *model.ListOptions\n//   - pipelineFilter *model.PipelineFilter\nfunc (_e *MockStore_Expecter) GetPipelineList(repo interface{}, listOptions interface{}, pipelineFilter interface{}) *MockStore_GetPipelineList_Call {\n\treturn &MockStore_GetPipelineList_Call{Call: _e.mock.On(\"GetPipelineList\", repo, listOptions, pipelineFilter)}\n}\n\nfunc (_c *MockStore_GetPipelineList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter)) *MockStore_GetPipelineList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\tvar arg2 *model.PipelineFilter\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.PipelineFilter)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineList_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetPipelineList_Call {\n\t_c.Call.Return(pipelines, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter) ([]*model.Pipeline, error)) *MockStore_GetPipelineList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineNumber provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineNumber(repo *model.Repo, n int64) (*model.Pipeline, error) {\n\tret := _mock.Called(repo, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineNumber\")\n\t}\n\n\tvar r0 *model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Pipeline, error)); ok {\n\t\treturn returnFunc(repo, n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) *model.Pipeline); ok {\n\t\tr0 = returnFunc(repo, n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, int64) error); ok {\n\t\tr1 = returnFunc(repo, n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineNumber_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineNumber'\ntype MockStore_GetPipelineNumber_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineNumber is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - n int64\nfunc (_e *MockStore_Expecter) GetPipelineNumber(repo interface{}, n interface{}) *MockStore_GetPipelineNumber_Call {\n\treturn &MockStore_GetPipelineNumber_Call{Call: _e.mock.On(\"GetPipelineNumber\", repo, n)}\n}\n\nfunc (_c *MockStore_GetPipelineNumber_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_GetPipelineNumber_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineNumber_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineNumber_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineNumber_Call) RunAndReturn(run func(repo *model.Repo, n int64) (*model.Pipeline, error)) *MockStore_GetPipelineNumber_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetPipelineQueue provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetPipelineQueue() ([]*model.Feed, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetPipelineQueue\")\n\t}\n\n\tvar r0 []*model.Feed\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*model.Feed, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*model.Feed); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Feed)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetPipelineQueue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineQueue'\ntype MockStore_GetPipelineQueue_Call struct {\n\t*mock.Call\n}\n\n// GetPipelineQueue is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) GetPipelineQueue() *MockStore_GetPipelineQueue_Call {\n\treturn &MockStore_GetPipelineQueue_Call{Call: _e.mock.On(\"GetPipelineQueue\")}\n}\n\nfunc (_c *MockStore_GetPipelineQueue_Call) Run(run func()) *MockStore_GetPipelineQueue_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineQueue_Call) Return(feeds []*model.Feed, err error) *MockStore_GetPipelineQueue_Call {\n\t_c.Call.Return(feeds, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetPipelineQueue_Call) RunAndReturn(run func() ([]*model.Feed, error)) *MockStore_GetPipelineQueue_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepo provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepo(n int64) (*model.Repo, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepo\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Repo, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Repo); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo'\ntype MockStore_GetRepo_Call struct {\n\t*mock.Call\n}\n\n// GetRepo is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) GetRepo(n interface{}) *MockStore_GetRepo_Call {\n\treturn &MockStore_GetRepo_Call{Call: _e.mock.On(\"GetRepo\", n)}\n}\n\nfunc (_c *MockStore_GetRepo_Call) Run(run func(n int64)) *MockStore_GetRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepo_Call) Return(repo *model.Repo, err error) *MockStore_GetRepo_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepo_Call) RunAndReturn(run func(n int64) (*model.Repo, error)) *MockStore_GetRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepoCount provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepoCount() (int64, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepoCount\")\n\t}\n\n\tvar r0 int64\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (int64, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() int64); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(int64)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepoCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoCount'\ntype MockStore_GetRepoCount_Call struct {\n\t*mock.Call\n}\n\n// GetRepoCount is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) GetRepoCount() *MockStore_GetRepoCount_Call {\n\treturn &MockStore_GetRepoCount_Call{Call: _e.mock.On(\"GetRepoCount\")}\n}\n\nfunc (_c *MockStore_GetRepoCount_Call) Run(run func()) *MockStore_GetRepoCount_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoCount_Call) Return(n int64, err error) *MockStore_GetRepoCount_Call {\n\t_c.Call.Return(n, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetRepoCount_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepoForgeID provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepoForgeID(n int64, forgeRemoteID model.ForgeRemoteID) (*model.Repo, error) {\n\tret := _mock.Called(n, forgeRemoteID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepoForgeID\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) (*model.Repo, error)); ok {\n\t\treturn returnFunc(n, forgeRemoteID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) *model.Repo); ok {\n\t\tr0 = returnFunc(n, forgeRemoteID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID) error); ok {\n\t\tr1 = returnFunc(n, forgeRemoteID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepoForgeID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoForgeID'\ntype MockStore_GetRepoForgeID_Call struct {\n\t*mock.Call\n}\n\n// GetRepoForgeID is a helper method to define mock.On call\n//   - n int64\n//   - forgeRemoteID model.ForgeRemoteID\nfunc (_e *MockStore_Expecter) GetRepoForgeID(n interface{}, forgeRemoteID interface{}) *MockStore_GetRepoForgeID_Call {\n\treturn &MockStore_GetRepoForgeID_Call{Call: _e.mock.On(\"GetRepoForgeID\", n, forgeRemoteID)}\n}\n\nfunc (_c *MockStore_GetRepoForgeID_Call) Run(run func(n int64, forgeRemoteID model.ForgeRemoteID)) *MockStore_GetRepoForgeID_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 model.ForgeRemoteID\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(model.ForgeRemoteID)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoForgeID_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoForgeID_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoForgeID_Call) RunAndReturn(run func(n int64, forgeRemoteID model.ForgeRemoteID) (*model.Repo, error)) *MockStore_GetRepoForgeID_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepoLatestPipelines provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepoLatestPipelines(int64s []int64) ([]*model.Pipeline, error) {\n\tret := _mock.Called(int64s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepoLatestPipelines\")\n\t}\n\n\tvar r0 []*model.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func([]int64) ([]*model.Pipeline, error)); ok {\n\t\treturn returnFunc(int64s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func([]int64) []*model.Pipeline); ok {\n\t\tr0 = returnFunc(int64s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func([]int64) error); ok {\n\t\tr1 = returnFunc(int64s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepoLatestPipelines_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoLatestPipelines'\ntype MockStore_GetRepoLatestPipelines_Call struct {\n\t*mock.Call\n}\n\n// GetRepoLatestPipelines is a helper method to define mock.On call\n//   - int64s []int64\nfunc (_e *MockStore_Expecter) GetRepoLatestPipelines(int64s interface{}) *MockStore_GetRepoLatestPipelines_Call {\n\treturn &MockStore_GetRepoLatestPipelines_Call{Call: _e.mock.On(\"GetRepoLatestPipelines\", int64s)}\n}\n\nfunc (_c *MockStore_GetRepoLatestPipelines_Call) Run(run func(int64s []int64)) *MockStore_GetRepoLatestPipelines_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 []int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].([]int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoLatestPipelines_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetRepoLatestPipelines_Call {\n\t_c.Call.Return(pipelines, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoLatestPipelines_Call) RunAndReturn(run func(int64s []int64) ([]*model.Pipeline, error)) *MockStore_GetRepoLatestPipelines_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepoName provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepoName(s string) (*model.Repo, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepoName\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Repo, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Repo); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepoName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoName'\ntype MockStore_GetRepoName_Call struct {\n\t*mock.Call\n}\n\n// GetRepoName is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) GetRepoName(s interface{}) *MockStore_GetRepoName_Call {\n\treturn &MockStore_GetRepoName_Call{Call: _e.mock.On(\"GetRepoName\", s)}\n}\n\nfunc (_c *MockStore_GetRepoName_Call) Run(run func(s string)) *MockStore_GetRepoName_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoName_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoName_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoName_Call) RunAndReturn(run func(s string) (*model.Repo, error)) *MockStore_GetRepoName_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetRepoNameFallback provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) {\n\tret := _mock.Called(forgeID, remoteID, fullName)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetRepoNameFallback\")\n\t}\n\n\tvar r0 *model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID, string) (*model.Repo, error)); ok {\n\t\treturn returnFunc(forgeID, remoteID, fullName)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID, string) *model.Repo); ok {\n\t\tr0 = returnFunc(forgeID, remoteID, fullName)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID, string) error); ok {\n\t\tr1 = returnFunc(forgeID, remoteID, fullName)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetRepoNameFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoNameFallback'\ntype MockStore_GetRepoNameFallback_Call struct {\n\t*mock.Call\n}\n\n// GetRepoNameFallback is a helper method to define mock.On call\n//   - forgeID int64\n//   - remoteID model.ForgeRemoteID\n//   - fullName string\nfunc (_e *MockStore_Expecter) GetRepoNameFallback(forgeID interface{}, remoteID interface{}, fullName interface{}) *MockStore_GetRepoNameFallback_Call {\n\treturn &MockStore_GetRepoNameFallback_Call{Call: _e.mock.On(\"GetRepoNameFallback\", forgeID, remoteID, fullName)}\n}\n\nfunc (_c *MockStore_GetRepoNameFallback_Call) Run(run func(forgeID int64, remoteID model.ForgeRemoteID, fullName string)) *MockStore_GetRepoNameFallback_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 model.ForgeRemoteID\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(model.ForgeRemoteID)\n\t\t}\n\t\tvar arg2 string\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoNameFallback_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoNameFallback_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetRepoNameFallback_Call) RunAndReturn(run func(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error)) *MockStore_GetRepoNameFallback_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetUser provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetUser(n int64) (*model.User, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetUser\")\n\t}\n\n\tvar r0 *model.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.User, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.User); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUser'\ntype MockStore_GetUser_Call struct {\n\t*mock.Call\n}\n\n// GetUser is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) GetUser(n interface{}) *MockStore_GetUser_Call {\n\treturn &MockStore_GetUser_Call{Call: _e.mock.On(\"GetUser\", n)}\n}\n\nfunc (_c *MockStore_GetUser_Call) Run(run func(n int64)) *MockStore_GetUser_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUser_Call) Return(user *model.User, err error) *MockStore_GetUser_Call {\n\t_c.Call.Return(user, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUser_Call) RunAndReturn(run func(n int64) (*model.User, error)) *MockStore_GetUser_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetUserByLogin provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetUserByLogin(n int64, s string) (*model.User, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetUserByLogin\")\n\t}\n\n\tvar r0 *model.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*model.User, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *model.User); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetUserByLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByLogin'\ntype MockStore_GetUserByLogin_Call struct {\n\t*mock.Call\n}\n\n// GetUserByLogin is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockStore_Expecter) GetUserByLogin(n interface{}, s interface{}) *MockStore_GetUserByLogin_Call {\n\treturn &MockStore_GetUserByLogin_Call{Call: _e.mock.On(\"GetUserByLogin\", n, s)}\n}\n\nfunc (_c *MockStore_GetUserByLogin_Call) Run(run func(n int64, s string)) *MockStore_GetUserByLogin_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserByLogin_Call) Return(user *model.User, err error) *MockStore_GetUserByLogin_Call {\n\t_c.Call.Return(user, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserByLogin_Call) RunAndReturn(run func(n int64, s string) (*model.User, error)) *MockStore_GetUserByLogin_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetUserByRemoteID provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetUserByRemoteID(n int64, forgeRemoteID model.ForgeRemoteID) (*model.User, error) {\n\tret := _mock.Called(n, forgeRemoteID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetUserByRemoteID\")\n\t}\n\n\tvar r0 *model.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) (*model.User, error)); ok {\n\t\treturn returnFunc(n, forgeRemoteID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) *model.User); ok {\n\t\tr0 = returnFunc(n, forgeRemoteID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID) error); ok {\n\t\tr1 = returnFunc(n, forgeRemoteID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetUserByRemoteID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByRemoteID'\ntype MockStore_GetUserByRemoteID_Call struct {\n\t*mock.Call\n}\n\n// GetUserByRemoteID is a helper method to define mock.On call\n//   - n int64\n//   - forgeRemoteID model.ForgeRemoteID\nfunc (_e *MockStore_Expecter) GetUserByRemoteID(n interface{}, forgeRemoteID interface{}) *MockStore_GetUserByRemoteID_Call {\n\treturn &MockStore_GetUserByRemoteID_Call{Call: _e.mock.On(\"GetUserByRemoteID\", n, forgeRemoteID)}\n}\n\nfunc (_c *MockStore_GetUserByRemoteID_Call) Run(run func(n int64, forgeRemoteID model.ForgeRemoteID)) *MockStore_GetUserByRemoteID_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 model.ForgeRemoteID\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(model.ForgeRemoteID)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserByRemoteID_Call) Return(user *model.User, err error) *MockStore_GetUserByRemoteID_Call {\n\t_c.Call.Return(user, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserByRemoteID_Call) RunAndReturn(run func(n int64, forgeRemoteID model.ForgeRemoteID) (*model.User, error)) *MockStore_GetUserByRemoteID_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetUserCount provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetUserCount() (int64, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetUserCount\")\n\t}\n\n\tvar r0 int64\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (int64, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() int64); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Get(0).(int64)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetUserCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserCount'\ntype MockStore_GetUserCount_Call struct {\n\t*mock.Call\n}\n\n// GetUserCount is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) GetUserCount() *MockStore_GetUserCount_Call {\n\treturn &MockStore_GetUserCount_Call{Call: _e.mock.On(\"GetUserCount\")}\n}\n\nfunc (_c *MockStore_GetUserCount_Call) Run(run func()) *MockStore_GetUserCount_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserCount_Call) Return(n int64, err error) *MockStore_GetUserCount_Call {\n\t_c.Call.Return(n, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetUserCount_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GetUserList provides a mock function for the type MockStore\nfunc (_mock *MockStore) GetUserList(p *model.ListOptions) ([]*model.User, error) {\n\tret := _mock.Called(p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GetUserList\")\n\t}\n\n\tvar r0 []*model.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.User, error)); ok {\n\t\treturn returnFunc(p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.User); ok {\n\t\tr0 = returnFunc(p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GetUserList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserList'\ntype MockStore_GetUserList_Call struct {\n\t*mock.Call\n}\n\n// GetUserList is a helper method to define mock.On call\n//   - p *model.ListOptions\nfunc (_e *MockStore_Expecter) GetUserList(p interface{}) *MockStore_GetUserList_Call {\n\treturn &MockStore_GetUserList_Call{Call: _e.mock.On(\"GetUserList\", p)}\n}\n\nfunc (_c *MockStore_GetUserList_Call) Run(run func(p *model.ListOptions)) *MockStore_GetUserList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserList_Call) Return(users []*model.User, err error) *MockStore_GetUserList_Call {\n\t_c.Call.Return(users, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GetUserList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.User, error)) *MockStore_GetUserList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) GlobalRegistryFind(s string) (*model.Registry, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind'\ntype MockStore_GlobalRegistryFind_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryFind is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) GlobalRegistryFind(s interface{}) *MockStore_GlobalRegistryFind_Call {\n\treturn &MockStore_GlobalRegistryFind_Call{Call: _e.mock.On(\"GlobalRegistryFind\", s)}\n}\n\nfunc (_c *MockStore_GlobalRegistryFind_Call) Run(run func(s string)) *MockStore_GlobalRegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_GlobalRegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockStore_GlobalRegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryList provides a mock function for the type MockStore\nfunc (_mock *MockStore) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList'\ntype MockStore_GlobalRegistryList_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) GlobalRegistryList(listOptions interface{}) *MockStore_GlobalRegistryList_Call {\n\treturn &MockStore_GlobalRegistryList_Call{Call: _e.mock.On(\"GlobalRegistryList\", listOptions)}\n}\n\nfunc (_c *MockStore_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_GlobalRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_GlobalRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_GlobalRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) GlobalSecretFind(s string) (*model.Secret, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Secret); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GlobalSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretFind'\ntype MockStore_GlobalSecretFind_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretFind is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) GlobalSecretFind(s interface{}) *MockStore_GlobalSecretFind_Call {\n\treturn &MockStore_GlobalSecretFind_Call{Call: _e.mock.On(\"GlobalSecretFind\", s)}\n}\n\nfunc (_c *MockStore_GlobalSecretFind_Call) Run(run func(s string)) *MockStore_GlobalSecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalSecretFind_Call) Return(secret *model.Secret, err error) *MockStore_GlobalSecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalSecretFind_Call) RunAndReturn(run func(s string) (*model.Secret, error)) *MockStore_GlobalSecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretList provides a mock function for the type MockStore\nfunc (_mock *MockStore) GlobalSecretList(listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_GlobalSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretList'\ntype MockStore_GlobalSecretList_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) GlobalSecretList(listOptions interface{}) *MockStore_GlobalSecretList_Call {\n\treturn &MockStore_GlobalSecretList_Call{Call: _e.mock.On(\"GlobalSecretList\", listOptions)}\n}\n\nfunc (_c *MockStore_GlobalSecretList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_GlobalSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalSecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_GlobalSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_GlobalSecretList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_GlobalSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// HasRedirectionForRepo provides a mock function for the type MockStore\nfunc (_mock *MockStore) HasRedirectionForRepo(n int64, s string) (bool, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for HasRedirectionForRepo\")\n\t}\n\n\tvar r0 bool\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (bool, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) bool); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tr0 = ret.Get(0).(bool)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_HasRedirectionForRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasRedirectionForRepo'\ntype MockStore_HasRedirectionForRepo_Call struct {\n\t*mock.Call\n}\n\n// HasRedirectionForRepo is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockStore_Expecter) HasRedirectionForRepo(n interface{}, s interface{}) *MockStore_HasRedirectionForRepo_Call {\n\treturn &MockStore_HasRedirectionForRepo_Call{Call: _e.mock.On(\"HasRedirectionForRepo\", n, s)}\n}\n\nfunc (_c *MockStore_HasRedirectionForRepo_Call) Run(run func(n int64, s string)) *MockStore_HasRedirectionForRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_HasRedirectionForRepo_Call) Return(b bool, err error) *MockStore_HasRedirectionForRepo_Call {\n\t_c.Call.Return(b, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_HasRedirectionForRepo_Call) RunAndReturn(run func(n int64, s string) (bool, error)) *MockStore_HasRedirectionForRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogAppend provides a mock function for the type MockStore\nfunc (_mock *MockStore) LogAppend(step *model.Step, logEntrys []*model.LogEntry) error {\n\tret := _mock.Called(step, logEntrys)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogAppend\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step, []*model.LogEntry) error); ok {\n\t\tr0 = returnFunc(step, logEntrys)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_LogAppend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogAppend'\ntype MockStore_LogAppend_Call struct {\n\t*mock.Call\n}\n\n// LogAppend is a helper method to define mock.On call\n//   - step *model.Step\n//   - logEntrys []*model.LogEntry\nfunc (_e *MockStore_Expecter) LogAppend(step interface{}, logEntrys interface{}) *MockStore_LogAppend_Call {\n\treturn &MockStore_LogAppend_Call{Call: _e.mock.On(\"LogAppend\", step, logEntrys)}\n}\n\nfunc (_c *MockStore_LogAppend_Call) Run(run func(step *model.Step, logEntrys []*model.LogEntry)) *MockStore_LogAppend_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\tvar arg1 []*model.LogEntry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]*model.LogEntry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_LogAppend_Call) Return(err error) *MockStore_LogAppend_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_LogAppend_Call) RunAndReturn(run func(step *model.Step, logEntrys []*model.LogEntry) error) *MockStore_LogAppend_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) LogDelete(step *model.Step) error {\n\tret := _mock.Called(step)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok {\n\t\tr0 = returnFunc(step)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_LogDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogDelete'\ntype MockStore_LogDelete_Call struct {\n\t*mock.Call\n}\n\n// LogDelete is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockStore_Expecter) LogDelete(step interface{}) *MockStore_LogDelete_Call {\n\treturn &MockStore_LogDelete_Call{Call: _e.mock.On(\"LogDelete\", step)}\n}\n\nfunc (_c *MockStore_LogDelete_Call) Run(run func(step *model.Step)) *MockStore_LogDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_LogDelete_Call) Return(err error) *MockStore_LogDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_LogDelete_Call) RunAndReturn(run func(step *model.Step) error) *MockStore_LogDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) LogFind(step *model.Step) ([]*model.LogEntry, error) {\n\tret := _mock.Called(step)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogFind\")\n\t}\n\n\tvar r0 []*model.LogEntry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) ([]*model.LogEntry, error)); ok {\n\t\treturn returnFunc(step)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) []*model.LogEntry); ok {\n\t\tr0 = returnFunc(step)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.LogEntry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Step) error); ok {\n\t\tr1 = returnFunc(step)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_LogFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogFind'\ntype MockStore_LogFind_Call struct {\n\t*mock.Call\n}\n\n// LogFind is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockStore_Expecter) LogFind(step interface{}) *MockStore_LogFind_Call {\n\treturn &MockStore_LogFind_Call{Call: _e.mock.On(\"LogFind\", step)}\n}\n\nfunc (_c *MockStore_LogFind_Call) Run(run func(step *model.Step)) *MockStore_LogFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_LogFind_Call) Return(logEntrys []*model.LogEntry, err error) *MockStore_LogFind_Call {\n\t_c.Call.Return(logEntrys, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_LogFind_Call) RunAndReturn(run func(step *model.Step) ([]*model.LogEntry, error)) *MockStore_LogFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Migrate provides a mock function for the type MockStore\nfunc (_mock *MockStore) Migrate(context1 context.Context, b bool) error {\n\tret := _mock.Called(context1, b)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Migrate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(context.Context, bool) error); ok {\n\t\tr0 = returnFunc(context1, b)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'\ntype MockStore_Migrate_Call struct {\n\t*mock.Call\n}\n\n// Migrate is a helper method to define mock.On call\n//   - context1 context.Context\n//   - b bool\nfunc (_e *MockStore_Expecter) Migrate(context1 interface{}, b interface{}) *MockStore_Migrate_Call {\n\treturn &MockStore_Migrate_Call{Call: _e.mock.On(\"Migrate\", context1, b)}\n}\n\nfunc (_c *MockStore_Migrate_Call) Run(run func(context1 context.Context, b bool)) *MockStore_Migrate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 context.Context\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(context.Context)\n\t\t}\n\t\tvar arg1 bool\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(bool)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_Migrate_Call) Return(err error) *MockStore_Migrate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_Migrate_Call) RunAndReturn(run func(context1 context.Context, b bool) error) *MockStore_Migrate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgCreate(org *model.Org) error {\n\tret := _mock.Called(org)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Org) error); ok {\n\t\tr0 = returnFunc(org)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_OrgCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgCreate'\ntype MockStore_OrgCreate_Call struct {\n\t*mock.Call\n}\n\n// OrgCreate is a helper method to define mock.On call\n//   - org *model.Org\nfunc (_e *MockStore_Expecter) OrgCreate(org interface{}) *MockStore_OrgCreate_Call {\n\treturn &MockStore_OrgCreate_Call{Call: _e.mock.On(\"OrgCreate\", org)}\n}\n\nfunc (_c *MockStore_OrgCreate_Call) Run(run func(org *model.Org)) *MockStore_OrgCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Org\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Org)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgCreate_Call) Return(err error) *MockStore_OrgCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgCreate_Call) RunAndReturn(run func(org *model.Org) error) *MockStore_OrgCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgDelete(n int64) error {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) error); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_OrgDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgDelete'\ntype MockStore_OrgDelete_Call struct {\n\t*mock.Call\n}\n\n// OrgDelete is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) OrgDelete(n interface{}) *MockStore_OrgDelete_Call {\n\treturn &MockStore_OrgDelete_Call{Call: _e.mock.On(\"OrgDelete\", n)}\n}\n\nfunc (_c *MockStore_OrgDelete_Call) Run(run func(n int64)) *MockStore_OrgDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgDelete_Call) Return(err error) *MockStore_OrgDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgDelete_Call) RunAndReturn(run func(n int64) error) *MockStore_OrgDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgFindByName provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgFindByName(s string, n int64) (*model.Org, error) {\n\tret := _mock.Called(s, n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgFindByName\")\n\t}\n\n\tvar r0 *model.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string, int64) (*model.Org, error)); ok {\n\t\treturn returnFunc(s, n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string, int64) *model.Org); ok {\n\t\tr0 = returnFunc(s, n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string, int64) error); ok {\n\t\tr1 = returnFunc(s, n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgFindByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgFindByName'\ntype MockStore_OrgFindByName_Call struct {\n\t*mock.Call\n}\n\n// OrgFindByName is a helper method to define mock.On call\n//   - s string\n//   - n int64\nfunc (_e *MockStore_Expecter) OrgFindByName(s interface{}, n interface{}) *MockStore_OrgFindByName_Call {\n\treturn &MockStore_OrgFindByName_Call{Call: _e.mock.On(\"OrgFindByName\", s, n)}\n}\n\nfunc (_c *MockStore_OrgFindByName_Call) Run(run func(s string, n int64)) *MockStore_OrgFindByName_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgFindByName_Call) Return(org *model.Org, err error) *MockStore_OrgFindByName_Call {\n\t_c.Call.Return(org, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgFindByName_Call) RunAndReturn(run func(s string, n int64) (*model.Org, error)) *MockStore_OrgFindByName_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgGet provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgGet(n int64) (*model.Org, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgGet\")\n\t}\n\n\tvar r0 *model.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Org, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Org); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgGet'\ntype MockStore_OrgGet_Call struct {\n\t*mock.Call\n}\n\n// OrgGet is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) OrgGet(n interface{}) *MockStore_OrgGet_Call {\n\treturn &MockStore_OrgGet_Call{Call: _e.mock.On(\"OrgGet\", n)}\n}\n\nfunc (_c *MockStore_OrgGet_Call) Run(run func(n int64)) *MockStore_OrgGet_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgGet_Call) Return(org *model.Org, err error) *MockStore_OrgGet_Call {\n\t_c.Call.Return(org, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgGet_Call) RunAndReturn(run func(n int64) (*model.Org, error)) *MockStore_OrgGet_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgList provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgList(listOptions *model.ListOptions) ([]*model.Org, error) {\n\tret := _mock.Called(listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgList\")\n\t}\n\n\tvar r0 []*model.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Org, error)); ok {\n\t\treturn returnFunc(listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Org); ok {\n\t\tr0 = returnFunc(listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok {\n\t\tr1 = returnFunc(listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgList'\ntype MockStore_OrgList_Call struct {\n\t*mock.Call\n}\n\n// OrgList is a helper method to define mock.On call\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) OrgList(listOptions interface{}) *MockStore_OrgList_Call {\n\treturn &MockStore_OrgList_Call{Call: _e.mock.On(\"OrgList\", listOptions)}\n}\n\nfunc (_c *MockStore_OrgList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_OrgList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgList_Call) Return(orgs []*model.Org, err error) *MockStore_OrgList_Call {\n\t_c.Call.Return(orgs, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Org, error)) *MockStore_OrgList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgRegistryFind(n int64, s string) (*model.Registry, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *model.Registry); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryFind'\ntype MockStore_OrgRegistryFind_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryFind is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockStore_Expecter) OrgRegistryFind(n interface{}, s interface{}) *MockStore_OrgRegistryFind_Call {\n\treturn &MockStore_OrgRegistryFind_Call{Call: _e.mock.On(\"OrgRegistryFind\", n, s)}\n}\n\nfunc (_c *MockStore_OrgRegistryFind_Call) Run(run func(n int64, s string)) *MockStore_OrgRegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_OrgRegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRegistryFind_Call) RunAndReturn(run func(n int64, s string) (*model.Registry, error)) *MockStore_OrgRegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryList provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgRegistryList(n int64, listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(n, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(n, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(n, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(n, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryList'\ntype MockStore_OrgRegistryList_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryList is a helper method to define mock.On call\n//   - n int64\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) OrgRegistryList(n interface{}, listOptions interface{}) *MockStore_OrgRegistryList_Call {\n\treturn &MockStore_OrgRegistryList_Call{Call: _e.mock.On(\"OrgRegistryList\", n, listOptions)}\n}\n\nfunc (_c *MockStore_OrgRegistryList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockStore_OrgRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_OrgRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRegistryList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_OrgRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRepoList provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgRepoList(org *model.Org, listOptions *model.ListOptions) ([]*model.Repo, error) {\n\tret := _mock.Called(org, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRepoList\")\n\t}\n\n\tvar r0 []*model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) ([]*model.Repo, error)); ok {\n\t\treturn returnFunc(org, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) []*model.Repo); ok {\n\t\tr0 = returnFunc(org, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Org, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(org, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgRepoList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRepoList'\ntype MockStore_OrgRepoList_Call struct {\n\t*mock.Call\n}\n\n// OrgRepoList is a helper method to define mock.On call\n//   - org *model.Org\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) OrgRepoList(org interface{}, listOptions interface{}) *MockStore_OrgRepoList_Call {\n\treturn &MockStore_OrgRepoList_Call{Call: _e.mock.On(\"OrgRepoList\", org, listOptions)}\n}\n\nfunc (_c *MockStore_OrgRepoList_Call) Run(run func(org *model.Org, listOptions *model.ListOptions)) *MockStore_OrgRepoList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Org\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Org)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRepoList_Call) Return(repos []*model.Repo, err error) *MockStore_OrgRepoList_Call {\n\t_c.Call.Return(repos, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgRepoList_Call) RunAndReturn(run func(org *model.Org, listOptions *model.ListOptions) ([]*model.Repo, error)) *MockStore_OrgRepoList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgSecretFind(n int64, s string) (*model.Secret, error) {\n\tret := _mock.Called(n, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(n, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *model.Secret); ok {\n\t\tr0 = returnFunc(n, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(n, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretFind'\ntype MockStore_OrgSecretFind_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretFind is a helper method to define mock.On call\n//   - n int64\n//   - s string\nfunc (_e *MockStore_Expecter) OrgSecretFind(n interface{}, s interface{}) *MockStore_OrgSecretFind_Call {\n\treturn &MockStore_OrgSecretFind_Call{Call: _e.mock.On(\"OrgSecretFind\", n, s)}\n}\n\nfunc (_c *MockStore_OrgSecretFind_Call) Run(run func(n int64, s string)) *MockStore_OrgSecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgSecretFind_Call) Return(secret *model.Secret, err error) *MockStore_OrgSecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgSecretFind_Call) RunAndReturn(run func(n int64, s string) (*model.Secret, error)) *MockStore_OrgSecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretList provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgSecretList(n int64, listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(n, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(n, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(n, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(n, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_OrgSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretList'\ntype MockStore_OrgSecretList_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretList is a helper method to define mock.On call\n//   - n int64\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) OrgSecretList(n interface{}, listOptions interface{}) *MockStore_OrgSecretList_Call {\n\treturn &MockStore_OrgSecretList_Call{Call: _e.mock.On(\"OrgSecretList\", n, listOptions)}\n}\n\nfunc (_c *MockStore_OrgSecretList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockStore_OrgSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgSecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_OrgSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgSecretList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_OrgSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) OrgUpdate(org *model.Org) error {\n\tret := _mock.Called(org)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Org) error); ok {\n\t\tr0 = returnFunc(org)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_OrgUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgUpdate'\ntype MockStore_OrgUpdate_Call struct {\n\t*mock.Call\n}\n\n// OrgUpdate is a helper method to define mock.On call\n//   - org *model.Org\nfunc (_e *MockStore_Expecter) OrgUpdate(org interface{}) *MockStore_OrgUpdate_Call {\n\treturn &MockStore_OrgUpdate_Call{Call: _e.mock.On(\"OrgUpdate\", org)}\n}\n\nfunc (_c *MockStore_OrgUpdate_Call) Run(run func(org *model.Org)) *MockStore_OrgUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Org\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Org)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgUpdate_Call) Return(err error) *MockStore_OrgUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_OrgUpdate_Call) RunAndReturn(run func(org *model.Org) error) *MockStore_OrgUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PermFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) {\n\tret := _mock.Called(user, repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PermFind\")\n\t}\n\n\tvar r0 *model.Perm\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) (*model.Perm, error)); ok {\n\t\treturn returnFunc(user, repo)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) *model.Perm); ok {\n\t\tr0 = returnFunc(user, repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Perm)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User, *model.Repo) error); ok {\n\t\tr1 = returnFunc(user, repo)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_PermFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermFind'\ntype MockStore_PermFind_Call struct {\n\t*mock.Call\n}\n\n// PermFind is a helper method to define mock.On call\n//   - user *model.User\n//   - repo *model.Repo\nfunc (_e *MockStore_Expecter) PermFind(user interface{}, repo interface{}) *MockStore_PermFind_Call {\n\treturn &MockStore_PermFind_Call{Call: _e.mock.On(\"PermFind\", user, repo)}\n}\n\nfunc (_c *MockStore_PermFind_Call) Run(run func(user *model.User, repo *model.Repo)) *MockStore_PermFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\tvar arg1 *model.Repo\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_PermFind_Call) Return(perm *model.Perm, err error) *MockStore_PermFind_Call {\n\t_c.Call.Return(perm, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_PermFind_Call) RunAndReturn(run func(user *model.User, repo *model.Repo) (*model.Perm, error)) *MockStore_PermFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PermPrune provides a mock function for the type MockStore\nfunc (_mock *MockStore) PermPrune(userID int64, keepRepoIDs []int64) error {\n\tret := _mock.Called(userID, keepRepoIDs)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PermPrune\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, []int64) error); ok {\n\t\tr0 = returnFunc(userID, keepRepoIDs)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_PermPrune_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermPrune'\ntype MockStore_PermPrune_Call struct {\n\t*mock.Call\n}\n\n// PermPrune is a helper method to define mock.On call\n//   - userID int64\n//   - keepRepoIDs []int64\nfunc (_e *MockStore_Expecter) PermPrune(userID interface{}, keepRepoIDs interface{}) *MockStore_PermPrune_Call {\n\treturn &MockStore_PermPrune_Call{Call: _e.mock.On(\"PermPrune\", userID, keepRepoIDs)}\n}\n\nfunc (_c *MockStore_PermPrune_Call) Run(run func(userID int64, keepRepoIDs []int64)) *MockStore_PermPrune_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 []int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_PermPrune_Call) Return(err error) *MockStore_PermPrune_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_PermPrune_Call) RunAndReturn(run func(userID int64, keepRepoIDs []int64) error) *MockStore_PermPrune_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PermUpsert provides a mock function for the type MockStore\nfunc (_mock *MockStore) PermUpsert(perm *model.Perm) error {\n\tret := _mock.Called(perm)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PermUpsert\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Perm) error); ok {\n\t\tr0 = returnFunc(perm)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_PermUpsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermUpsert'\ntype MockStore_PermUpsert_Call struct {\n\t*mock.Call\n}\n\n// PermUpsert is a helper method to define mock.On call\n//   - perm *model.Perm\nfunc (_e *MockStore_Expecter) PermUpsert(perm interface{}) *MockStore_PermUpsert_Call {\n\treturn &MockStore_PermUpsert_Call{Call: _e.mock.On(\"PermUpsert\", perm)}\n}\n\nfunc (_c *MockStore_PermUpsert_Call) Run(run func(perm *model.Perm)) *MockStore_PermUpsert_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Perm\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Perm)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_PermUpsert_Call) Return(err error) *MockStore_PermUpsert_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_PermUpsert_Call) RunAndReturn(run func(perm *model.Perm) error) *MockStore_PermUpsert_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Ping provides a mock function for the type MockStore\nfunc (_mock *MockStore) Ping() error {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Ping\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func() error); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_Ping_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ping'\ntype MockStore_Ping_Call struct {\n\t*mock.Call\n}\n\n// Ping is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) Ping() *MockStore_Ping_Call {\n\treturn &MockStore_Ping_Call{Call: _e.mock.On(\"Ping\")}\n}\n\nfunc (_c *MockStore_Ping_Call) Run(run func()) *MockStore_Ping_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_Ping_Call) Return(err error) *MockStore_Ping_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_Ping_Call) RunAndReturn(run func() error) *MockStore_Ping_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineConfigCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) PipelineConfigCreate(pipelineConfig *model.PipelineConfig) error {\n\tret := _mock.Called(pipelineConfig)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineConfigCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.PipelineConfig) error); ok {\n\t\tr0 = returnFunc(pipelineConfig)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_PipelineConfigCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineConfigCreate'\ntype MockStore_PipelineConfigCreate_Call struct {\n\t*mock.Call\n}\n\n// PipelineConfigCreate is a helper method to define mock.On call\n//   - pipelineConfig *model.PipelineConfig\nfunc (_e *MockStore_Expecter) PipelineConfigCreate(pipelineConfig interface{}) *MockStore_PipelineConfigCreate_Call {\n\treturn &MockStore_PipelineConfigCreate_Call{Call: _e.mock.On(\"PipelineConfigCreate\", pipelineConfig)}\n}\n\nfunc (_c *MockStore_PipelineConfigCreate_Call) Run(run func(pipelineConfig *model.PipelineConfig)) *MockStore_PipelineConfigCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.PipelineConfig\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.PipelineConfig)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_PipelineConfigCreate_Call) Return(err error) *MockStore_PipelineConfigCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_PipelineConfigCreate_Call) RunAndReturn(run func(pipelineConfig *model.PipelineConfig) error) *MockStore_PipelineConfigCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryCreate(registry *model.Registry) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_RegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryCreate'\ntype MockStore_RegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// RegistryCreate is a helper method to define mock.On call\n//   - registry *model.Registry\nfunc (_e *MockStore_Expecter) RegistryCreate(registry interface{}) *MockStore_RegistryCreate_Call {\n\treturn &MockStore_RegistryCreate_Call{Call: _e.mock.On(\"RegistryCreate\", registry)}\n}\n\nfunc (_c *MockStore_RegistryCreate_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryCreate_Call) Return(err error) *MockStore_RegistryCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryCreate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryDelete(registry *model.Registry) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_RegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryDelete'\ntype MockStore_RegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// RegistryDelete is a helper method to define mock.On call\n//   - registry *model.Registry\nfunc (_e *MockStore_Expecter) RegistryDelete(registry interface{}) *MockStore_RegistryDelete_Call {\n\treturn &MockStore_RegistryDelete_Call{Call: _e.mock.On(\"RegistryDelete\", registry)}\n}\n\nfunc (_c *MockStore_RegistryDelete_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryDelete_Call) Return(err error) *MockStore_RegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryDelete_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryFind(repo *model.Repo, s string) (*model.Registry, error) {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryFind\")\n\t}\n\n\tvar r0 *model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Registry, error)); ok {\n\t\treturn returnFunc(repo, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Registry); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok {\n\t\tr1 = returnFunc(repo, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryFind'\ntype MockStore_RegistryFind_Call struct {\n\t*mock.Call\n}\n\n// RegistryFind is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockStore_Expecter) RegistryFind(repo interface{}, s interface{}) *MockStore_RegistryFind_Call {\n\treturn &MockStore_RegistryFind_Call{Call: _e.mock.On(\"RegistryFind\", repo, s)}\n}\n\nfunc (_c *MockStore_RegistryFind_Call) Run(run func(repo *model.Repo, s string)) *MockStore_RegistryFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_RegistryFind_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Registry, error)) *MockStore_RegistryFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryList provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryList(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Registry, error) {\n\tret := _mock.Called(repo, b, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryList\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error)); ok {\n\t\treturn returnFunc(repo, b, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) []*model.Registry); ok {\n\t\tr0 = returnFunc(repo, b, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, bool, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(repo, b, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryList'\ntype MockStore_RegistryList_Call struct {\n\t*mock.Call\n}\n\n// RegistryList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - b bool\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) RegistryList(repo interface{}, b interface{}, listOptions interface{}) *MockStore_RegistryList_Call {\n\treturn &MockStore_RegistryList_Call{Call: _e.mock.On(\"RegistryList\", repo, b, listOptions)}\n}\n\nfunc (_c *MockStore_RegistryList_Call) Run(run func(repo *model.Repo, b bool, listOptions *model.ListOptions)) *MockStore_RegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 bool\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(bool)\n\t\t}\n\t\tvar arg2 *model.ListOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_RegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryList_Call) RunAndReturn(run func(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_RegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryListAll provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryListAll() ([]*model.Registry, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryListAll\")\n\t}\n\n\tvar r0 []*model.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*model.Registry, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*model.Registry); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RegistryListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryListAll'\ntype MockStore_RegistryListAll_Call struct {\n\t*mock.Call\n}\n\n// RegistryListAll is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) RegistryListAll() *MockStore_RegistryListAll_Call {\n\treturn &MockStore_RegistryListAll_Call{Call: _e.mock.On(\"RegistryListAll\")}\n}\n\nfunc (_c *MockStore_RegistryListAll_Call) Run(run func()) *MockStore_RegistryListAll_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryListAll_Call) Return(registrys []*model.Registry, err error) *MockStore_RegistryListAll_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryListAll_Call) RunAndReturn(run func() ([]*model.Registry, error)) *MockStore_RegistryListAll_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) RegistryUpdate(registry *model.Registry) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_RegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryUpdate'\ntype MockStore_RegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// RegistryUpdate is a helper method to define mock.On call\n//   - registry *model.Registry\nfunc (_e *MockStore_Expecter) RegistryUpdate(registry interface{}) *MockStore_RegistryUpdate_Call {\n\treturn &MockStore_RegistryUpdate_Call{Call: _e.mock.On(\"RegistryUpdate\", registry)}\n}\n\nfunc (_c *MockStore_RegistryUpdate_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryUpdate_Call) Return(err error) *MockStore_RegistryUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RegistryUpdate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoList provides a mock function for the type MockStore\nfunc (_mock *MockStore) RepoList(user *model.User, owned bool, active bool, filter *model.RepoFilter) ([]*model.Repo, error) {\n\tret := _mock.Called(user, owned, active, filter)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoList\")\n\t}\n\n\tvar r0 []*model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, bool, bool, *model.RepoFilter) ([]*model.Repo, error)); ok {\n\t\treturn returnFunc(user, owned, active, filter)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User, bool, bool, *model.RepoFilter) []*model.Repo); ok {\n\t\tr0 = returnFunc(user, owned, active, filter)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User, bool, bool, *model.RepoFilter) error); ok {\n\t\tr1 = returnFunc(user, owned, active, filter)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RepoList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoList'\ntype MockStore_RepoList_Call struct {\n\t*mock.Call\n}\n\n// RepoList is a helper method to define mock.On call\n//   - user *model.User\n//   - owned bool\n//   - active bool\n//   - filter *model.RepoFilter\nfunc (_e *MockStore_Expecter) RepoList(user interface{}, owned interface{}, active interface{}, filter interface{}) *MockStore_RepoList_Call {\n\treturn &MockStore_RepoList_Call{Call: _e.mock.On(\"RepoList\", user, owned, active, filter)}\n}\n\nfunc (_c *MockStore_RepoList_Call) Run(run func(user *model.User, owned bool, active bool, filter *model.RepoFilter)) *MockStore_RepoList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\tvar arg1 bool\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(bool)\n\t\t}\n\t\tvar arg2 bool\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(bool)\n\t\t}\n\t\tvar arg3 *model.RepoFilter\n\t\tif args[3] != nil {\n\t\t\targ3 = args[3].(*model.RepoFilter)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t\targ3,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoList_Call) Return(repos []*model.Repo, err error) *MockStore_RepoList_Call {\n\t_c.Call.Return(repos, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoList_Call) RunAndReturn(run func(user *model.User, owned bool, active bool, filter *model.RepoFilter) ([]*model.Repo, error)) *MockStore_RepoList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoListAll provides a mock function for the type MockStore\nfunc (_mock *MockStore) RepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error) {\n\tret := _mock.Called(active, p)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoListAll\")\n\t}\n\n\tvar r0 []*model.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(bool, *model.ListOptions) ([]*model.Repo, error)); ok {\n\t\treturn returnFunc(active, p)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(bool, *model.ListOptions) []*model.Repo); ok {\n\t\tr0 = returnFunc(active, p)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(bool, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(active, p)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RepoListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoListAll'\ntype MockStore_RepoListAll_Call struct {\n\t*mock.Call\n}\n\n// RepoListAll is a helper method to define mock.On call\n//   - active bool\n//   - p *model.ListOptions\nfunc (_e *MockStore_Expecter) RepoListAll(active interface{}, p interface{}) *MockStore_RepoListAll_Call {\n\treturn &MockStore_RepoListAll_Call{Call: _e.mock.On(\"RepoListAll\", active, p)}\n}\n\nfunc (_c *MockStore_RepoListAll_Call) Run(run func(active bool, p *model.ListOptions)) *MockStore_RepoListAll_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 bool\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(bool)\n\t\t}\n\t\tvar arg1 *model.ListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoListAll_Call) Return(repos []*model.Repo, err error) *MockStore_RepoListAll_Call {\n\t_c.Call.Return(repos, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoListAll_Call) RunAndReturn(run func(active bool, p *model.ListOptions) ([]*model.Repo, error)) *MockStore_RepoListAll_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoListLatest provides a mock function for the type MockStore\nfunc (_mock *MockStore) RepoListLatest(user *model.User) ([]*model.Feed, error) {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoListLatest\")\n\t}\n\n\tvar r0 []*model.Feed\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) ([]*model.Feed, error)); ok {\n\t\treturn returnFunc(user)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) []*model.Feed); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Feed)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User) error); ok {\n\t\tr1 = returnFunc(user)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_RepoListLatest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoListLatest'\ntype MockStore_RepoListLatest_Call struct {\n\t*mock.Call\n}\n\n// RepoListLatest is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockStore_Expecter) RepoListLatest(user interface{}) *MockStore_RepoListLatest_Call {\n\treturn &MockStore_RepoListLatest_Call{Call: _e.mock.On(\"RepoListLatest\", user)}\n}\n\nfunc (_c *MockStore_RepoListLatest_Call) Run(run func(user *model.User)) *MockStore_RepoListLatest_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoListLatest_Call) Return(feeds []*model.Feed, err error) *MockStore_RepoListLatest_Call {\n\t_c.Call.Return(feeds, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_RepoListLatest_Call) RunAndReturn(run func(user *model.User) ([]*model.Feed, error)) *MockStore_RepoListLatest_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretCreate(secret *model.Secret) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_SecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretCreate'\ntype MockStore_SecretCreate_Call struct {\n\t*mock.Call\n}\n\n// SecretCreate is a helper method to define mock.On call\n//   - secret *model.Secret\nfunc (_e *MockStore_Expecter) SecretCreate(secret interface{}) *MockStore_SecretCreate_Call {\n\treturn &MockStore_SecretCreate_Call{Call: _e.mock.On(\"SecretCreate\", secret)}\n}\n\nfunc (_c *MockStore_SecretCreate_Call) Run(run func(secret *model.Secret)) *MockStore_SecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretCreate_Call) Return(err error) *MockStore_SecretCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretCreate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretDelete(secret *model.Secret) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_SecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretDelete'\ntype MockStore_SecretDelete_Call struct {\n\t*mock.Call\n}\n\n// SecretDelete is a helper method to define mock.On call\n//   - secret *model.Secret\nfunc (_e *MockStore_Expecter) SecretDelete(secret interface{}) *MockStore_SecretDelete_Call {\n\treturn &MockStore_SecretDelete_Call{Call: _e.mock.On(\"SecretDelete\", secret)}\n}\n\nfunc (_c *MockStore_SecretDelete_Call) Run(run func(secret *model.Secret)) *MockStore_SecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretDelete_Call) Return(err error) *MockStore_SecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretDelete_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretFind(repo *model.Repo, s string) (*model.Secret, error) {\n\tret := _mock.Called(repo, s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretFind\")\n\t}\n\n\tvar r0 *model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Secret, error)); ok {\n\t\treturn returnFunc(repo, s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Secret); ok {\n\t\tr0 = returnFunc(repo, s)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok {\n\t\tr1 = returnFunc(repo, s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_SecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretFind'\ntype MockStore_SecretFind_Call struct {\n\t*mock.Call\n}\n\n// SecretFind is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - s string\nfunc (_e *MockStore_Expecter) SecretFind(repo interface{}, s interface{}) *MockStore_SecretFind_Call {\n\treturn &MockStore_SecretFind_Call{Call: _e.mock.On(\"SecretFind\", repo, s)}\n}\n\nfunc (_c *MockStore_SecretFind_Call) Run(run func(repo *model.Repo, s string)) *MockStore_SecretFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretFind_Call) Return(secret *model.Secret, err error) *MockStore_SecretFind_Call {\n\t_c.Call.Return(secret, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Secret, error)) *MockStore_SecretFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretList provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretList(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Secret, error) {\n\tret := _mock.Called(repo, b, listOptions)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretList\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) ([]*model.Secret, error)); ok {\n\t\treturn returnFunc(repo, b, listOptions)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) []*model.Secret); ok {\n\t\tr0 = returnFunc(repo, b, listOptions)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Repo, bool, *model.ListOptions) error); ok {\n\t\tr1 = returnFunc(repo, b, listOptions)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_SecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretList'\ntype MockStore_SecretList_Call struct {\n\t*mock.Call\n}\n\n// SecretList is a helper method to define mock.On call\n//   - repo *model.Repo\n//   - b bool\n//   - listOptions *model.ListOptions\nfunc (_e *MockStore_Expecter) SecretList(repo interface{}, b interface{}, listOptions interface{}) *MockStore_SecretList_Call {\n\treturn &MockStore_SecretList_Call{Call: _e.mock.On(\"SecretList\", repo, b, listOptions)}\n}\n\nfunc (_c *MockStore_SecretList_Call) Run(run func(repo *model.Repo, b bool, listOptions *model.ListOptions)) *MockStore_SecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\tvar arg1 bool\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(bool)\n\t\t}\n\t\tvar arg2 *model.ListOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(*model.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_SecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretList_Call) RunAndReturn(run func(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_SecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretListAll provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretListAll() ([]*model.Secret, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretListAll\")\n\t}\n\n\tvar r0 []*model.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*model.Secret, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*model.Secret); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_SecretListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretListAll'\ntype MockStore_SecretListAll_Call struct {\n\t*mock.Call\n}\n\n// SecretListAll is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) SecretListAll() *MockStore_SecretListAll_Call {\n\treturn &MockStore_SecretListAll_Call{Call: _e.mock.On(\"SecretListAll\")}\n}\n\nfunc (_c *MockStore_SecretListAll_Call) Run(run func()) *MockStore_SecretListAll_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretListAll_Call) Return(secrets []*model.Secret, err error) *MockStore_SecretListAll_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretListAll_Call) RunAndReturn(run func() ([]*model.Secret, error)) *MockStore_SecretListAll_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) SecretUpdate(secret *model.Secret) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_SecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretUpdate'\ntype MockStore_SecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// SecretUpdate is a helper method to define mock.On call\n//   - secret *model.Secret\nfunc (_e *MockStore_Expecter) SecretUpdate(secret interface{}) *MockStore_SecretUpdate_Call {\n\treturn &MockStore_SecretUpdate_Call{Call: _e.mock.On(\"SecretUpdate\", secret)}\n}\n\nfunc (_c *MockStore_SecretUpdate_Call) Run(run func(secret *model.Secret)) *MockStore_SecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretUpdate_Call) Return(err error) *MockStore_SecretUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_SecretUpdate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ServerConfigDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) ServerConfigDelete(s string) error {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ServerConfigDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_ServerConfigDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigDelete'\ntype MockStore_ServerConfigDelete_Call struct {\n\t*mock.Call\n}\n\n// ServerConfigDelete is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) ServerConfigDelete(s interface{}) *MockStore_ServerConfigDelete_Call {\n\treturn &MockStore_ServerConfigDelete_Call{Call: _e.mock.On(\"ServerConfigDelete\", s)}\n}\n\nfunc (_c *MockStore_ServerConfigDelete_Call) Run(run func(s string)) *MockStore_ServerConfigDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigDelete_Call) Return(err error) *MockStore_ServerConfigDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigDelete_Call) RunAndReturn(run func(s string) error) *MockStore_ServerConfigDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ServerConfigGet provides a mock function for the type MockStore\nfunc (_mock *MockStore) ServerConfigGet(s string) (string, error) {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ServerConfigGet\")\n\t}\n\n\tvar r0 string\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {\n\t\treturn returnFunc(s)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) string); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tr0 = ret.Get(0).(string)\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(s)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_ServerConfigGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigGet'\ntype MockStore_ServerConfigGet_Call struct {\n\t*mock.Call\n}\n\n// ServerConfigGet is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) ServerConfigGet(s interface{}) *MockStore_ServerConfigGet_Call {\n\treturn &MockStore_ServerConfigGet_Call{Call: _e.mock.On(\"ServerConfigGet\", s)}\n}\n\nfunc (_c *MockStore_ServerConfigGet_Call) Run(run func(s string)) *MockStore_ServerConfigGet_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigGet_Call) Return(s1 string, err error) *MockStore_ServerConfigGet_Call {\n\t_c.Call.Return(s1, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigGet_Call) RunAndReturn(run func(s string) (string, error)) *MockStore_ServerConfigGet_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// ServerConfigSet provides a mock function for the type MockStore\nfunc (_mock *MockStore) ServerConfigSet(s string, s1 string) error {\n\tret := _mock.Called(s, s1)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for ServerConfigSet\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string, string) error); ok {\n\t\tr0 = returnFunc(s, s1)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_ServerConfigSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigSet'\ntype MockStore_ServerConfigSet_Call struct {\n\t*mock.Call\n}\n\n// ServerConfigSet is a helper method to define mock.On call\n//   - s string\n//   - s1 string\nfunc (_e *MockStore_Expecter) ServerConfigSet(s interface{}, s1 interface{}) *MockStore_ServerConfigSet_Call {\n\treturn &MockStore_ServerConfigSet_Call{Call: _e.mock.On(\"ServerConfigSet\", s, s1)}\n}\n\nfunc (_c *MockStore_ServerConfigSet_Call) Run(run func(s string, s1 string)) *MockStore_ServerConfigSet_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigSet_Call) Return(err error) *MockStore_ServerConfigSet_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_ServerConfigSet_Call) RunAndReturn(run func(s string, s1 string) error) *MockStore_ServerConfigSet_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepByUUID provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepByUUID(uuid string) (*model.Step, error) {\n\tret := _mock.Called(uuid)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepByUUID\")\n\t}\n\n\tvar r0 *model.Step\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*model.Step, error)); ok {\n\t\treturn returnFunc(uuid)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *model.Step); ok {\n\t\tr0 = returnFunc(uuid)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Step)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(uuid)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_StepByUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepByUUID'\ntype MockStore_StepByUUID_Call struct {\n\t*mock.Call\n}\n\n// StepByUUID is a helper method to define mock.On call\n//   - uuid string\nfunc (_e *MockStore_Expecter) StepByUUID(uuid interface{}) *MockStore_StepByUUID_Call {\n\treturn &MockStore_StepByUUID_Call{Call: _e.mock.On(\"StepByUUID\", uuid)}\n}\n\nfunc (_c *MockStore_StepByUUID_Call) Run(run func(uuid string)) *MockStore_StepByUUID_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepByUUID_Call) Return(step *model.Step, err error) *MockStore_StepByUUID_Call {\n\t_c.Call.Return(step, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_StepByUUID_Call) RunAndReturn(run func(uuid string) (*model.Step, error)) *MockStore_StepByUUID_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepFinished provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepFinished(step *model.Step) {\n\t_mock.Called(step)\n\treturn\n}\n\n// MockStore_StepFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepFinished'\ntype MockStore_StepFinished_Call struct {\n\t*mock.Call\n}\n\n// StepFinished is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockStore_Expecter) StepFinished(step interface{}) *MockStore_StepFinished_Call {\n\treturn &MockStore_StepFinished_Call{Call: _e.mock.On(\"StepFinished\", step)}\n}\n\nfunc (_c *MockStore_StepFinished_Call) Run(run func(step *model.Step)) *MockStore_StepFinished_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepFinished_Call) Return() *MockStore_StepFinished_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockStore_StepFinished_Call) RunAndReturn(run func(step *model.Step)) *MockStore_StepFinished_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// StepList provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepList(pipelineID int64) ([]*model.Step, error) {\n\tret := _mock.Called(pipelineID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepList\")\n\t}\n\n\tvar r0 []*model.Step\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) ([]*model.Step, error)); ok {\n\t\treturn returnFunc(pipelineID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) []*model.Step); ok {\n\t\tr0 = returnFunc(pipelineID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Step)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(pipelineID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_StepList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepList'\ntype MockStore_StepList_Call struct {\n\t*mock.Call\n}\n\n// StepList is a helper method to define mock.On call\n//   - pipelineID int64\nfunc (_e *MockStore_Expecter) StepList(pipelineID interface{}) *MockStore_StepList_Call {\n\treturn &MockStore_StepList_Call{Call: _e.mock.On(\"StepList\", pipelineID)}\n}\n\nfunc (_c *MockStore_StepList_Call) Run(run func(pipelineID int64)) *MockStore_StepList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepList_Call) Return(steps []*model.Step, err error) *MockStore_StepList_Call {\n\t_c.Call.Return(steps, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_StepList_Call) RunAndReturn(run func(pipelineID int64) ([]*model.Step, error)) *MockStore_StepList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepListFromWorkflowFind provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepListFromWorkflowFind(workflow *model.Workflow) ([]*model.Step, error) {\n\tret := _mock.Called(workflow)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepListFromWorkflowFind\")\n\t}\n\n\tvar r0 []*model.Step\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Workflow) ([]*model.Step, error)); ok {\n\t\treturn returnFunc(workflow)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Workflow) []*model.Step); ok {\n\t\tr0 = returnFunc(workflow)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Step)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Workflow) error); ok {\n\t\tr1 = returnFunc(workflow)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_StepListFromWorkflowFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepListFromWorkflowFind'\ntype MockStore_StepListFromWorkflowFind_Call struct {\n\t*mock.Call\n}\n\n// StepListFromWorkflowFind is a helper method to define mock.On call\n//   - workflow *model.Workflow\nfunc (_e *MockStore_Expecter) StepListFromWorkflowFind(workflow interface{}) *MockStore_StepListFromWorkflowFind_Call {\n\treturn &MockStore_StepListFromWorkflowFind_Call{Call: _e.mock.On(\"StepListFromWorkflowFind\", workflow)}\n}\n\nfunc (_c *MockStore_StepListFromWorkflowFind_Call) Run(run func(workflow *model.Workflow)) *MockStore_StepListFromWorkflowFind_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Workflow\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Workflow)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepListFromWorkflowFind_Call) Return(steps []*model.Step, err error) *MockStore_StepListFromWorkflowFind_Call {\n\t_c.Call.Return(steps, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_StepListFromWorkflowFind_Call) RunAndReturn(run func(workflow *model.Workflow) ([]*model.Step, error)) *MockStore_StepListFromWorkflowFind_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepLoad provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepLoad(pipelineID int64, stepID int64) (*model.Step, error) {\n\tret := _mock.Called(pipelineID, stepID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepLoad\")\n\t}\n\n\tvar r0 *model.Step\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) (*model.Step, error)); ok {\n\t\treturn returnFunc(pipelineID, stepID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) *model.Step); ok {\n\t\tr0 = returnFunc(pipelineID, stepID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Step)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(pipelineID, stepID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_StepLoad_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepLoad'\ntype MockStore_StepLoad_Call struct {\n\t*mock.Call\n}\n\n// StepLoad is a helper method to define mock.On call\n//   - pipelineID int64\n//   - stepID int64\nfunc (_e *MockStore_Expecter) StepLoad(pipelineID interface{}, stepID interface{}) *MockStore_StepLoad_Call {\n\treturn &MockStore_StepLoad_Call{Call: _e.mock.On(\"StepLoad\", pipelineID, stepID)}\n}\n\nfunc (_c *MockStore_StepLoad_Call) Run(run func(pipelineID int64, stepID int64)) *MockStore_StepLoad_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepLoad_Call) Return(step *model.Step, err error) *MockStore_StepLoad_Call {\n\t_c.Call.Return(step, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_StepLoad_Call) RunAndReturn(run func(pipelineID int64, stepID int64) (*model.Step, error)) *MockStore_StepLoad_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) StepUpdate(step *model.Step) error {\n\tret := _mock.Called(step)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok {\n\t\tr0 = returnFunc(step)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_StepUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepUpdate'\ntype MockStore_StepUpdate_Call struct {\n\t*mock.Call\n}\n\n// StepUpdate is a helper method to define mock.On call\n//   - step *model.Step\nfunc (_e *MockStore_Expecter) StepUpdate(step interface{}) *MockStore_StepUpdate_Call {\n\treturn &MockStore_StepUpdate_Call{Call: _e.mock.On(\"StepUpdate\", step)}\n}\n\nfunc (_c *MockStore_StepUpdate_Call) Run(run func(step *model.Step)) *MockStore_StepUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Step\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Step)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_StepUpdate_Call) Return(err error) *MockStore_StepUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_StepUpdate_Call) RunAndReturn(run func(step *model.Step) error) *MockStore_StepUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// TaskDelete provides a mock function for the type MockStore\nfunc (_mock *MockStore) TaskDelete(s string) error {\n\tret := _mock.Called(s)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for TaskDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(s)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_TaskDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskDelete'\ntype MockStore_TaskDelete_Call struct {\n\t*mock.Call\n}\n\n// TaskDelete is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockStore_Expecter) TaskDelete(s interface{}) *MockStore_TaskDelete_Call {\n\treturn &MockStore_TaskDelete_Call{Call: _e.mock.On(\"TaskDelete\", s)}\n}\n\nfunc (_c *MockStore_TaskDelete_Call) Run(run func(s string)) *MockStore_TaskDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskDelete_Call) Return(err error) *MockStore_TaskDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskDelete_Call) RunAndReturn(run func(s string) error) *MockStore_TaskDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// TaskInsert provides a mock function for the type MockStore\nfunc (_mock *MockStore) TaskInsert(task *model.Task) error {\n\tret := _mock.Called(task)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for TaskInsert\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Task) error); ok {\n\t\tr0 = returnFunc(task)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_TaskInsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskInsert'\ntype MockStore_TaskInsert_Call struct {\n\t*mock.Call\n}\n\n// TaskInsert is a helper method to define mock.On call\n//   - task *model.Task\nfunc (_e *MockStore_Expecter) TaskInsert(task interface{}) *MockStore_TaskInsert_Call {\n\treturn &MockStore_TaskInsert_Call{Call: _e.mock.On(\"TaskInsert\", task)}\n}\n\nfunc (_c *MockStore_TaskInsert_Call) Run(run func(task *model.Task)) *MockStore_TaskInsert_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Task\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Task)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskInsert_Call) Return(err error) *MockStore_TaskInsert_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskInsert_Call) RunAndReturn(run func(task *model.Task) error) *MockStore_TaskInsert_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// TaskList provides a mock function for the type MockStore\nfunc (_mock *MockStore) TaskList() ([]*model.Task, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for TaskList\")\n\t}\n\n\tvar r0 []*model.Task\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*model.Task, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*model.Task); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Task)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_TaskList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskList'\ntype MockStore_TaskList_Call struct {\n\t*mock.Call\n}\n\n// TaskList is a helper method to define mock.On call\nfunc (_e *MockStore_Expecter) TaskList() *MockStore_TaskList_Call {\n\treturn &MockStore_TaskList_Call{Call: _e.mock.On(\"TaskList\")}\n}\n\nfunc (_c *MockStore_TaskList_Call) Run(run func()) *MockStore_TaskList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskList_Call) Return(tasks []*model.Task, err error) *MockStore_TaskList_Call {\n\t_c.Call.Return(tasks, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_TaskList_Call) RunAndReturn(run func() ([]*model.Task, error)) *MockStore_TaskList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UpdatePipeline provides a mock function for the type MockStore\nfunc (_mock *MockStore) UpdatePipeline(pipeline *model.Pipeline) error {\n\tret := _mock.Called(pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UpdatePipeline\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline) error); ok {\n\t\tr0 = returnFunc(pipeline)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_UpdatePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePipeline'\ntype MockStore_UpdatePipeline_Call struct {\n\t*mock.Call\n}\n\n// UpdatePipeline is a helper method to define mock.On call\n//   - pipeline *model.Pipeline\nfunc (_e *MockStore_Expecter) UpdatePipeline(pipeline interface{}) *MockStore_UpdatePipeline_Call {\n\treturn &MockStore_UpdatePipeline_Call{Call: _e.mock.On(\"UpdatePipeline\", pipeline)}\n}\n\nfunc (_c *MockStore_UpdatePipeline_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_UpdatePipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Pipeline\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Pipeline)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdatePipeline_Call) Return(err error) *MockStore_UpdatePipeline_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdatePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline) error) *MockStore_UpdatePipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UpdateRepo provides a mock function for the type MockStore\nfunc (_mock *MockStore) UpdateRepo(repo *model.Repo) error {\n\tret := _mock.Called(repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UpdateRepo\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok {\n\t\tr0 = returnFunc(repo)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_UpdateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepo'\ntype MockStore_UpdateRepo_Call struct {\n\t*mock.Call\n}\n\n// UpdateRepo is a helper method to define mock.On call\n//   - repo *model.Repo\nfunc (_e *MockStore_Expecter) UpdateRepo(repo interface{}) *MockStore_UpdateRepo_Call {\n\treturn &MockStore_UpdateRepo_Call{Call: _e.mock.On(\"UpdateRepo\", repo)}\n}\n\nfunc (_c *MockStore_UpdateRepo_Call) Run(run func(repo *model.Repo)) *MockStore_UpdateRepo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Repo\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Repo)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdateRepo_Call) Return(err error) *MockStore_UpdateRepo_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdateRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_UpdateRepo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UpdateUser provides a mock function for the type MockStore\nfunc (_mock *MockStore) UpdateUser(user *model.User) error {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UpdateUser\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) error); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_UpdateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUser'\ntype MockStore_UpdateUser_Call struct {\n\t*mock.Call\n}\n\n// UpdateUser is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockStore_Expecter) UpdateUser(user interface{}) *MockStore_UpdateUser_Call {\n\treturn &MockStore_UpdateUser_Call{Call: _e.mock.On(\"UpdateUser\", user)}\n}\n\nfunc (_c *MockStore_UpdateUser_Call) Run(run func(user *model.User)) *MockStore_UpdateUser_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdateUser_Call) Return(err error) *MockStore_UpdateUser_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_UpdateUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_UpdateUser_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UserFeed provides a mock function for the type MockStore\nfunc (_mock *MockStore) UserFeed(user *model.User) ([]*model.Feed, error) {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UserFeed\")\n\t}\n\n\tvar r0 []*model.Feed\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) ([]*model.Feed, error)); ok {\n\t\treturn returnFunc(user)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.User) []*model.Feed); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Feed)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.User) error); ok {\n\t\tr1 = returnFunc(user)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_UserFeed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserFeed'\ntype MockStore_UserFeed_Call struct {\n\t*mock.Call\n}\n\n// UserFeed is a helper method to define mock.On call\n//   - user *model.User\nfunc (_e *MockStore_Expecter) UserFeed(user interface{}) *MockStore_UserFeed_Call {\n\treturn &MockStore_UserFeed_Call{Call: _e.mock.On(\"UserFeed\", user)}\n}\n\nfunc (_c *MockStore_UserFeed_Call) Run(run func(user *model.User)) *MockStore_UserFeed_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_UserFeed_Call) Return(feeds []*model.Feed, err error) *MockStore_UserFeed_Call {\n\t_c.Call.Return(feeds, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_UserFeed_Call) RunAndReturn(run func(user *model.User) ([]*model.Feed, error)) *MockStore_UserFeed_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WorkflowGetTree provides a mock function for the type MockStore\nfunc (_mock *MockStore) WorkflowGetTree(pipeline *model.Pipeline) ([]*model.Workflow, error) {\n\tret := _mock.Called(pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WorkflowGetTree\")\n\t}\n\n\tvar r0 []*model.Workflow\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline) ([]*model.Workflow, error)); ok {\n\t\treturn returnFunc(pipeline)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline) []*model.Workflow); ok {\n\t\tr0 = returnFunc(pipeline)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*model.Workflow)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*model.Pipeline) error); ok {\n\t\tr1 = returnFunc(pipeline)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_WorkflowGetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowGetTree'\ntype MockStore_WorkflowGetTree_Call struct {\n\t*mock.Call\n}\n\n// WorkflowGetTree is a helper method to define mock.On call\n//   - pipeline *model.Pipeline\nfunc (_e *MockStore_Expecter) WorkflowGetTree(pipeline interface{}) *MockStore_WorkflowGetTree_Call {\n\treturn &MockStore_WorkflowGetTree_Call{Call: _e.mock.On(\"WorkflowGetTree\", pipeline)}\n}\n\nfunc (_c *MockStore_WorkflowGetTree_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_WorkflowGetTree_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Pipeline\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Pipeline)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowGetTree_Call) Return(workflows []*model.Workflow, err error) *MockStore_WorkflowGetTree_Call {\n\t_c.Call.Return(workflows, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowGetTree_Call) RunAndReturn(run func(pipeline *model.Pipeline) ([]*model.Workflow, error)) *MockStore_WorkflowGetTree_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WorkflowLoad provides a mock function for the type MockStore\nfunc (_mock *MockStore) WorkflowLoad(n int64) (*model.Workflow, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WorkflowLoad\")\n\t}\n\n\tvar r0 *model.Workflow\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*model.Workflow, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *model.Workflow); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*model.Workflow)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockStore_WorkflowLoad_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowLoad'\ntype MockStore_WorkflowLoad_Call struct {\n\t*mock.Call\n}\n\n// WorkflowLoad is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockStore_Expecter) WorkflowLoad(n interface{}) *MockStore_WorkflowLoad_Call {\n\treturn &MockStore_WorkflowLoad_Call{Call: _e.mock.On(\"WorkflowLoad\", n)}\n}\n\nfunc (_c *MockStore_WorkflowLoad_Call) Run(run func(n int64)) *MockStore_WorkflowLoad_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowLoad_Call) Return(workflow *model.Workflow, err error) *MockStore_WorkflowLoad_Call {\n\t_c.Call.Return(workflow, err)\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowLoad_Call) RunAndReturn(run func(n int64) (*model.Workflow, error)) *MockStore_WorkflowLoad_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WorkflowUpdate provides a mock function for the type MockStore\nfunc (_mock *MockStore) WorkflowUpdate(workflow *model.Workflow) error {\n\tret := _mock.Called(workflow)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WorkflowUpdate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Workflow) error); ok {\n\t\tr0 = returnFunc(workflow)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_WorkflowUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowUpdate'\ntype MockStore_WorkflowUpdate_Call struct {\n\t*mock.Call\n}\n\n// WorkflowUpdate is a helper method to define mock.On call\n//   - workflow *model.Workflow\nfunc (_e *MockStore_Expecter) WorkflowUpdate(workflow interface{}) *MockStore_WorkflowUpdate_Call {\n\treturn &MockStore_WorkflowUpdate_Call{Call: _e.mock.On(\"WorkflowUpdate\", workflow)}\n}\n\nfunc (_c *MockStore_WorkflowUpdate_Call) Run(run func(workflow *model.Workflow)) *MockStore_WorkflowUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Workflow\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Workflow)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowUpdate_Call) Return(err error) *MockStore_WorkflowUpdate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowUpdate_Call) RunAndReturn(run func(workflow *model.Workflow) error) *MockStore_WorkflowUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WorkflowsCreate provides a mock function for the type MockStore\nfunc (_mock *MockStore) WorkflowsCreate(workflows []*model.Workflow) error {\n\tret := _mock.Called(workflows)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WorkflowsCreate\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func([]*model.Workflow) error); ok {\n\t\tr0 = returnFunc(workflows)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_WorkflowsCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowsCreate'\ntype MockStore_WorkflowsCreate_Call struct {\n\t*mock.Call\n}\n\n// WorkflowsCreate is a helper method to define mock.On call\n//   - workflows []*model.Workflow\nfunc (_e *MockStore_Expecter) WorkflowsCreate(workflows interface{}) *MockStore_WorkflowsCreate_Call {\n\treturn &MockStore_WorkflowsCreate_Call{Call: _e.mock.On(\"WorkflowsCreate\", workflows)}\n}\n\nfunc (_c *MockStore_WorkflowsCreate_Call) Run(run func(workflows []*model.Workflow)) *MockStore_WorkflowsCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 []*model.Workflow\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].([]*model.Workflow)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowsCreate_Call) Return(err error) *MockStore_WorkflowsCreate_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowsCreate_Call) RunAndReturn(run func(workflows []*model.Workflow) error) *MockStore_WorkflowsCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// WorkflowsReplace provides a mock function for the type MockStore\nfunc (_mock *MockStore) WorkflowsReplace(pipeline *model.Pipeline, workflows []*model.Workflow) error {\n\tret := _mock.Called(pipeline, workflows)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for WorkflowsReplace\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(*model.Pipeline, []*model.Workflow) error); ok {\n\t\tr0 = returnFunc(pipeline, workflows)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockStore_WorkflowsReplace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowsReplace'\ntype MockStore_WorkflowsReplace_Call struct {\n\t*mock.Call\n}\n\n// WorkflowsReplace is a helper method to define mock.On call\n//   - pipeline *model.Pipeline\n//   - workflows []*model.Workflow\nfunc (_e *MockStore_Expecter) WorkflowsReplace(pipeline interface{}, workflows interface{}) *MockStore_WorkflowsReplace_Call {\n\treturn &MockStore_WorkflowsReplace_Call{Call: _e.mock.On(\"WorkflowsReplace\", pipeline, workflows)}\n}\n\nfunc (_c *MockStore_WorkflowsReplace_Call) Run(run func(pipeline *model.Pipeline, workflows []*model.Workflow)) *MockStore_WorkflowsReplace_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *model.Pipeline\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*model.Pipeline)\n\t\t}\n\t\tvar arg1 []*model.Workflow\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].([]*model.Workflow)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowsReplace_Call) Return(err error) *MockStore_WorkflowsReplace_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockStore_WorkflowsReplace_Call) RunAndReturn(run func(pipeline *model.Pipeline, workflows []*model.Workflow) error) *MockStore_WorkflowsReplace_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "server/store/store.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"context\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/model\"\n)\n\n// TODO: CreateX func should return new object to not indirect let storage change an existing object (alter ID etc...)\n\ntype Store interface {\n\t// Users\n\t// GetUser gets a user by unique ID.\n\tGetUser(int64) (*model.User, error)\n\t// GetUserByRemoteID gets a user by remote ID.\n\tGetUserByRemoteID(int64, model.ForgeRemoteID) (*model.User, error)\n\t// GetUserByLogin gets a user by its login name.\n\tGetUserByLogin(int64, string) (*model.User, error)\n\t// GetUserList gets a list of all users in the system.\n\tGetUserList(p *model.ListOptions) ([]*model.User, error)\n\t// GetUserCount gets a count of all users in the system.\n\tGetUserCount() (int64, error)\n\t// CreateUser creates a new user account.\n\tCreateUser(*model.User) error\n\t// UpdateUser updates a user account.\n\tUpdateUser(*model.User) error\n\t// DeleteUser deletes a user account.\n\tDeleteUser(*model.User) error\n\n\t// Repos\n\t// GetRepo gets a repo by unique ID.\n\tGetRepo(int64) (*model.Repo, error)\n\t// GetRepoForgeID gets a repo by its forge ID and forge remote ID.\n\tGetRepoForgeID(int64, model.ForgeRemoteID) (*model.Repo, error)\n\t// GetRepoNameFallback gets the repo by its forge ID and forge remote ID, and if this doesn't exist by its full name.\n\tGetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error)\n\t// GetRepoName gets a repo by its full name.\n\tGetRepoName(string) (*model.Repo, error)\n\t// GetRepoCount gets a count of all repositories in the system.\n\tGetRepoCount() (int64, error)\n\t// CreateRepo creates a new repository.\n\tCreateRepo(*model.Repo) error\n\t// UpdateRepo updates a user repository.\n\tUpdateRepo(*model.Repo) error\n\t// DeleteRepo deletes a user repository.\n\tDeleteRepo(*model.Repo) error\n\n\t// Redirections\n\t// CreateRedirection creates a redirection\n\tCreateRedirection(redirection *model.Redirection) error\n\t// HasRedirectionForRepo checks if there's a redirection for the given repo and full name\n\tHasRedirectionForRepo(int64, string) (bool, error)\n\n\t// Pipelines\n\t// GetPipeline gets a pipeline by unique ID.\n\tGetPipeline(int64) (*model.Pipeline, error)\n\t// GetPipelineNumber gets a pipeline by number.\n\tGetPipelineNumber(*model.Repo, int64) (*model.Pipeline, error)\n\t// GetPipelineBadge gets the last relevant pipeline for the badge.\n\tGetPipelineBadge(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error)\n\t// GetPipelineLastByBranch gets the last pipeline for the branch.\n\tGetPipelineLastByBranch(*model.Repo, string) (*model.Pipeline, error)\n\t// GetPipelineLastBefore gets the last pipeline before pipeline number N.\n\tGetPipelineLastBefore(*model.Repo, string, int64) (*model.Pipeline, error)\n\t// GetPipelineList gets a list of pipelines for the repository\n\tGetPipelineList(*model.Repo, *model.ListOptions, *model.PipelineFilter) ([]*model.Pipeline, error)\n\t// GetRepoLatestPipelines gets the latest pipelines for the given repo IDs.\n\tGetRepoLatestPipelines([]int64) ([]*model.Pipeline, error)\n\t// GetActivePipelineList gets a list of the active pipelines for the repository\n\tGetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error)\n\t// GetPipelineQueue gets a list of pipelines in queue.\n\tGetPipelineQueue() ([]*model.Feed, error)\n\t// GetPipelineCount gets a count of all pipelines in the system.\n\tGetPipelineCount() (int64, error)\n\t// CreatePipeline creates a new pipeline and steps.\n\tCreatePipeline(*model.Pipeline, ...*model.Step) error\n\t// UpdatePipeline updates a pipeline.\n\tUpdatePipeline(*model.Pipeline) error\n\t// DeletePipeline deletes a pipeline.\n\tDeletePipeline(*model.Pipeline) error\n\n\t// Feeds\n\tUserFeed(*model.User) ([]*model.Feed, error)\n\n\t// Repositories\n\tRepoList(user *model.User, owned, active bool, filter *model.RepoFilter) ([]*model.Repo, error)\n\tRepoListLatest(*model.User) ([]*model.Feed, error)\n\tRepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error)\n\n\t// Permissions\n\tPermFind(user *model.User, repo *model.Repo) (*model.Perm, error)\n\tPermUpsert(perm *model.Perm) error\n\tPermPrune(userID int64, keepRepoIDs []int64) error\n\n\t// Configs\n\tConfigsForPipeline(pipelineID int64) ([]*model.Config, error)\n\tConfigPersist(*model.Config) (*model.Config, error)\n\tPipelineConfigCreate(*model.PipelineConfig) error\n\n\t// Secrets\n\tSecretFind(*model.Repo, string) (*model.Secret, error)\n\tSecretList(*model.Repo, bool, *model.ListOptions) ([]*model.Secret, error)\n\tSecretListAll() ([]*model.Secret, error)\n\tSecretCreate(*model.Secret) error\n\tSecretUpdate(*model.Secret) error\n\tSecretDelete(*model.Secret) error\n\tOrgSecretFind(int64, string) (*model.Secret, error)\n\tOrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error)\n\tGlobalSecretFind(string) (*model.Secret, error)\n\tGlobalSecretList(*model.ListOptions) ([]*model.Secret, error)\n\n\t// Registries\n\tRegistryFind(*model.Repo, string) (*model.Registry, error)\n\tRegistryList(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error)\n\tRegistryListAll() ([]*model.Registry, error)\n\tRegistryCreate(*model.Registry) error\n\tRegistryUpdate(*model.Registry) error\n\tRegistryDelete(*model.Registry) error\n\tOrgRegistryFind(int64, string) (*model.Registry, error)\n\tOrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error)\n\tGlobalRegistryFind(string) (*model.Registry, error)\n\tGlobalRegistryList(*model.ListOptions) ([]*model.Registry, error)\n\n\t// Steps\n\tStepLoad(pipelineID, stepID int64) (*model.Step, error)\n\tStepByUUID(uuid string) (*model.Step, error)\n\tStepList(pipelineID int64) ([]*model.Step, error)\n\tStepUpdate(*model.Step) error\n\tStepListFromWorkflowFind(*model.Workflow) ([]*model.Step, error)\n\n\t// Logs\n\tLogFind(*model.Step) ([]*model.LogEntry, error)\n\tLogAppend(*model.Step, []*model.LogEntry) error\n\tLogDelete(*model.Step) error\n\tStepFinished(*model.Step)\n\n\t// Tasks\n\t// TaskList TODO: paginate & opt filter\n\tTaskList() ([]*model.Task, error)\n\tTaskInsert(*model.Task) error\n\tTaskDelete(string) error\n\n\t// ServerConfig\n\tServerConfigGet(string) (string, error)\n\tServerConfigSet(string, string) error\n\tServerConfigDelete(string) error\n\n\t// Cron\n\tCronCreate(*model.Cron) error\n\tCronFind(*model.Repo, int64) (*model.Cron, error)\n\tCronList(*model.Repo, *model.ListOptions) ([]*model.Cron, error)\n\tCronUpdate(*model.Repo, *model.Cron) error\n\tCronDelete(*model.Repo, int64) error\n\tCronListNextExecute(int64, int64) ([]*model.Cron, error)\n\tCronGetLock(*model.Cron, int64) (bool, error)\n\n\t// Forge\n\tForgeCreate(*model.Forge) error\n\tForgeGet(int64) (*model.Forge, error)\n\tForgeList(p *model.ListOptions) ([]*model.Forge, error)\n\tForgeUpdate(*model.Forge) error\n\tForgeDelete(*model.Forge) error\n\n\t// Agent\n\tAgentCreate(*model.Agent) error\n\tAgentFind(int64) (*model.Agent, error)\n\tAgentFindByToken(string) (*model.Agent, error)\n\tAgentList(p *model.ListOptions) ([]*model.Agent, error)\n\tAgentUpdate(*model.Agent) error\n\tAgentDelete(*model.Agent) error\n\tAgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error)\n\n\t// Workflow\n\tWorkflowGetTree(*model.Pipeline) ([]*model.Workflow, error)\n\tWorkflowsCreate([]*model.Workflow) error\n\tWorkflowsReplace(*model.Pipeline, []*model.Workflow) error\n\tWorkflowLoad(int64) (*model.Workflow, error)\n\tWorkflowUpdate(*model.Workflow) error\n\n\t// Org\n\tOrgCreate(*model.Org) error\n\tOrgGet(int64) (*model.Org, error)\n\tOrgFindByName(string, int64) (*model.Org, error)\n\tOrgUpdate(*model.Org) error\n\tOrgDelete(int64) error\n\tOrgList(*model.ListOptions) ([]*model.Org, error)\n\n\t// Org repos\n\tOrgRepoList(*model.Org, *model.ListOptions) ([]*model.Repo, error)\n\n\t// Store operations\n\tPing() error\n\tClose() error\n\tMigrate(context.Context, bool) error\n}\n"
  },
  {
    "path": "server/store/types/errors.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n)\n\nvar (\n\t// RecordNotExist a Get or Update could not find the requested record.\n\tErrRecordNotExist = sql.ErrNoRows\n\n\t// ErrInsertDuplicateDetected is returned when an insert fails because of unique constrains.\n\tErrInsertDuplicateDetected = errors.New(\"on insert duplicate based on constraints was detected\")\n)\n"
  },
  {
    "path": "server/web/config.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage web\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"text/template\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc Config(c *gin.Context) {\n\tuser := session.User(c)\n\n\tvar csrf string\n\tif user != nil {\n\t\tt := token.New(token.CsrfToken)\n\t\tt.Set(\"user-id\", strconv.FormatInt(user.ID, 10))\n\t\tcsrf, _ = t.Sign(user.Hash)\n\t}\n\n\tconfigData := map[string]any{\n\t\t\"user\":                        user,\n\t\t\"csrf\":                        csrf,\n\t\t\"version\":                     version.String(),\n\t\t\"skip_version_check\":          server.Config.WebUI.SkipVersionCheck,\n\t\t\"root_path\":                   server.Config.Server.RootPath,\n\t\t\"enable_swagger\":              server.Config.WebUI.EnableSwagger,\n\t\t\"user_registered_agents\":      !server.Config.Agent.DisableUserRegisteredAgentRegistration,\n\t\t\"max_pipeline_log_line_count\": server.Config.WebUI.MaxPipelineLogLineCount,\n\t}\n\n\t// default func map with json parser.\n\tfuncMap := template.FuncMap{\n\t\t\"json\": func(v any) string {\n\t\t\ta, err := json.Marshal(v)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error().Err(err).Msg(\"could not marshal JSON\")\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn string(a)\n\t\t},\n\t}\n\n\tc.Header(\"Content-Type\", \"text/javascript; charset=utf-8\")\n\ttmpl := template.Must(template.New(\"\").Funcs(funcMap).Parse(configTemplate))\n\n\tif err := tmpl.Execute(c.Writer, configData); err != nil {\n\t\tlog.Error().Err(err).Msg(\"could not execute template\")\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tc.Status(http.StatusOK)\n}\n\nconst configTemplate = `\nwindow.WOODPECKER_USER = {{ json .user }};\nwindow.WOODPECKER_CSRF = \"{{ .csrf }}\";\nwindow.WOODPECKER_VERSION = \"{{ .version }}\";\nwindow.WOODPECKER_ROOT_PATH = \"{{ .root_path }}\";\nwindow.WOODPECKER_ENABLE_SWAGGER = {{ .enable_swagger }};\nwindow.WOODPECKER_SKIP_VERSION_CHECK = {{ .skip_version_check }}\nwindow.WOODPECKER_USER_REGISTERED_AGENTS = {{ .user_registered_agents }}\nwindow.WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT = {{ .max_pipeline_log_line_count }}\n`\n"
  },
  {
    "path": "server/web/web.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage web\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/rs/zerolog/log\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/web\"\n)\n\nvar indexHTML []byte\n\ntype prefixFS struct {\n\tfs     http.FileSystem\n\tprefix string\n}\n\nfunc (f *prefixFS) Open(name string) (http.File, error) {\n\treturn f.fs.Open(strings.TrimPrefix(name, f.prefix))\n}\n\n// New returns a gin engine to serve the web frontend.\nfunc New() (*gin.Engine, error) {\n\te := gin.New()\n\tvar err error\n\tindexHTML, err = parseIndex()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trootPath := server.Config.Server.RootPath\n\n\thttpFS, err := web.HTTPFS()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tf := &prefixFS{httpFS, rootPath}\n\te.GET(rootPath+\"/favicon.svg\", redirect(server.Config.Server.RootPath+\"/favicons/favicon-light-default.svg\", http.StatusPermanentRedirect))\n\te.GET(rootPath+\"/favicons/*filepath\", serveFile(f))\n\te.GET(rootPath+\"/assets/*filepath\", handleCustomFilesAndAssets(f))\n\n\te.NoRoute(handleIndex)\n\n\treturn e, nil\n}\n\nfunc handleCustomFilesAndAssets(fs *prefixFS) func(ctx *gin.Context) {\n\tserveFileOrEmptyContent := func(w http.ResponseWriter, r *http.Request, localFileName, fileName string) {\n\t\tif len(localFileName) > 0 {\n\t\t\thttp.ServeFile(w, r, localFileName)\n\t\t} else {\n\t\t\t// prefer zero content over sending a 404 Not Found\n\t\t\thttp.ServeContent(w, r, fileName, time.Now(), bytes.NewReader([]byte{}))\n\t\t}\n\t}\n\treturn func(ctx *gin.Context) {\n\t\tswitch {\n\t\tcase strings.HasSuffix(ctx.Request.RequestURI, \"/assets/custom.js\"):\n\t\t\tserveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomJsFile, \"file.js\")\n\t\tcase strings.HasSuffix(ctx.Request.RequestURI, \"/assets/custom.css\"):\n\t\t\tserveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomCSSFile, \"file.css\")\n\t\tdefault:\n\t\t\tserveFile(fs)(ctx)\n\t\t}\n\t}\n}\n\nfunc serveFile(f *prefixFS) func(ctx *gin.Context) {\n\treturn func(ctx *gin.Context) {\n\t\tfile, err := f.Open(ctx.Request.URL.Path)\n\t\tif err != nil {\n\t\t\tcode := http.StatusInternalServerError\n\t\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tcode = http.StatusNotFound\n\t\t\t} else if errors.Is(err, fs.ErrPermission) {\n\t\t\t\tcode = http.StatusForbidden\n\t\t\t}\n\t\t\tctx.Status(code)\n\t\t\treturn\n\t\t}\n\t\tdata, err := io.ReadAll(file)\n\t\tif err != nil {\n\t\t\tctx.Status(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tvar mime string\n\t\tswitch {\n\t\tcase strings.HasSuffix(ctx.Request.URL.Path, \".js\"):\n\t\t\tmime = \"text/javascript\"\n\t\tcase strings.HasSuffix(ctx.Request.URL.Path, \".css\"):\n\t\t\tmime = \"text/css\"\n\t\tcase strings.HasSuffix(ctx.Request.URL.Path, \".png\"):\n\t\t\tmime = \"image/png\"\n\t\tcase strings.HasSuffix(ctx.Request.URL.Path, \".svg\"):\n\t\t\tmime = \"image/svg+xml\"\n\t\t}\n\t\tctx.Status(http.StatusOK)\n\t\tctx.Writer.Header().Set(\"Cache-Control\", \"public, max-age=31536000\")\n\t\tctx.Writer.Header().Del(\"Expires\")\n\t\tctx.Writer.Header().Set(\"Content-Type\", mime)\n\t\tif _, err := ctx.Writer.Write(replaceBytes(data)); err != nil {\n\t\t\tlog.Error().Err(err).Msgf(\"cannot write %s\", ctx.Request.URL.Path)\n\t\t}\n\t}\n}\n\n// redirect return gin helper to redirect a request.\nfunc redirect(location string, status ...int) func(ctx *gin.Context) {\n\treturn func(ctx *gin.Context) {\n\t\tcode := http.StatusFound\n\t\tif len(status) == 1 {\n\t\t\tcode = status[0]\n\t\t}\n\n\t\thttp.Redirect(ctx.Writer, ctx.Request, location, code)\n\t}\n}\n\nfunc handleIndex(c *gin.Context) {\n\trw := c.Writer\n\trw.Header().Set(\"Cache-Control\", \"no-cache\")\n\trw.Header().Set(\"Content-Type\", \"text/html; charset=UTF-8\")\n\trw.WriteHeader(http.StatusOK)\n\tif _, err := rw.Write(indexHTML); err != nil {\n\t\tlog.Error().Err(err).Msg(\"cannot write index.html\")\n\t}\n}\n\nfunc loadFile(path string) ([]byte, error) {\n\tdata, err := web.Lookup(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn replaceBytes(data), nil\n}\n\nfunc replaceBytes(data []byte) []byte {\n\treturn bytes.ReplaceAll(data, []byte(\"/BASE_PATH\"), []byte(server.Config.Server.RootPath))\n}\n\nfunc parseIndex() ([]byte, error) {\n\tdata, err := loadFile(\"index.html\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"cannot find index.html: %w\", err)\n\t}\n\tdata = bytes.ReplaceAll(data, []byte(\"/web-config.js\"), []byte(server.Config.Server.RootPath+\"/web-config.js\"))\n\tdata = bytes.ReplaceAll(data, []byte(\"/assets/custom.css\"), []byte(server.Config.Server.RootPath+\"/assets/custom.css\"))\n\tdata = bytes.ReplaceAll(data, []byte(\"/assets/custom.js\"), []byte(server.Config.Server.RootPath+\"/assets/custom.js\"))\n\treturn data, nil\n}\n"
  },
  {
    "path": "server/web/web_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage web\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/server\"\n)\n\nfunc Test_custom_file_returns_OK_and_empty_content_and_fitting_mimetype(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tfilesToTest := []struct {\n\t\tfileURL       string\n\t\tshortMimetype string\n\t}{\n\t\t{\n\t\t\tfileURL:       \"/assets/custom.js\",\n\t\t\tshortMimetype: \"javascript\", // using just the short version, since it depends on the go runtime/version\n\t\t},\n\t\t{\n\t\t\tfileURL:       \"/assets/custom.css\",\n\t\t\tshortMimetype: \"css\", // using just the short version, since it depends on the go runtime/version\n\t\t},\n\t}\n\n\tfor _, f := range filesToTest {\n\t\tt.Run(f.fileURL, func(t *testing.T) {\n\t\t\trequest, err := http.NewRequest(http.MethodGet, f.fileURL, nil)\n\t\t\trequest.RequestURI = f.fileURL // additional required for mocking\n\t\t\tassert.NoError(t, err)\n\n\t\t\trr := httptest.NewRecorder()\n\t\t\trouter, _ := New()\n\t\t\trouter.ServeHTTP(rr, request)\n\n\t\t\tassert.Equal(t, 200, rr.Code)\n\t\t\tassert.Equal(t, []byte(nil), rr.Body.Bytes())\n\t\t\tassert.Contains(t, rr.Header().Get(\"Content-Type\"), f.shortMimetype)\n\t\t})\n\t}\n}\n\nfunc Test_custom_file_return_actual_content(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ttemp, err := os.CreateTemp(os.TempDir(), \"data.txt\")\n\tassert.NoError(t, err)\n\t_, err = temp.Write([]byte(\"EXPECTED-DATA\"))\n\tassert.NoError(t, err)\n\terr = temp.Close()\n\tassert.NoError(t, err)\n\n\tserver.Config.Server.CustomJsFile = temp.Name()\n\tserver.Config.Server.CustomCSSFile = temp.Name()\n\n\tcustomRequestedFilesToTest := []string{\n\t\t\"/assets/custom.js\",\n\t\t\"/assets/custom.css\",\n\t}\n\n\tfor _, f := range customRequestedFilesToTest {\n\t\tt.Run(f, func(t *testing.T) {\n\t\t\trequest, err := http.NewRequest(http.MethodGet, f, nil)\n\t\t\trequest.RequestURI = f // additional required for mocking\n\t\t\tassert.NoError(t, err)\n\n\t\t\trr := httptest.NewRecorder()\n\t\t\trouter, _ := New()\n\t\t\trouter.ServeHTTP(rr, request)\n\n\t\t\tassert.Equal(t, 200, rr.Code)\n\t\t\tassert.Equal(t, []byte(\"EXPECTED-DATA\"), rr.Body.Bytes())\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "shared/constant/constant.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constant\n\nimport \"time\"\n\n// DefaultConfigOrder represent the priority in witch woodpecker search for a pipeline config by default\n// folders are indicated by supplying a trailing slash.\nvar DefaultConfigOrder = [...]string{\n\t\".woodpecker/\",\n\t\".woodpecker.yaml\",\n\t\".woodpecker.yml\",\n}\n\nconst (\n\t// DefaultClonePlugin can be changed by 'WOODPECKER_DEFAULT_CLONE_PLUGIN' at runtime.\n\t// renovate: datasource=docker depName=woodpeckerci/plugin-git\n\tDefaultClonePlugin = \"docker.io/woodpeckerci/plugin-git:2.9.0\"\n)\n\n// TrustedClonePlugins can be changed by 'WOODPECKER_PLUGINS_TRUSTED_CLONE' at runtime.\nvar TrustedClonePlugins = []string{\n\tDefaultClonePlugin,\n\t\"docker.io/woodpeckerci/plugin-git\",\n\t\"quay.io/woodpeckerci/plugin-git\",\n}\n\n// TaskTimeout is the time till a running task is counted as dead.\nvar TaskTimeout = time.Minute\n"
  },
  {
    "path": "shared/httputil/http_error.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"context\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n)\n\n// EnhanceHTTPError adds detailed context to HTTP errors to help with debugging.\nfunc EnhanceHTTPError(err error, method, endpoint string) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\t// parse url to get host information\n\tparsedURL, parseErr := url.Parse(endpoint)\n\tvar host string\n\tif parseErr == nil {\n\t\thost = parsedURL.Host\n\t} else {\n\t\thost = endpoint\n\t}\n\n\t// base error message\n\tbaseMsg := fmt.Sprintf(\"%s %q\", method, endpoint)\n\n\t// check for context errors\n\t// timeout\n\tif errors.Is(err, context.DeadlineExceeded) {\n\t\tif strings.Contains(err.Error(), \"Client.Timeout\") {\n\t\t\treturn fmt.Errorf(\"connection timeout: %s: %w (the remote server at %s did not respond within the configured timeout)\", baseMsg, err, host)\n\t\t}\n\t\treturn fmt.Errorf(\"request timeout: %s: %w (operation took too long time)\", baseMsg, err)\n\t}\n\n\t// cancellation\n\tif errors.Is(err, context.Canceled) {\n\t\treturn fmt.Errorf(\"request canceled: %s: %w (the operation was canceled before completion)\", baseMsg, err)\n\t}\n\n\t// check for net package errors\n\t// dns error handling\n\tvar dnsErr *net.DNSError\n\tif errors.As(err, &dnsErr) {\n\t\tif dnsErr.IsNotFound {\n\t\t\treturn fmt.Errorf(\"DNS resolution failed: %s: %w (hostname %s does not exist or cannot be resolved)\", baseMsg, err, host)\n\t\t}\n\t\tif dnsErr.IsTimeout {\n\t\t\treturn fmt.Errorf(\"DNS timeout: %s: %w (DNS server did not respond in time)\", baseMsg, err)\n\t\t}\n\t\treturn fmt.Errorf(\"DNS error: %s: %w\", baseMsg, err)\n\t}\n\n\t// op error handling\n\tvar opErr *net.OpError\n\tif errors.As(err, &opErr) {\n\t\t// connection refused\n\t\tif errors.Is(opErr.Err, syscall.ECONNREFUSED) {\n\t\t\treturn fmt.Errorf(\"connection refused: %s: %w (server at %s is not accepting connections - is it running?)\", baseMsg, err, host)\n\t\t}\n\n\t\t// connection reset\n\t\tif errors.Is(opErr.Err, syscall.ECONNRESET) {\n\t\t\treturn fmt.Errorf(\"connection reset: %s: %w (server at %s closed the connection unexpectedly)\", baseMsg, err, host)\n\t\t}\n\n\t\t// network unreachable\n\t\tif errors.Is(opErr.Err, syscall.ENETUNREACH) {\n\t\t\treturn fmt.Errorf(\"network unreachable: %s: %w (cannot reach %s - check network connectivity)\", baseMsg, err, host)\n\t\t}\n\n\t\t// host unreachable\n\t\tif errors.Is(opErr.Err, syscall.EHOSTUNREACH) {\n\t\t\treturn fmt.Errorf(\"host unreachable: %s: %w (cannot reach %s - host may be down or firewall blocking)\", baseMsg, err, host)\n\t\t}\n\n\t\t// timeout during operation\n\t\tif opErr.Timeout() {\n\t\t\treturn fmt.Errorf(\"network timeout during %s: %s: %w (operation at %s took too long)\", opErr.Op, baseMsg, err, host)\n\t\t}\n\n\t\treturn fmt.Errorf(\"network error during %s: %s: %w\", opErr.Op, baseMsg, err)\n\t}\n\n\t// check for url parsing errors\n\tvar urlErr *url.Error\n\tif errors.As(err, &urlErr) {\n\t\treturn fmt.Errorf(\"URL error: %s: %w (check if the endpoint URL is correctly formatted)\", baseMsg, err)\n\t}\n\n\t// check for TLS/certificate errors\n\tvar certErr *x509.CertificateInvalidError\n\tif errors.As(err, &certErr) {\n\t\treturn fmt.Errorf(\"TLS certificate invalid: %s: %w (certificate validation failed for %s)\", baseMsg, err, host)\n\t}\n\n\tvar unknownAuthErr *x509.UnknownAuthorityError\n\tif errors.As(err, &unknownAuthErr) {\n\t\treturn fmt.Errorf(\"TLS certificate verification failed: %s: %w (certificate signed by unknown authority for %s)\", baseMsg, err, host)\n\t}\n\n\tvar hostErr *x509.HostnameError\n\tif errors.As(err, &hostErr) {\n\t\treturn fmt.Errorf(\"TLS hostname mismatch: %s: %w (certificate is not valid for %s)\", baseMsg, err, host)\n\t}\n\n\t// check for os errors\n\tif errors.Is(err, os.ErrInvalid) {\n\t\treturn fmt.Errorf(\"invalid argument: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrPermission) {\n\t\treturn fmt.Errorf(\"permission denied: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrExist) {\n\t\treturn fmt.Errorf(\"file already exists: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrNotExist) {\n\t\treturn fmt.Errorf(\"file does not exist: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrClosed) {\n\t\treturn fmt.Errorf(\"file already closed: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrNoDeadline) {\n\t\treturn fmt.Errorf(\"file type does not support deadline: %s: %w\", baseMsg, err)\n\t}\n\tif errors.Is(err, os.ErrDeadlineExceeded) {\n\t\treturn fmt.Errorf(\"i/o timeout: %s: %w\", baseMsg, err)\n\t}\n\n\t// check for EOF specifically\n\tif err.Error() == \"EOF\" || strings.Contains(err.Error(), \"EOF\") {\n\t\treturn fmt.Errorf(\"unexpected connection closure: %s: %w (server at %s closed connection prematurely - possible causes: server crash, request too large, incompatible protocol, or server-side timeout)\", baseMsg, err, host)\n\t}\n\n\t// check for \"connection reset by peer\"\n\tif strings.Contains(err.Error(), \"connection reset by peer\") {\n\t\treturn fmt.Errorf(\"connection reset by peer: %s: %w (server at %s forcibly closed the connection)\", baseMsg, err, host)\n\t}\n\n\t// generic error\n\treturn fmt.Errorf(\"HTTP request failed: %s: %w\", baseMsg, err)\n}\n"
  },
  {
    "path": "shared/httputil/http_error_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"context\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"syscall\"\n\t\"testing\"\n)\n\n// TestEnhanceHTTPError tests the enhanceHTTPError function with various error types.\nfunc TestEnhanceHTTPError(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\terr      error\n\t\tmethod   string\n\t\tendpoint string\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname:     \"nil error\",\n\t\t\terr:      nil,\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com\",\n\t\t\twant:     \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"context deadline exceeded\",\n\t\t\terr:      context.DeadlineExceeded,\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com/api\",\n\t\t\twant:     \"request timeout\",\n\t\t},\n\t\t{\n\t\t\tname:     \"context canceled\",\n\t\t\terr:      context.Canceled,\n\t\t\tmethod:   \"GET\",\n\t\t\tendpoint: \"https://example.com/api\",\n\t\t\twant:     \"request canceled\",\n\t\t},\n\t\t{\n\t\t\tname: \"DNS not found error\",\n\t\t\terr: &net.DNSError{\n\t\t\t\tErr:         \"no such host\",\n\t\t\t\tIsNotFound:  true,\n\t\t\t\tIsTimeout:   false,\n\t\t\t\tIsTemporary: false,\n\t\t\t},\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://nonexistent.example.com\",\n\t\t\twant:     \"DNS resolution failed\",\n\t\t},\n\t\t{\n\t\t\tname: \"DNS timeout error\",\n\t\t\terr: &net.DNSError{\n\t\t\t\tErr:         \"timeout\",\n\t\t\t\tIsTimeout:   true,\n\t\t\t\tIsTemporary: true,\n\t\t\t},\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com\",\n\t\t\twant:     \"DNS timeout\",\n\t\t},\n\t\t{\n\t\t\tname:     \"unknown authority certificate error\",\n\t\t\terr:      x509.UnknownAuthorityError{},\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://self-signed.example.com\",\n\t\t\twant:     \"TLS certificate verification failed\",\n\t\t},\n\t\t{\n\t\t\tname: \"connection refused\",\n\t\t\terr: &net.OpError{\n\t\t\t\tOp:  \"dial\",\n\t\t\t\tErr: syscall.ECONNREFUSED,\n\t\t\t},\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://localhost:9999\",\n\t\t\twant:     \"connection refused\",\n\t\t},\n\t\t{\n\t\t\tname: \"connection reset\",\n\t\t\terr: &net.OpError{\n\t\t\t\tOp:  \"read\",\n\t\t\t\tErr: syscall.ECONNRESET,\n\t\t\t},\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com\",\n\t\t\twant:     \"connection reset\",\n\t\t},\n\t\t{\n\t\t\tname:     \"EOF error\",\n\t\t\terr:      io.EOF,\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com/api\",\n\t\t\twant:     \"unexpected connection closure\",\n\t\t},\n\t\t{\n\t\t\tname:     \"generic error\",\n\t\t\terr:      errors.New(\"some random error\"),\n\t\t\tmethod:   \"POST\",\n\t\t\tendpoint: \"https://example.com\",\n\t\t\twant:     \"HTTP request failed\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := EnhanceHTTPError(tt.err, tt.method, tt.endpoint)\n\n\t\t\tif tt.want == \"\" {\n\t\t\t\tif got != nil {\n\t\t\t\t\tt.Errorf(\"enhanceHTTPError() = %v, want nil\", got)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif got == nil {\n\t\t\t\tt.Errorf(\"enhanceHTTPError() = nil, want error containing %q\", tt.want)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif got.Error() == \"\" {\n\t\t\t\tt.Errorf(\"enhanceHTTPError() returned empty error message\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// check empty error message\n\t\t\terrMsg := got.Error()\n\t\t\tif len(errMsg) == 0 {\n\t\t\t\tt.Errorf(\"enhanceHTTPError() returned empty error string\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// view the enhance error message\n\t\t\tt.Logf(\"enhanced error: %v\", errMsg)\n\t\t})\n\t}\n}\n\nfunc TestEnhanceHTTPErrorPreservesOriginal(t *testing.T) {\n\toriginalErr := io.EOF\n\tendpoint := \"https://example.com/api\"\n\n\tenhanced := EnhanceHTTPError(originalErr, \"POST\", endpoint)\n\n\t// the io.EOF error should be wrapped inside the enhanced error\n\tif !errors.Is(enhanced, originalErr) {\n\t\tt.Errorf(\"enhanced error should wrap original error, but errors.Is returned false\")\n\t}\n}\n"
  },
  {
    "path": "shared/httputil/httputil.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"math\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// IsHTTPS is a helper function that evaluates the http.Request\n// and returns True if the Request uses HTTPS. It is able to detect,\n// using the X-Forwarded-Proto, if the original request was HTTPS and\n// routed through a reverse proxy with SSL termination.\nfunc IsHTTPS(r *http.Request) bool {\n\tswitch {\n\tcase r.URL.Scheme == \"https\":\n\t\treturn true\n\tcase r.TLS != nil:\n\t\treturn true\n\tcase strings.HasPrefix(r.Proto, \"HTTPS\"):\n\t\treturn true\n\tcase r.Header.Get(\"X-Forwarded-Proto\") == \"https\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// SetCookie writes the cookie value.\nfunc SetCookie(w http.ResponseWriter, r *http.Request, name, value string) {\n\tcookie := http.Cookie{\n\t\tName:     name,\n\t\tValue:    value,\n\t\tPath:     \"/\",\n\t\tDomain:   r.URL.Host,\n\t\tHttpOnly: true,\n\t\tSecure:   IsHTTPS(r),\n\t\tMaxAge:   math.MaxInt32, // the cookie value (token) is responsible for expiration\n\t}\n\n\thttp.SetCookie(w, &cookie)\n}\n\n// DelCookie deletes a cookie.\nfunc DelCookie(w http.ResponseWriter, r *http.Request, name string) {\n\tcookie := http.Cookie{\n\t\tName:   name,\n\t\tValue:  \"deleted\",\n\t\tPath:   \"/\",\n\t\tDomain: r.URL.Host,\n\t\tMaxAge: -1,\n\t}\n\n\thttp.SetCookie(w, &cookie)\n}\n"
  },
  {
    "path": "shared/httputil/useragent.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// UserAgentRoundTripper is an http.RoundTripper that sets a custom User-Agent header\n// on all outgoing requests.\ntype UserAgentRoundTripper struct {\n\tbase      http.RoundTripper\n\tuserAgent string\n}\n\n// NewUserAgentRoundTripper creates a new RoundTripper that adds the Woodpecker User-Agent\n// to all requests. If base is nil, http.DefaultTransport is used.\nfunc NewUserAgentRoundTripper(base http.RoundTripper, component string) *UserAgentRoundTripper {\n\tif base == nil {\n\t\tbase = http.DefaultTransport\n\t}\n\n\tuserAgent := fmt.Sprintf(\"Woodpecker/%s\", version.String())\n\tif component != \"\" {\n\t\tuserAgent = fmt.Sprintf(\"%s (%s)\", userAgent, component)\n\t}\n\n\treturn &UserAgentRoundTripper{\n\t\tbase:      base,\n\t\tuserAgent: userAgent,\n\t}\n}\n\n// RoundTrip implements the http.RoundTripper interface.\nfunc (rt *UserAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\t// Clone the request to avoid modifying the original\n\treqClone := req.Clone(req.Context())\n\n\t// Set the User-Agent header if not already set\n\tif reqClone.Header.Get(\"User-Agent\") == \"\" {\n\t\treqClone.Header.Set(\"User-Agent\", rt.userAgent)\n\t}\n\n\t// Execute the request using the base transport\n\treturn rt.base.RoundTrip(reqClone)\n}\n\n// WrapClient wraps an existing http.Client with the UserAgentRoundTripper.\n// If client is nil, a new client with default settings is created.\nfunc WrapClient(client *http.Client, component string) *http.Client {\n\tif client == nil {\n\t\tclient = &http.Client{}\n\t}\n\n\tclient.Transport = NewUserAgentRoundTripper(client.Transport, component)\n\treturn client\n}\n"
  },
  {
    "path": "shared/httputil/useragent_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc TestNewUserAgentRoundTripper(t *testing.T) {\n\tt.Run(\"with custom component\", func(t *testing.T) {\n\t\trt := NewUserAgentRoundTripper(nil, \"test-component\")\n\t\tassert.NotNil(t, rt)\n\t\tassert.NotNil(t, rt.base)\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s (test-component)\", version.String())\n\t\tassert.Equal(t, expectedUA, rt.userAgent)\n\t})\n\n\tt.Run(\"without component\", func(t *testing.T) {\n\t\trt := NewUserAgentRoundTripper(nil, \"\")\n\t\tassert.NotNil(t, rt)\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s\", version.String())\n\t\tassert.Equal(t, expectedUA, rt.userAgent)\n\t})\n\n\tt.Run(\"with custom base transport\", func(t *testing.T) {\n\t\tcustomTransport := &http.Transport{}\n\t\trt := NewUserAgentRoundTripper(customTransport, \"custom\")\n\t\tassert.Equal(t, customTransport, rt.base)\n\t})\n}\n\nfunc TestUserAgentRoundTripper_RoundTrip(t *testing.T) {\n\t// Create a test server to capture requests\n\tvar capturedUserAgent string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcapturedUserAgent = r.Header.Get(\"User-Agent\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"OK\"))\n\t}))\n\tdefer server.Close()\n\n\tt.Run(\"sets user-agent when not present\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"agent\"),\n\t\t}\n\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s (agent)\", version.String())\n\t\tassert.Equal(t, expectedUA, capturedUserAgent)\n\t})\n\n\tt.Run(\"preserves existing user-agent\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"agent\"),\n\t\t}\n\n\t\tcustomUA := \"CustomUserAgent/1.0\"\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\t\treq.Header.Set(\"User-Agent\", customUA)\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\tassert.Equal(t, customUA, capturedUserAgent)\n\t})\n\n\tt.Run(\"does not modify original request\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"test\"),\n\t\t}\n\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\n\t\toriginalUserAgent := req.Header.Get(\"User-Agent\")\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\t// Original request should remain unchanged\n\t\tassert.Equal(t, originalUserAgent, req.Header.Get(\"User-Agent\"))\n\t})\n}\n\nfunc TestWrapClient(t *testing.T) {\n\tt.Run(\"wraps existing client\", func(t *testing.T) {\n\t\toriginalClient := &http.Client{}\n\t\twrappedClient := WrapClient(originalClient, \"cli\")\n\n\t\tassert.Equal(t, originalClient, wrappedClient)\n\t\tassert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport)\n\t})\n\n\tt.Run(\"creates new client when nil\", func(t *testing.T) {\n\t\twrappedClient := WrapClient(nil, \"server\")\n\n\t\tassert.NotNil(t, wrappedClient)\n\t\tassert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport)\n\t})\n\n\tt.Run(\"preserves existing transport\", func(t *testing.T) {\n\t\tcustomTransport := &http.Transport{}\n\t\toriginalClient := &http.Client{\n\t\t\tTransport: customTransport,\n\t\t}\n\n\t\twrappedClient := WrapClient(originalClient, \"test\")\n\n\t\trt, ok := wrappedClient.Transport.(*UserAgentRoundTripper)\n\t\tassert.True(t, ok)\n\t\tassert.Equal(t, customTransport, rt.base)\n\t})\n}\n\nfunc TestIntegration_UserAgentInRealRequest(t *testing.T) {\n\t// Test with a real HTTP server\n\tvar receivedHeaders http.Header\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedHeaders = r.Header.Clone()\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := WrapClient(nil, \"integration-test\")\n\n\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\tassert.NoError(t, err)\n\n\tresp, err := client.Do(req)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, resp)\n\tdefer resp.Body.Close()\n\n\tuserAgent := receivedHeaders.Get(\"User-Agent\")\n\tassert.NotEmpty(t, userAgent)\n\tassert.Contains(t, userAgent, \"Woodpecker/\")\n\tassert.Contains(t, userAgent, \"(integration-test)\")\n}\n"
  },
  {
    "path": "shared/logger/addon_logger.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logger\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\tstd_log \"log\"\n\n\t\"github.com/hashicorp/go-hclog\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n)\n\ntype AddonClientLogger struct {\n\tLogger   zerolog.Logger\n\tname     string\n\twithArgs []any\n}\n\n// cspell:words hclog\n\nfunc convertLvl(level hclog.Level) zerolog.Level {\n\tswitch level {\n\tcase hclog.Error:\n\t\treturn zerolog.ErrorLevel\n\tcase hclog.Warn:\n\t\treturn zerolog.WarnLevel\n\tcase hclog.Info:\n\t\treturn zerolog.InfoLevel\n\tcase hclog.Debug:\n\t\treturn zerolog.DebugLevel\n\tcase hclog.Trace:\n\t\treturn zerolog.TraceLevel\n\t}\n\treturn zerolog.NoLevel\n}\n\nfunc (c *AddonClientLogger) applyArgs(args []any) *zerolog.Logger {\n\tvar key string\n\tlogger := c.Logger.With()\n\targs = append(args, c.withArgs)\n\tfor i, arg := range args {\n\t\tswitch {\n\t\tcase key != \"\":\n\t\t\tlogger.Any(key, arg)\n\t\t\tkey = \"\"\n\t\tcase i == len(args)-1:\n\t\t\tlogger.Any(hclog.MissingKey, arg)\n\t\tdefault:\n\n\t\t\tkey, _ = arg.(string)\n\t\t}\n\t}\n\tl := logger.Logger()\n\treturn &l\n}\n\nfunc (c *AddonClientLogger) Log(level hclog.Level, msg string, args ...any) {\n\tc.applyArgs(args).WithLevel(convertLvl(level)).Msg(msg)\n}\n\nfunc (c *AddonClientLogger) Trace(msg string, args ...any) {\n\tc.applyArgs(args).Trace().Msg(msg)\n}\n\nfunc (c *AddonClientLogger) Debug(msg string, args ...any) {\n\tc.applyArgs(args).Debug().Msg(msg)\n}\n\nfunc (c *AddonClientLogger) Info(msg string, args ...any) {\n\tc.applyArgs(args).Info().Msg(msg)\n}\n\nfunc (c *AddonClientLogger) Warn(msg string, args ...any) {\n\tc.applyArgs(args).Warn().Msg(msg)\n}\n\nfunc (c *AddonClientLogger) Error(msg string, args ...any) {\n\tc.applyArgs(args).Error().Msg(msg)\n}\n\nfunc (c *AddonClientLogger) IsTrace() bool {\n\treturn log.Logger.GetLevel() >= zerolog.TraceLevel\n}\n\nfunc (c *AddonClientLogger) IsDebug() bool {\n\treturn log.Logger.GetLevel() >= zerolog.DebugLevel\n}\n\nfunc (c *AddonClientLogger) IsInfo() bool {\n\treturn log.Logger.GetLevel() >= zerolog.InfoLevel\n}\n\nfunc (c *AddonClientLogger) IsWarn() bool {\n\treturn log.Logger.GetLevel() >= zerolog.WarnLevel\n}\n\nfunc (c *AddonClientLogger) IsError() bool {\n\treturn log.Logger.GetLevel() >= zerolog.ErrorLevel\n}\n\nfunc (c *AddonClientLogger) ImpliedArgs() []any {\n\treturn c.withArgs\n}\n\nfunc (c *AddonClientLogger) With(args ...any) hclog.Logger {\n\treturn &AddonClientLogger{\n\t\tLogger:   c.Logger,\n\t\tname:     c.name,\n\t\twithArgs: args,\n\t}\n}\n\nfunc (c *AddonClientLogger) Name() string {\n\treturn c.name\n}\n\nfunc (c *AddonClientLogger) Named(name string) hclog.Logger {\n\tcurr := c.name\n\tif curr != \"\" {\n\t\tcurr = c.name + \".\"\n\t}\n\treturn c.ResetNamed(curr + name)\n}\n\nfunc (c *AddonClientLogger) ResetNamed(name string) hclog.Logger {\n\treturn &AddonClientLogger{\n\t\tLogger:   c.Logger,\n\t\tname:     name,\n\t\twithArgs: c.withArgs,\n\t}\n}\n\nfunc (c *AddonClientLogger) SetLevel(level hclog.Level) {\n\tc.Logger = c.Logger.Level(convertLvl(level))\n}\n\nfunc (c *AddonClientLogger) GetLevel() hclog.Level {\n\tswitch c.Logger.GetLevel() {\n\tcase zerolog.ErrorLevel:\n\t\treturn hclog.Error\n\tcase zerolog.WarnLevel:\n\t\treturn hclog.Warn\n\tcase zerolog.InfoLevel:\n\t\treturn hclog.Info\n\tcase zerolog.DebugLevel:\n\t\treturn hclog.Debug\n\tcase zerolog.TraceLevel:\n\t\treturn hclog.Trace\n\t}\n\treturn hclog.NoLevel\n}\n\nfunc (c *AddonClientLogger) StandardLogger(opts *hclog.StandardLoggerOptions) *std_log.Logger {\n\treturn std_log.New(c.StandardWriter(opts), \"\", 0)\n}\n\nfunc (c *AddonClientLogger) StandardWriter(*hclog.StandardLoggerOptions) io.Writer {\n\treturn ioAdapter{logger: c.Logger}\n}\n\ntype ioAdapter struct {\n\tlogger zerolog.Logger\n}\n\nfunc (i ioAdapter) Write(p []byte) (n int, err error) {\n\tstr := string(bytes.TrimRight(p, \" \\t\\n\"))\n\ti.logger.Log().Msg(str)\n\treturn len(p), nil\n}\n"
  },
  {
    "path": "shared/logger/logger.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logger\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/6543/logfile-open\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/urfave/cli/v3\"\n)\n\nvar GlobalLoggerFlags = []cli.Flag{\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_LOG_LEVEL\"),\n\t\tName:    \"log-level\",\n\t\tUsage:   \"set logging level\",\n\t\tValue:   \"info\",\n\t},\n\t&cli.StringFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_LOG_FILE\"),\n\t\tName:    \"log-file\",\n\t\tUsage:   \"Output destination for logs. 'stdout' and 'stderr' can be used as special keywords.\",\n\t\tValue:   \"stderr\",\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEBUG_PRETTY\"),\n\t\tName:    \"pretty\",\n\t\tUsage:   \"enable pretty-printed debug output\",\n\t\tValue:   isInteractiveTerminal(), // make pretty on interactive terminal by default\n\t},\n\t&cli.BoolFlag{\n\t\tSources: cli.EnvVars(\"WOODPECKER_DEBUG_NOCOLOR\"),\n\t\tName:    \"nocolor\",\n\t\tUsage:   \"disable colored debug output, only has effect if pretty output is set too\",\n\t\tValue:   !isInteractiveTerminal(), // do color on interactive terminal by default\n\t},\n}\n\nfunc SetupGlobalLogger(ctx context.Context, c *cli.Command, outputLvl bool) error {\n\tlogLevel := c.String(\"log-level\")\n\tpretty := c.Bool(\"pretty\")\n\tnoColor := c.Bool(\"nocolor\")\n\tlogFile := c.String(\"log-file\")\n\n\tvar file io.ReadWriteCloser\n\tswitch logFile {\n\tcase \"\", \"stderr\": // default case\n\t\tfile = os.Stderr\n\tcase \"stdout\":\n\t\tfile = os.Stdout\n\tdefault: // a file was set\n\t\topenFile, err := logfile.OpenFileWithContext(ctx, logFile, 0o660)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"could not open log file '%s': %w\", logFile, err)\n\t\t}\n\t\tfile = openFile\n\t\tnoColor = true\n\t}\n\n\tlog.Logger = zerolog.New(file).With().Timestamp().Logger()\n\n\tif pretty {\n\t\tlog.Logger = log.Output(\n\t\t\tzerolog.ConsoleWriter{\n\t\t\t\tOut:     file,\n\t\t\t\tNoColor: noColor,\n\t\t\t},\n\t\t)\n\t}\n\n\t// TODO: format output & options to switch to json aka. option to add channels to send logs to\n\n\tlvl, err := zerolog.ParseLevel(logLevel)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unknown logging level: %s\", logLevel)\n\t}\n\tzerolog.SetGlobalLevel(lvl)\n\n\t// if debug or trace also log the caller\n\tif zerolog.GlobalLevel() <= zerolog.DebugLevel {\n\t\tlog.Logger = log.With().Caller().Logger()\n\t}\n\n\tif outputLvl {\n\t\tlog.Info().Msgf(\"log level: %s\", zerolog.GlobalLevel().String())\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "shared/logger/terminal.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logger\n\nimport (\n\t\"os\"\n\n\t\"golang.org/x/term\"\n)\n\n// isInteractiveTerminal checks if the output is piped, but NOT if the session is run interactively.\nfunc isInteractiveTerminal() bool {\n\treturn term.IsTerminal(int(os.Stdout.Fd()))\n}\n"
  },
  {
    "path": "shared/optional/option.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 The Gitea Authors.\n//\n// Licensed under the MIT License.\n\npackage optional\n\nimport \"reflect\"\n\ntype Option[T any] []T\n\nfunc None[T any]() Option[T] {\n\treturn nil\n}\n\nfunc Some[T any](v T) Option[T] {\n\treturn Option[T]{v}\n}\n\nfunc FromPtr[T any](v *T) Option[T] {\n\tif v == nil {\n\t\treturn None[T]()\n\t}\n\treturn Some(*v)\n}\n\nfunc FromNonDefault[T comparable](v T) Option[T] {\n\tvar zero T\n\tif v == zero {\n\t\treturn None[T]()\n\t}\n\treturn Some(v)\n}\n\nfunc (o Option[T]) Has() bool {\n\treturn o != nil\n}\n\nfunc (o Option[T]) Value() T {\n\tvar zero T\n\treturn o.ValueOrDefault(zero)\n}\n\nfunc (o Option[T]) ValueOrDefault(v T) T {\n\tif o.Has() {\n\t\treturn o[0]\n\t}\n\treturn v\n}\n\nfunc (o Option[T]) ToPtr() *T {\n\tif o.Has() {\n\t\treturn &o[0]\n\t}\n\treturn nil\n}\n\n// ExtractValue return value or nil and bool if object was an Optional\n// it should only be used if you already have to deal with interface{} values\n// and expect an Option type within it.\nfunc ExtractValue(obj any) (any, bool) {\n\trt := reflect.TypeOf(obj)\n\tif rt.Kind() != reflect.Slice {\n\t\treturn nil, false\n\t}\n\n\ttype hasHasFunc interface {\n\t\tHas() bool\n\t}\n\tif hasObj, ok := obj.(hasHasFunc); !ok {\n\t\treturn nil, false\n\t} else if !hasObj.Has() {\n\t\treturn nil, true\n\t}\n\n\trv := reflect.ValueOf(obj)\n\tif rv.Len() != 1 {\n\t\t// it's still false as optional.Option[T] types would have reported with hasObj.Has() that it is empty\n\t\treturn nil, false\n\t}\n\treturn rv.Index(0).Interface(), true\n}\n"
  },
  {
    "path": "shared/optional/option_test.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 The Gitea Authors.\n//\n// Licensed under the MIT License.\n\npackage optional_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\nfunc TestOption(t *testing.T) {\n\tvar uninitialized optional.Option[int]\n\tassert.False(t, uninitialized.Has())\n\tassert.Equal(t, int(0), uninitialized.Value())\n\tassert.Equal(t, int(1), uninitialized.ValueOrDefault(1))\n\n\tnone := optional.None[int]()\n\tassert.False(t, none.Has())\n\tassert.Equal(t, int(0), none.Value())\n\tassert.Equal(t, int(1), none.ValueOrDefault(1))\n\n\tsome := optional.Some[int](1)\n\tassert.True(t, some.Has())\n\tassert.Equal(t, int(1), some.Value())\n\tassert.Equal(t, int(1), some.ValueOrDefault(2))\n\n\tvar ptr *int\n\tassert.False(t, optional.FromPtr(ptr).Has())\n\n\tvar boolPtr *bool\n\tassert.Equal(t, boolPtr, optional.None[bool]().ToPtr())\n\n\tboolPtr = optional.Some[bool](false).ToPtr()\n\tassert.Equal(t, toPtr(false), boolPtr)\n\n\topt1 := optional.FromPtr(toPtr(1))\n\tassert.True(t, opt1.Has())\n\tassert.Equal(t, int(1), opt1.Value())\n\n\tassert.False(t, optional.FromNonDefault(\"\").Has())\n\n\topt2 := optional.FromNonDefault(\"test\")\n\tassert.True(t, opt2.Has())\n\tassert.Equal(t, \"test\", opt2.Value())\n\n\tassert.False(t, optional.FromNonDefault(0).Has())\n\n\topt3 := optional.FromNonDefault(1)\n\tassert.True(t, opt3.Has())\n\tassert.Equal(t, int(1), opt3.Value())\n}\n\nfunc TestExtractValue(t *testing.T) {\n\tval, ok := optional.ExtractValue(\"aaaa\")\n\tassert.False(t, ok)\n\tassert.Nil(t, val)\n\n\tval, ok = optional.ExtractValue(optional.Some(\"aaaa\"))\n\tassert.True(t, ok)\n\tif assert.NotNil(t, val) {\n\t\tval, ok := val.(string)\n\t\tassert.True(t, ok)\n\t\tassert.EqualValues(t, \"aaaa\", val)\n\t}\n\n\tval, ok = optional.ExtractValue(optional.None[float64]())\n\tassert.True(t, ok)\n\tassert.Nil(t, val)\n\n\tval, ok = optional.ExtractValue(&fakeHas{})\n\tassert.False(t, ok)\n\tassert.Nil(t, val)\n\n\twrongType := make(fakeHas2, 0, 1)\n\tval, ok = optional.ExtractValue(wrongType)\n\tassert.False(t, ok)\n\tassert.Nil(t, val)\n}\n\nfunc toPtr[T any](val T) *T {\n\treturn &val\n}\n\ntype fakeHas struct{}\n\nfunc (fakeHas) Has() bool {\n\treturn true\n}\n\ntype fakeHas2 []string\n\nfunc (fakeHas2) Has() bool {\n\treturn true\n}\n"
  },
  {
    "path": "shared/optional/serialization.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 \"6543\".\n//\n// Licensed under the MIT License.\n\npackage optional\n\nimport (\n\t\"encoding/json\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc (o *Option[T]) UnmarshalJSON(data []byte) error {\n\tvar v *T\n\tif err := json.Unmarshal(data, &v); err != nil {\n\t\treturn err\n\t}\n\t*o = FromPtr(v)\n\treturn nil\n}\n\nfunc (o Option[T]) MarshalJSON() ([]byte, error) {\n\tif !o.Has() {\n\t\treturn []byte(\"null\"), nil\n\t}\n\n\treturn json.Marshal(o.Value())\n}\n\nfunc (o *Option[T]) UnmarshalYAML(value *yaml.Node) error {\n\tvar v *T\n\tif err := value.Decode(&v); err != nil {\n\t\treturn err\n\t}\n\t*o = FromPtr(v)\n\treturn nil\n}\n\nfunc (o Option[T]) MarshalYAML() (any, error) {\n\tif !o.Has() {\n\t\treturn nil, nil\n\t}\n\n\tvalue := new(yaml.Node)\n\terr := value.Encode(o.Value())\n\treturn value, err\n}\n"
  },
  {
    "path": "shared/optional/serialization_json_test.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 \"6543\".\n//\n// Licensed under the MIT License.\n\npackage optional_test\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\nfunc TestOptionalToJson(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tobj  *testSerializationStruct\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"empty\",\n\t\t\tobj:  new(testSerializationStruct),\n\t\t\twant: `{\"normal_string\":\"\",\"normal_bool\":false,\"optional_two_bool\":null,\"optional_twostring\":null}`,\n\t\t},\n\t\t{\n\t\t\tname: \"some\",\n\t\t\tobj: &testSerializationStruct{\n\t\t\t\tNormalString: \"a string\",\n\t\t\t\tNormalBool:   true,\n\t\t\t\tOptBool:      optional.Some(false),\n\t\t\t\tOptString:    optional.Some(\"\"),\n\t\t\t},\n\t\t\twant: `{\"normal_string\":\"a string\",\"normal_bool\":true,\"optional_bool\":false,\"optional_string\":\"\",\"optional_two_bool\":null,\"optional_twostring\":null}`,\n\t\t},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tb, err := json.Marshal(tc.obj)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.EqualValues(t, tc.want, string(b), \"std json module returned unexpected\")\n\t\t})\n\t}\n}\n\nfunc TestOptionalFromJson(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tdata string\n\t\twant testSerializationStruct\n\t}{\n\t\t{\n\t\t\tname: \"empty\",\n\t\t\tdata: `{}`,\n\t\t\twant: testSerializationStruct{\n\t\t\t\tNormalString: \"\",\n\t\t\t\tOptBool:      optional.None[bool](),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"some\",\n\t\t\tdata: `{\"normal_string\":\"a string\",\"normal_bool\":true,\"optional_bool\":false,\"optional_string\":\"\",\"optional_two_bool\":null,\"optional_twostring\":null}`,\n\t\t\twant: testSerializationStruct{\n\t\t\t\tNormalString: \"a string\",\n\t\t\t\tNormalBool:   true,\n\t\t\t\tOptBool:      optional.Some(false),\n\t\t\t\tOptString:    optional.Some(\"\"),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar obj testSerializationStruct\n\t\t\terr := json.Unmarshal([]byte(tc.data), &obj)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.EqualValues(t, tc.want, obj, \"std json module returned unexpected\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "shared/optional/serialization_test.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 \"6543\".\n//\n// Licensed under the MIT License.\n\npackage optional_test\n\nimport (\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\ntype testSerializationStruct struct {\n\tNormalString string                  `json:\"normal_string\" yaml:\"normal_string\"`\n\tNormalBool   bool                    `json:\"normal_bool\" yaml:\"normal_bool\"`\n\tOptBool      optional.Option[bool]   `json:\"optional_bool,omitempty\" yaml:\"optional_bool,omitempty\"`\n\tOptString    optional.Option[string] `json:\"optional_string,omitempty\" yaml:\"optional_string,omitempty\"`\n\tOptTwoBool   optional.Option[bool]   `json:\"optional_two_bool\" yaml:\"optional_two_bool\"`\n\tOptTwoString optional.Option[string] `json:\"optional_twostring\" yaml:\"optional_two_string\"`\n}\n"
  },
  {
    "path": "shared/optional/serialization_yaml_test.go",
    "content": "// Copyright 2025 Woodpecker Authors.\n// Copyright 2024 \"6543\".\n//\n// Licensed under the MIT License.\n\npackage optional_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/yaml.v3\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/optional\"\n)\n\ntype testBoolStruct struct {\n\tOptBoolOmitEmpty1 optional.Option[bool] `json:\"opt_bool_omit_empty_1,omitempty\" yaml:\"opt_bool_omit_empty_1,omitempty\"`\n\tOptBoolOmitEmpty2 optional.Option[bool] `json:\"opt_bool_omit_empty_2,omitempty\" yaml:\"opt_bool_omit_empty_2,omitempty\"`\n\tOptBoolOmitEmpty3 optional.Option[bool] `json:\"opt_bool_omit_empty_3,omitempty\" yaml:\"opt_bool_omit_empty_3,omitempty\"`\n\tOptBool4          optional.Option[bool] `json:\"opt_bool_4\" yaml:\"opt_bool_4\"`\n\tOptBool5          optional.Option[bool] `json:\"opt_bool_5\" yaml:\"opt_bool_5\"`\n\tOptBool6          optional.Option[bool] `json:\"opt_bool_6\" yaml:\"opt_bool_6\"`\n}\n\nfunc TestOptionalBoolYaml(t *testing.T) {\n\ttYaml := `\nopt_bool_omit_empty_1: false\nopt_bool_omit_empty_2: true\nopt_bool_4: false\nopt_bool_5: true\n`\n\n\ttObj := new(testBoolStruct)\n\tt.Run(\"Unmarshal\", func(t *testing.T) {\n\t\terr := yaml.Unmarshal([]byte(tYaml), tObj)\n\t\trequire.NoError(t, err)\n\t\tassert.EqualValues(t, &testBoolStruct{\n\t\t\tOptBoolOmitEmpty1: optional.Some(false),\n\t\t\tOptBoolOmitEmpty2: optional.Some(true),\n\t\t\tOptBoolOmitEmpty3: optional.None[bool](),\n\t\t\tOptBool4:          optional.Some(false),\n\t\t\tOptBool5:          optional.Some(true),\n\t\t\tOptBool6:          optional.None[bool](),\n\t\t}, tObj)\n\t})\n\tt.Run(\"Marshal\", func(t *testing.T) {\n\t\ttBytes, err := yaml.Marshal(tObj)\n\t\trequire.NoError(t, err)\n\t\tassert.EqualValues(t, `opt_bool_omit_empty_1: false\nopt_bool_omit_empty_2: true\nopt_bool_4: false\nopt_bool_5: true\nopt_bool_6: null\n`, string(tBytes))\n\t})\n}\n\nfunc TestOptionalToYaml(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tobj  *testSerializationStruct\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"empty\",\n\t\t\tobj:  new(testSerializationStruct),\n\t\t\twant: `normal_string: \"\"\nnormal_bool: false\noptional_two_bool: null\noptional_two_string: null\n`,\n\t\t},\n\t\t{\n\t\t\tname: \"some\",\n\t\t\tobj: &testSerializationStruct{\n\t\t\t\tNormalString: \"a string\",\n\t\t\t\tNormalBool:   true,\n\t\t\t\tOptBool:      optional.Some(false),\n\t\t\t\tOptString:    optional.Some(\"\"),\n\t\t\t},\n\t\t\twant: `normal_string: a string\nnormal_bool: true\noptional_bool: false\noptional_string: \"\"\noptional_two_bool: null\noptional_two_string: null\n`,\n\t\t},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tb, err := yaml.Marshal(tc.obj)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.EqualValues(t, tc.want, string(b), \"yaml module returned unexpected\")\n\t\t})\n\t}\n}\n\nfunc TestOptionalFromYaml(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tdata string\n\t\twant testSerializationStruct\n\t}{\n\t\t{\n\t\t\tname: \"empty\",\n\t\t\tdata: ``,\n\t\t\twant: testSerializationStruct{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty but init\",\n\t\t\tdata: `normal_string: \"\"\nnormal_bool: false\noptional_bool:\noptional_two_bool:\noptional_two_string:\n`,\n\t\t\twant: testSerializationStruct{},\n\t\t},\n\t\t{\n\t\t\tname: \"some\",\n\t\t\tdata: `\nnormal_string: a string\nnormal_bool: true\noptional_bool: false\noptional_string: \"\"\noptional_two_bool: null\noptional_twostring: null\n`,\n\t\t\twant: testSerializationStruct{\n\t\t\t\tNormalString: \"a string\",\n\t\t\t\tNormalBool:   true,\n\t\t\t\tOptBool:      optional.Some(false),\n\t\t\t\tOptString:    optional.Some(\"\"),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar obj testSerializationStruct\n\t\t\terr := yaml.Unmarshal([]byte(tc.data), &obj)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.EqualValues(t, tc.want, obj, \"yaml module returned unexpected\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "shared/token/token.go",
    "content": "// Copyright 2018 Drone.IO Inc.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage token\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"slices\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/rs/zerolog/log\"\n)\n\ntype SecretFunc func(*Token) (string, error)\n\ntype Type string\n\nconst (\n\tUserToken       Type = \"user\" // user token (exp cli)\n\tSessToken       Type = \"sess\" // session token (ui token requires csrf check)\n\tHookToken       Type = \"hook\" // repo hook token\n\tCsrfToken       Type = \"csrf\"\n\tAgentToken      Type = \"agent\"\n\tOAuthStateToken Type = \"oauth-state\"\n)\n\n// SignerAlgo id default algorithm used to sign JWT tokens.\nconst SignerAlgo = \"HS256\"\n\ntype Token struct {\n\tType   Type\n\tclaims jwt.MapClaims\n}\n\nfunc Parse(allowedTypes []Type, raw string, fn SecretFunc) (*Token, error) {\n\ttoken := &Token{\n\t\tclaims: jwt.MapClaims{},\n\t}\n\tparsed, err := jwt.Parse(raw, keyFunc(token, fn))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !parsed.Valid {\n\t\treturn nil, jwt.ErrTokenUnverifiable\n\t}\n\n\thasAllowedType := slices.Contains(allowedTypes, token.Type)\n\n\tif !hasAllowedType {\n\t\treturn nil, jwt.ErrInvalidType\n\t}\n\n\treturn token, nil\n}\n\nfunc ParseRequest(allowedTypes []Type, r *http.Request, fn SecretFunc) (*Token, error) {\n\t// first we attempt to get the token from the\n\t// authorization header.\n\ttoken := r.Header.Get(\"Authorization\")\n\tif len(token) != 0 {\n\t\tlog.Trace().Msgf(\"token.ParseRequest: found token in header: %s\", token)\n\t\tbearer := token\n\t\tif _, err := fmt.Sscanf(token, \"Bearer %s\", &bearer); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn Parse(allowedTypes, bearer, fn)\n\t}\n\n\ttoken = r.Header.Get(\"X-Gitlab-Token\")\n\tif len(token) != 0 {\n\t\treturn Parse(allowedTypes, token, fn)\n\t}\n\n\t// then we attempt to get the token from the\n\t// access_token url query parameter\n\ttoken = r.FormValue(\"access_token\")\n\tif len(token) != 0 {\n\t\treturn Parse(allowedTypes, token, fn)\n\t}\n\n\t// and finally we attempt to get the token from\n\t// the user session cookie\n\tcookie, err := r.Cookie(\"user_sess\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn Parse(allowedTypes, cookie.Value, fn)\n}\n\nfunc CheckCsrf(r *http.Request, fn SecretFunc) error {\n\t// get and options requests are always\n\t// enabled, without CSRF checks.\n\tswitch r.Method {\n\tcase http.MethodGet, http.MethodOptions:\n\t\treturn nil\n\t}\n\n\t// parse the raw CSRF token value and validate\n\traw := r.Header.Get(\"X-CSRF-TOKEN\")\n\t_, err := Parse([]Type{CsrfToken}, raw, fn)\n\treturn err\n}\n\nfunc New(tokenType Type) *Token {\n\treturn &Token{Type: tokenType, claims: jwt.MapClaims{}}\n}\n\n// Sign signs the token using the given secret hash\n// and returns the string value.\nfunc (t *Token) Sign(secret string) (string, error) {\n\treturn t.SignExpires(secret, 0)\n}\n\n// Sign signs the token using the given secret hash\n// with an expiration date.\nfunc (t *Token) SignExpires(secret string, exp int64) (string, error) {\n\ttoken := jwt.New(jwt.SigningMethodHS256)\n\tclaims, ok := token.Claims.(jwt.MapClaims)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"token claim is not a MapClaims\")\n\t}\n\n\tfor k, v := range t.claims {\n\t\tclaims[k] = v\n\t}\n\n\tclaims[\"type\"] = t.Type\n\tif exp > 0 {\n\t\tclaims[\"exp\"] = float64(exp)\n\t}\n\n\treturn token.SignedString([]byte(secret))\n}\n\nfunc (t *Token) Set(key, value string) {\n\tt.claims[key] = value\n}\n\nfunc (t *Token) Get(key string) string {\n\tclaim, ok := t.claims[key].(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\treturn claim\n}\n\nfunc keyFunc(token *Token, fn SecretFunc) jwt.Keyfunc {\n\treturn func(t *jwt.Token) (any, error) {\n\t\tclaims, ok := t.Claims.(jwt.MapClaims)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"token claim is not a MapClaims\")\n\t\t}\n\n\t\t// validate the correct algorithm is being used\n\t\tif t.Method.Alg() != SignerAlgo {\n\t\t\treturn nil, jwt.ErrSignatureInvalid\n\t\t}\n\n\t\t// extract the token type and cast to the expected type\n\t\ttokenType, ok := claims[\"type\"].(string)\n\t\tif !ok {\n\t\t\treturn nil, jwt.ErrInvalidType\n\t\t}\n\t\ttoken.Type = Type(tokenType)\n\n\t\t// copy custom claims\n\t\tfor k, v := range claims {\n\t\t\t// skip the reserved claims https://datatracker.ietf.org/doc/html/rfc7519#section-4.1\n\t\t\tif k == \"iss\" || k == \"sub\" || k == \"aud\" || k == \"exp\" || k == \"nbf\" || k == \"iat\" || k == \"jti\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif k == \"type\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\ttoken.claims[k] = v\n\t\t}\n\n\t\t// invoke the callback function to retrieve\n\t\t// the secret key used to verify\n\t\tsecret, err := fn(token)\n\t\treturn []byte(secret), err\n\t}\n}\n"
  },
  {
    "path": "shared/token/token_test.go",
    "content": "// Copyright 2021 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage token_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/shared/token\"\n)\n\nconst jwtSecret = \"secret-to-sign-the-token\"\n\nfunc TestTokenValid(t *testing.T) {\n\t_token := token.New(token.UserToken)\n\t_token.Set(\"user-id\", \"1\")\n\tsignedToken, err := _token.Sign(jwtSecret)\n\tassert.NoError(t, err)\n\n\tparsed, err := token.Parse([]token.Type{token.UserToken}, signedToken, func(_ *token.Token) (string, error) {\n\t\treturn jwtSecret, nil\n\t})\n\n\tassert.NoError(t, err)\n\tassert.NotNil(t, parsed)\n\tassert.Equal(t, \"1\", parsed.Get(\"user-id\"))\n}\n\nfunc TestTokenWrongType(t *testing.T) {\n\t_token := token.New(token.UserToken)\n\t_token.Set(\"user-id\", \"1\")\n\tsignedToken, err := _token.Sign(jwtSecret)\n\tassert.NoError(t, err)\n\n\t_, err = token.Parse([]token.Type{token.AgentToken}, signedToken, func(_ *token.Token) (string, error) {\n\t\treturn jwtSecret, nil\n\t})\n\n\tassert.ErrorIs(t, err, jwt.ErrInvalidType)\n}\n\nfunc TestTokenWrongSecret(t *testing.T) {\n\t_token := token.New(token.UserToken)\n\t_token.Set(\"user-id\", \"1\")\n\tsignedToken, err := _token.Sign(jwtSecret)\n\tassert.NoError(t, err)\n\n\t_, err = token.Parse([]token.Type{token.UserToken}, signedToken, func(_ *token.Token) (string, error) {\n\t\treturn \"this-is-a-wrong-secret\", nil\n\t})\n\n\tassert.ErrorIs(t, err, jwt.ErrSignatureInvalid)\n}\n"
  },
  {
    "path": "shared/utils/context.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n)\n\n// Returns a copy of parent context that is canceled when\n// an os interrupt signal is received.\nfunc WithContextSigtermCallback(ctx context.Context, f func()) context.Context {\n\tctx, cancel := context.WithCancelCause(ctx)\n\tgo func() {\n\t\treceivedSignal := make(chan os.Signal, 1)\n\t\tsignal.Notify(receivedSignal, syscall.SIGINT, syscall.SIGTERM)\n\t\tdefer signal.Stop(receivedSignal)\n\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\tcase <-receivedSignal:\n\t\t\tif f != nil {\n\t\t\t\tf()\n\t\t\t}\n\t\t\tcancel(fmt.Errorf(\"received signal: %v\", receivedSignal))\n\t\t}\n\t}()\n\n\treturn ctx\n}\n"
  },
  {
    "path": "shared/utils/paginate.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\n// Paginate iterates over a func call until it does not return new items and return it as list.\nfunc Paginate[T any](get func(page int) ([]T, error), limit int) ([]T, error) {\n\titems := make([]T, 0, 10)\n\tpage := 1\n\tlenFirstBatch := -1\n\n\tfor {\n\t\t// limit < 1 means get all results\n\t\tremaining := 0\n\t\tif limit > 0 {\n\t\t\tremaining = limit - len(items)\n\t\t\tif remaining <= 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tbatch, err := get(page)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Take only what we need from this batch if limit > 0\n\t\tif limit > 0 && len(batch) > remaining {\n\t\t\tbatch = batch[:remaining]\n\t\t}\n\n\t\titems = append(items, batch...)\n\n\t\tif page == 1 {\n\t\t\tif len(batch) == 0 {\n\t\t\t\treturn items, nil\n\t\t\t}\n\t\t\tlenFirstBatch = len(batch)\n\t\t} else if len(batch) < lenFirstBatch || len(batch) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tpage++\n\t}\n\n\treturn items, nil\n}\n"
  },
  {
    "path": "shared/utils/paginate_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestPaginate(t *testing.T) {\n\t// Generic mock generator that can handle all cases\n\tcreateMock := func(pages [][]int) func(page int) []int {\n\t\treturn func(page int) []int {\n\t\t\tif page <= 0 {\n\t\t\t\tpage = 0\n\t\t\t} else {\n\t\t\t\tpage--\n\t\t\t}\n\n\t\t\tif page >= len(pages) {\n\t\t\t\treturn []int{}\n\t\t\t}\n\n\t\t\treturn pages[page]\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\tlimit    int\n\t\tpages    [][]int\n\t\texpected []int\n\t\tapiCalls int\n\t}{\n\t\t{\n\t\t\tname:     \"multiple pages\",\n\t\t\tlimit:    -1,\n\t\t\tpages:    [][]int{{11, 12, 13}, {21, 22, 23}, {31, 32}},\n\t\t\texpected: []int{11, 12, 13, 21, 22, 23, 31, 32},\n\t\t\tapiCalls: 3,\n\t\t},\n\t\t{\n\t\t\tname:     \"zero limit\",\n\t\t\tlimit:    0,\n\t\t\tpages:    [][]int{{1, 2, 3}, {1, 2, 3}, {1, 2, 3}},\n\t\t\texpected: []int{1, 2, 3, 1, 2, 3, 1, 2, 3},\n\t\t\tapiCalls: 4,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty result\",\n\t\t\tlimit:    5,\n\t\t\tpages:    [][]int{{}},\n\t\t\texpected: []int{},\n\t\t\tapiCalls: 1,\n\t\t},\n\t\t{\n\t\t\tname:     \"limit less than batch\",\n\t\t\tlimit:    2,\n\t\t\tpages:    [][]int{{1, 2, 3, 4, 5}},\n\t\t\texpected: []int{1, 2},\n\t\t\tapiCalls: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tapiExec := 0\n\t\t\tmock := createMock(tt.pages)\n\n\t\t\tresult, _ := Paginate(func(page int) ([]int, error) {\n\t\t\t\tapiExec++\n\t\t\t\treturn mock(page), nil\n\t\t\t}, tt.limit)\n\n\t\t\tassert.EqualValues(t, tt.apiCalls, apiExec)\n\t\t\tassert.EqualValues(t, tt.expected, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "shared/utils/protected.go",
    "content": "// Copyright 2026 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"sync\"\n)\n\n// Protected provides thread-safe read and write access to a value of type T.\ntype Protected[T any] interface {\n\t// Get returns the current value using a read lock, allowing multiple concurrent\n\t// readers. Safe to call from multiple goroutines simultaneously.\n\tGet() T\n\n\t// Set replaces the current value using an exclusive write lock.\n\t// Blocks until all ongoing reads/writes complete.\n\tSet(v T)\n\n\t// Update performs an atomic read-modify-write operation under a single exclusive\n\t// lock. The provided function receives the current value and returns the new value,\n\t// eliminating the race condition that would occur with a separate Get + Set.\n\tUpdate(fn func(T) T)\n}\n\ntype protected[T any] struct {\n\tmu    sync.RWMutex\n\tvalue T\n}\n\n// NewProtected creates and returns a new Protected wrapper initialized with the\n// given value. Use this as the constructor instead of creating a protected struct directly.\nfunc NewProtected[T any](initial T) Protected[T] {\n\treturn &protected[T]{value: initial}\n}\n\nfunc (p *protected[T]) Get() T {\n\tp.mu.RLock()\n\tdefer p.mu.RUnlock()\n\treturn p.value\n}\n\nfunc (p *protected[T]) Set(v T) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.value = v\n}\n\nfunc (p *protected[T]) Update(fn func(T) T) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.value = fn(p.value)\n}\n"
  },
  {
    "path": "shared/utils/slices.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\n// EqualSliceValues compare two slices if they have equal values independent of how they are sorted.\nfunc EqualSliceValues[E comparable](s1, s2 []E) bool {\n\tif len(s1) != len(s2) {\n\t\treturn false\n\t}\n\n\tm1 := sliceToCountMap(s1)\n\tm2 := sliceToCountMap(s2)\n\n\tfor k, v := range m1 {\n\t\tif m2[k] != v {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc sliceToCountMap[E comparable](list []E) map[E]int {\n\tm := make(map[E]int)\n\tfor i := range list {\n\t\tm[list[i]]++\n\t}\n\treturn m\n}\n\n// SliceToBoolMap is a helper function to convert a string slice to a map.\nfunc SliceToBoolMap(s []string) map[string]bool {\n\tv := map[string]bool{}\n\tfor _, ss := range s {\n\t\tif ss == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tv[ss] = true\n\t}\n\treturn v\n}\n\n// StringSliceDeleteEmpty removes empty strings from a string slice.\nfunc StringSliceDeleteEmpty(s []string) []string {\n\tr := make([]string, 0)\n\tfor _, str := range s {\n\t\tif str != \"\" {\n\t\t\tr = append(r, str)\n\t\t}\n\t}\n\treturn r\n}\n"
  },
  {
    "path": "shared/utils/slices_test.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestEqualSliceValues(t *testing.T) {\n\ttests := []struct {\n\t\tin1 []string\n\t\tin2 []string\n\t\tout bool\n\t}{{\n\t\tin1: []string{\"\", \"ab\", \"12\", \"ab\"},\n\t\tin2: []string{\"12\", \"ab\"},\n\t\tout: false,\n\t}, {\n\t\tin1: nil,\n\t\tin2: nil,\n\t\tout: true,\n\t}, {\n\t\tin1: []string{\"AA\", \"AA\", \"2\", \" \"},\n\t\tin2: []string{\"2\", \"AA\", \" \", \"AA\"},\n\t\tout: true,\n\t}, {\n\t\tin1: []string{\"AA\", \"AA\", \"2\", \" \"},\n\t\tin2: []string{\"2\", \"2\", \" \", \"AA\"},\n\t\tout: false,\n\t}}\n\n\tfor _, tc := range tests {\n\t\tassert.EqualValues(t, tc.out, EqualSliceValues(tc.in1, tc.in2), \"could not correctly process input: '%#v', %#v\", tc.in1, tc.in2)\n\t}\n\n\tassert.True(t, EqualSliceValues([]bool{true, false, false}, []bool{false, false, true}))\n\tassert.False(t, EqualSliceValues([]bool{true, false, false}, []bool{true, false, true}))\n}\n\nfunc TestSliceToBoolMap(t *testing.T) {\n\tassert.Equal(t, map[string]bool{\n\t\t\"a\": true,\n\t\t\"b\": true,\n\t\t\"c\": true,\n\t}, SliceToBoolMap([]string{\"a\", \"b\", \"c\"}))\n\tassert.Equal(t, map[string]bool{}, SliceToBoolMap([]string{}))\n\tassert.Equal(t, map[string]bool{}, SliceToBoolMap([]string{\"\"}))\n}\n\nfunc TestStringSliceDeleteEmpty(t *testing.T) {\n\ttests := []struct {\n\t\tin  []string\n\t\tout []string\n\t}{{\n\t\tin:  []string{\"\", \"ab\", \"ab\"},\n\t\tout: []string{\"ab\", \"ab\"},\n\t}, {\n\t\tin:  []string{\"\", \"ab\", \"\"},\n\t\tout: []string{\"ab\"},\n\t}, {\n\t\tin:  []string{\"\"},\n\t\tout: []string{},\n\t}}\n\n\tfor _, tc := range tests {\n\t\texp := StringSliceDeleteEmpty(tc.in)\n\t\tassert.EqualValues(t, tc.out, exp, \"got '%#v', expects %#v\", exp, tc.out)\n\t}\n}\n"
  },
  {
    "path": "shared/utils/strings.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\n// DeduplicateStrings deduplicate string list, empty items are dropped.\nfunc DeduplicateStrings(src []string) []string {\n\tm := make(map[string]struct{}, len(src))\n\tdst := make([]string, 0, len(src))\n\n\tfor _, v := range src {\n\t\t// Skip empty items\n\t\tif len(v) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip duplicates\n\t\tif _, ok := m[v]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tm[v] = struct{}{}\n\t\tdst = append(dst, v)\n\t}\n\n\treturn dst\n}\n"
  },
  {
    "path": "shared/utils/strings_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"sort\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestDeduplicateStrings(t *testing.T) {\n\ttests := []struct {\n\t\tin  []string\n\t\tout []string\n\t}{{\n\t\tin:  []string{\"\", \"ab\", \"12\", \"ab\"},\n\t\tout: []string{\"12\", \"ab\"},\n\t}, {\n\t\tin:  nil,\n\t\tout: nil,\n\t}, {\n\t\tin:  []string{\"\"},\n\t\tout: nil,\n\t}}\n\n\tfor _, tc := range tests {\n\t\tresult := DeduplicateStrings(tc.in)\n\t\tsort.Strings(result)\n\t\tif len(tc.out) == 0 {\n\t\t\tassert.Len(t, result, 0)\n\t\t} else {\n\t\t\tassert.EqualValues(t, tc.out, result, \"could not correctly process input '%#v'\", tc.in)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tools/tools.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build tools\n// +build tools\n\npackage main\n\nimport (\n\t_ \"github.com/getkin/kin-openapi/cmd/validate\"\n\t_ \"github.com/swaggo/swag/cmd/swag\"\n)\n"
  },
  {
    "path": "version/version.go",
    "content": "// Copyright 2019 Laszlo Fogas\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// cSpell:ignore ldflags\n\npackage version\n\n// Version of Woodpecker, set with ldflags, from Git tag.\nvar Version string\n\n// String returns the Version set at build time or \"dev\".\nfunc String() string {\n\tif Version == \"\" {\n\t\treturn \"dev\"\n\t}\n\n\treturn Version\n}\n"
  },
  {
    "path": "web/.gitignore",
    "content": "node_modules\n.DS_Store\ndist\ndist-ssr\n*.local\n"
  },
  {
    "path": "web/.prettierignore",
    "content": ".pnpm-store/\npnpm-lock.yaml\ndist\ncoverage/\nLICENSE\ncomponents.d.ts\nsrc/assets/locales/*.json\n!src/assets/locales/en.json\n"
  },
  {
    "path": "web/.prettierrc.js",
    "content": "import { readFile } from 'node:fs/promises';\n\n// eslint-disable-next-line antfu/no-top-level-await\nconst config = JSON.parse(await readFile(new URL('../.prettierrc.json', import.meta.url)));\n\nexport default {\n  ...config,\n  plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'],\n  importOrder: [\n    '<THIRD_PARTY_MODULES>', // Imports not matched by other special words or groups.\n    '', // Empty string will match any import not matched by other special words or groups.\n    '^(#|@|~|\\\\$)(/.*)$',\n    '',\n    '^[./]',\n  ],\n};\n"
  },
  {
    "path": "web/.yamlignore",
    "content": ".pnpm-lock.yaml\n"
  },
  {
    "path": "web/LICENSE",
    "content": "Copyright 2017 Drone.IO Inc\nCopyright 2019 Laszlo Fogas\nCopyright 2020 Woodpecker Authors\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n---\n\nWoodpecker icon by Georgiana Ionescu from the Noun Project\nLicensed as Creative Commons CC BY\nhttps://thenounproject.com/term/woodpecker/1761314/\n"
  },
  {
    "path": "web/components.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\nexport {}\n\ndeclare module 'vue' {\n  export interface GlobalComponents {\n    ActionsTab: typeof import('./src/components/repo/settings/ActionsTab.vue')['default']\n    ActivePipelines: typeof import('./src/components/layout/header/ActivePipelines.vue')['default']\n    AdminAgentsTab: typeof import('./src/components/admin/settings/AdminAgentsTab.vue')['default']\n    AdminInfoTab: typeof import('./src/components/admin/settings/AdminInfoTab.vue')['default']\n    AdminOrgsTab: typeof import('./src/components/admin/settings/AdminOrgsTab.vue')['default']\n    AdminQueueStats: typeof import('./src/components/admin/settings/queue/AdminQueueStats.vue')['default']\n    AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default']\n    AdminRegistriesTab: typeof import('./src/components/admin/settings/AdminRegistriesTab.vue')['default']\n    AdminReposTab: typeof import('./src/components/admin/settings/AdminReposTab.vue')['default']\n    AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default']\n    AdminUsersTab: typeof import('./src/components/admin/settings/AdminUsersTab.vue')['default']\n    Badge: typeof import('./src/components/atomic/Badge.vue')['default']\n    BadgeTab: typeof import('./src/components/repo/settings/BadgeTab.vue')['default']\n    Button: typeof import('./src/components/atomic/Button.vue')['default']\n    Checkbox: typeof import('./src/components/form/Checkbox.vue')['default']\n    CheckboxesField: typeof import('./src/components/form/CheckboxesField.vue')['default']\n    CodeBox: typeof import('./src/components/layout/CodeBox.vue')['default']\n    Container: typeof import('./src/components/layout/Container.vue')['default']\n    CronTab: typeof import('./src/components/repo/settings/CronTab.vue')['default']\n    DeployPipelinePopup: typeof import('./src/components/layout/popups/DeployPipelinePopup.vue')['default']\n    DocsLink: typeof import('./src/components/atomic/DocsLink.vue')['default']\n    Error: typeof import('./src/components/atomic/Error.vue')['default']\n    ExtensionsTab: typeof import('./src/components/repo/settings/ExtensionsTab.vue')['default']\n    GeneralTab: typeof import('./src/components/repo/settings/GeneralTab.vue')['default']\n    Header: typeof import('./src/components/layout/scaffold/Header.vue')['default']\n    IBiCheckCircleFill: typeof import('~icons/bi/check-circle-fill')['default']\n    IBiExclamationTriangle: typeof import('~icons/bi/exclamation-triangle')['default']\n    IBiExclamationTriangleFill: typeof import('~icons/bi/exclamation-triangle-fill')['default']\n    IBiSlashCircleFill: typeof import('~icons/bi/slash-circle-fill')['default']\n    IBxBxPowerOff: typeof import('~icons/bx/bx-power-off')['default']\n    ICarbonCloseOutline: typeof import('~icons/carbon/close-outline')['default']\n    IClarityDeployLine: typeof import('~icons/clarity/deploy-line')['default']\n    IClaritySettingsSolid: typeof import('~icons/clarity/settings-solid')['default']\n    Icon: typeof import('./src/components/atomic/Icon.vue')['default']\n    IconButton: typeof import('./src/components/atomic/IconButton.vue')['default']\n    IGgTrash: typeof import('~icons/gg/trash')['default']\n    IIcBaselineDarkMode: typeof import('~icons/ic/baseline-dark-mode')['default']\n    IIcBaselineDownloadForOffline: typeof import('~icons/ic/baseline-download-for-offline')['default']\n    IIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default']\n    IIcBaselineFileDownload: typeof import('~icons/ic/baseline-file-download')['default']\n    IIcBaselineFileDownloadOff: typeof import('~icons/ic/baseline-file-download-off')['default']\n    IIcBaselineHealing: typeof import('~icons/ic/baseline-healing')['default']\n    IIcBaselinePause: typeof import('~icons/ic/baseline-pause')['default']\n    IIcBaselinePlayArrow: typeof import('~icons/ic/baseline-play-arrow')['default']\n    IIconoirArrowLeft: typeof import('~icons/iconoir/arrow-left')['default']\n    IIconParkOutlineAlarmClock: typeof import('~icons/icon-park-outline/alarm-clock')['default']\n    IIcRoundLightMode: typeof import('~icons/ic/round-light-mode')['default']\n    IIcSharpTimelapse: typeof import('~icons/ic/sharp-timelapse')['default']\n    IIcTwotoneAdd: typeof import('~icons/ic/twotone-add')['default']\n    ILaTimes: typeof import('~icons/la/times')['default']\n    IMdiBitbucket: typeof import('~icons/mdi/bitbucket')['default']\n    IMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']\n    IMdiClockTimeEightOutline: typeof import('~icons/mdi/clock-time-eight-outline')['default']\n    IMdiCloseThick: typeof import('~icons/mdi/close-thick')['default']\n    IMdiErrorOutline: typeof import('~icons/mdi/error-outline')['default']\n    IMdiFormatListBulleted: typeof import('~icons/mdi/format-list-bulleted')['default']\n    IMdiGestureTap: typeof import('~icons/mdi/gesture-tap')['default']\n    IMdiGithub: typeof import('~icons/mdi/github')['default']\n    IMdiLoading: typeof import('~icons/mdi/loading')['default']\n    IMdiPlay: typeof import('~icons/mdi/play')['default']\n    IMdiRadioboxBlank: typeof import('~icons/mdi/radiobox-blank')['default']\n    IMdiRadioboxIndeterminateVariant: typeof import('~icons/mdi/radiobox-indeterminate-variant')['default']\n    IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default']\n    IMdiSourceCommit: typeof import('~icons/mdi/source-commit')['default']\n    IMdiSourceMerge: typeof import('~icons/mdi/source-merge')['default']\n    IMdiSourcePull: typeof import('~icons/mdi/source-pull')['default']\n    IMdiStop: typeof import('~icons/mdi/stop')['default']\n    IMdiSync: typeof import('~icons/mdi/sync')['default']\n    IMdiTagOutline: typeof import('~icons/mdi/tag-outline')['default']\n    InputField: typeof import('./src/components/form/InputField.vue')['default']\n    IPhGitlabLogoSimpleFill: typeof import('~icons/ph/gitlab-logo-simple-fill')['default']\n    ISimpleIconsForgejo: typeof import('~icons/simple-icons/forgejo')['default']\n    ISimpleIconsGitea: typeof import('~icons/simple-icons/gitea')['default']\n    ISvgSpinners180RingWithBg: typeof import('~icons/svg-spinners/180-ring-with-bg')['default']\n    ITeenyiconsGitSolid: typeof import('~icons/teenyicons/git-solid')['default']\n    ITeenyiconsRefreshOutline: typeof import('~icons/teenyicons/refresh-outline')['default']\n    IVaadinQuestionCircleO: typeof import('~icons/vaadin/question-circle-o')['default']\n    ListItem: typeof import('./src/components/atomic/ListItem.vue')['default']\n    ManualPipelinePopup: typeof import('./src/components/layout/popups/ManualPipelinePopup.vue')['default']\n    Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default']\n    NumberField: typeof import('./src/components/form/NumberField.vue')['default']\n    OrgRegistriesTab: typeof import('./src/components/org/settings/OrgRegistriesTab.vue')['default']\n    OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default']\n    Panel: typeof import('./src/components/layout/Panel.vue')['default']\n    PipelineFeedItem: typeof import('./src/components/pipeline-feed/PipelineFeedItem.vue')['default']\n    PipelineFeedSidebar: typeof import('./src/components/pipeline-feed/PipelineFeedSidebar.vue')['default']\n    PipelineItem: typeof import('./src/components/repo/pipeline/PipelineItem.vue')['default']\n    PipelineList: typeof import('./src/components/repo/pipeline/PipelineList.vue')['default']\n    PipelineLog: typeof import('./src/components/repo/pipeline/PipelineLog.vue')['default']\n    PipelineRunningIcon: typeof import('./src/components/repo/pipeline/PipelineRunningIcon.vue')['default']\n    PipelineStatusIcon: typeof import('./src/components/repo/pipeline/PipelineStatusIcon.vue')['default']\n    PipelineStepDuration: typeof import('./src/components/repo/pipeline/PipelineStepDuration.vue')['default']\n    PipelineStepList: typeof import('./src/components/repo/pipeline/PipelineStepList.vue')['default']\n    Popup: typeof import('./src/components/layout/Popup.vue')['default']\n    RadioField: typeof import('./src/components/form/RadioField.vue')['default']\n    RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default']\n    RegistryEdit: typeof import('./src/components/registry/RegistryEdit.vue')['default']\n    RegistryList: typeof import('./src/components/registry/RegistryList.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    Scaffold: typeof import('./src/components/layout/scaffold/Scaffold.vue')['default']\n    SecretEdit: typeof import('./src/components/secrets/SecretEdit.vue')['default']\n    SecretList: typeof import('./src/components/secrets/SecretList.vue')['default']\n    SecretsTab: typeof import('./src/components/repo/settings/SecretsTab.vue')['default']\n    SelectField: typeof import('./src/components/form/SelectField.vue')['default']\n    Settings: typeof import('./src/components/layout/Settings.vue')['default']\n    Tab: typeof import('./src/components/layout/scaffold/Tab.vue')['default']\n    Tabs: typeof import('./src/components/layout/scaffold/Tabs.vue')['default']\n    TextField: typeof import('./src/components/form/TextField.vue')['default']\n    UserCLIAndAPITab: typeof import('./src/components/user/UserCLIAndAPITab.vue')['default']\n    UserGeneralTab: typeof import('./src/components/user/UserGeneralTab.vue')['default']\n    UserRegistriesTab: typeof import('./src/components/user/UserRegistriesTab.vue')['default']\n    UserSecretsTab: typeof import('./src/components/user/UserSecretsTab.vue')['default']\n    Warning: typeof import('./src/components/atomic/Warning.vue')['default']\n  }\n}\n"
  },
  {
    "path": "web/eslint.config.js",
    "content": "// cSpell:ignore tseslint\n// @ts-check\n\nimport antfu from '@antfu/eslint-config';\nimport js from '@eslint/js';\nimport vueI18n from '@intlify/eslint-plugin-vue-i18n';\nimport eslintPromise from 'eslint-plugin-promise';\nimport eslintPluginVueScopedCSS from 'eslint-plugin-vue-scoped-css';\n\nexport default antfu(\n  {\n    stylistic: false,\n    typescript: {\n      tsconfigPath: './tsconfig.json',\n    },\n    vue: true,\n\n    // Disable jsonc and yaml support\n    jsonc: false,\n    yaml: false,\n  },\n\n  js.configs.recommended,\n  eslintPromise.configs['flat/recommended'],\n  ...eslintPluginVueScopedCSS.configs['flat/recommended'],\n  ...vueI18n.configs['flat/recommended'],\n\n  {\n    rules: {\n      'import/order': 'off',\n      'sort-imports': 'off',\n      'perfectionist/sort-imports': 'off',\n      'perfectionist/sort-named-imports': 'off',\n      'promise/prefer-await-to-callbacks': 'error',\n      'vue-scoped-css/no-parsing-error': 'off',\n\n      // Vue I18n\n      '@intlify/vue-i18n/no-raw-text': [\n        'error',\n        {\n          attributes: {\n            '/.+/': ['label'],\n          },\n        },\n      ],\n      '@intlify/vue-i18n/key-format-style': ['error', 'snake_case'],\n      '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',\n      '@intlify/vue-i18n/no-dynamic-keys': 'error',\n      '@intlify/vue-i18n/no-deprecated-i18n-component': 'error',\n      '@intlify/vue-i18n/no-deprecated-tc': 'error',\n      '@intlify/vue-i18n/no-i18n-t-path-prop': 'error',\n      '@intlify/vue-i18n/no-missing-keys-in-other-locales': 'off',\n      '@intlify/vue-i18n/valid-message-syntax': 'error',\n      '@intlify/vue-i18n/no-missing-keys': 'error',\n      '@intlify/vue-i18n/no-unknown-locale': 'error',\n      '@intlify/vue-i18n/no-unused-keys': ['error', { extensions: ['.ts', '.vue'] }],\n      '@intlify/vue-i18n/prefer-sfc-lang-attr': 'error',\n      '@intlify/vue-i18n/no-html-messages': 'error',\n      '@intlify/vue-i18n/prefer-linked-key-with-paren': 'error',\n      '@intlify/vue-i18n/sfc-locale-attr': 'error',\n    },\n    settings: {\n      // Vue I18n\n      'vue-i18n': {\n        localeDir: './src/assets/locales/en.json',\n        // Specify the version of `vue-i18n` you are using.\n        // If not specified, the message will be parsed twice.\n        messageSyntaxVersion: '^9.0.0',\n      },\n    },\n  },\n\n  // Vue\n  {\n    files: ['**/*.vue'],\n    rules: {\n      'vue/multi-word-component-names': 'off',\n      'vue/html-self-closing': [\n        'error',\n        {\n          html: {\n            void: 'always',\n            normal: 'always',\n            component: 'always',\n          },\n          svg: 'always',\n          math: 'always',\n        },\n      ],\n      'vue/html-indent': 'off',\n      'vue/block-order': [\n        'error',\n        {\n          order: ['template', 'script', 'style'],\n        },\n      ],\n      'vue/singleline-html-element-content-newline': ['off'],\n      'no-useless-assignment': ['off'],\n    },\n  },\n\n  // Ignore list\n  {\n    ignores: [\n      'dist',\n      'coverage/',\n      'package.json',\n      'tsconfig.eslint.json',\n      'tsconfig.json',\n      'src/assets/locales/**/*',\n      '!src/assets/locales/en.json',\n      'components.d.ts',\n    ],\n  },\n);\n"
  },
  {
    "path": "web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"alternate icon\" type=\"image/png\" href=\"/favicons/favicon-light-default.png\" id=\"favicon-png\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicons/favicon-light-default.svg\" id=\"favicon-svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"theme-color\" content=\"#65a30d\" />\n    <title>Woodpecker</title>\n    <script type=\"\" src=\"/web-config.js\"></script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n    <script type=\"application/javascript\" src=\"/assets/custom.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"woodpecker-ci\",\n  \"author\": \"Woodpecker CI\",\n  \"version\": \"0.0.0\",\n  \"license\": \"Apache-2.0\",\n  \"packageManager\": \"pnpm@10.33.4\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=20\"\n  },\n  \"scripts\": {\n    \"start\": \"vite\",\n    \"build\": \"vite build --base=/BASE_PATH\",\n    \"serve\": \"vite preview\",\n    \"lint\": \"eslint --max-warnings 0 .\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier -c .\",\n    \"typecheck\": \"vue-tsc --noEmit\",\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"@kyvg/vue3-notification\": \"^3.4.2\",\n    \"@mdi/js\": \"^7.4.47\",\n    \"@vueuse/core\": \"^14.2.1\",\n    \"ansi_up\": \"^6.0.6\",\n    \"dompurify\": \"^3.4.0\",\n    \"fuse.js\": \"^7.3.0\",\n    \"js-base64\": \"^3.7.8\",\n    \"marked\": \"^18.0.0\",\n    \"node-emoji\": \"^2.2.0\",\n    \"pinia\": \"^3.0.4\",\n    \"prismjs\": \"^1.30.0\",\n    \"semver\": \"^7.7.4\",\n    \"simple-icons\": \"^16.16.0\",\n    \"tailwindcss\": \"^4.2.2\",\n    \"vue\": \"^3.5.32\",\n    \"vue-i18n\": \"^11.3.2\",\n    \"vue-router\": \"^5.0.4\"\n  },\n  \"devDependencies\": {\n    \"@antfu/eslint-config\": \"^8.2.0\",\n    \"@eslint/js\": \"^10.0.1\",\n    \"@ianvs/prettier-plugin-sort-imports\": \"^4.7.1\",\n    \"@intlify/eslint-plugin-vue-i18n\": \"4.3.0\",\n    \"@intlify/unplugin-vue-i18n\": \"^11.0.7\",\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"@tailwindcss/vite\": \"4.3.0\",\n    \"@types/node\": \"^24.12.2\",\n    \"@types/prismjs\": \"^1.26.6\",\n    \"@types/semver\": \"^7.7.1\",\n    \"@types/tinycolor2\": \"^1.4.6\",\n    \"@vitejs/plugin-vue\": \"^6.0.6\",\n    \"@vue/compiler-sfc\": \"^3.5.32\",\n    \"@vue/test-utils\": \"^2.4.6\",\n    \"dotenv\": \"^17.4.2\",\n    \"eslint\": \"^10.2.0\",\n    \"eslint-plugin-promise\": \"^7.2.1\",\n    \"eslint-plugin-vue-scoped-css\": \"^3.0.0\",\n    \"jsdom\": \"^29.0.2\",\n    \"prettier\": \"^3.8.3\",\n    \"prettier-plugin-tailwindcss\": \"^0.8.0\",\n    \"tinycolor2\": \"^1.6.0\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"6.0.3\",\n    \"vite\": \"^8.0.5\",\n    \"vite-plugin-prismjs\": \"^0.0.11\",\n    \"vite-svg-loader\": \"^5.1.1\",\n    \"vitest\": \"^4.1.4\",\n    \"vue-tsc\": \"^3.2.6\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"semver@<7.5.2\": \">=7.5.2\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/src/App.vue",
    "content": "<template>\n  <div class=\"app bg-wp-background-100 dark:bg-wp-background-300 m-auto flex h-full w-full flex-col\">\n    <router-view v-if=\"layout === 'blank'\" />\n    <template v-else>\n      <Navbar />\n      <main class=\"relative flex h-full min-h-0\">\n        <div id=\"scroll-component\" class=\"flex grow flex-col overflow-y-auto\">\n          <router-view />\n        </div>\n        <transition name=\"slide-right\">\n          <PipelineFeedSidebar\n            class=\"dark:shadow-wp-background-500 absolute top-0 right-0 bottom-0 w-full max-w-80 border-l shadow-lg xl:max-w-96\"\n          />\n        </transition>\n      </main>\n    </template>\n    <notifications position=\"bottom right\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute } from 'vue-router';\n\nimport Navbar from '~/components/layout/header/Navbar.vue';\nimport PipelineFeedSidebar from '~/components/pipeline-feed/PipelineFeedSidebar.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useNotifications from '~/compositions/useNotifications';\n\nconst route = useRoute();\nconst apiClient = useApiClient();\nconst { notify } = useNotifications();\nconst i18n = useI18n();\n\n// eslint-disable-next-line promise/prefer-await-to-callbacks\napiClient.setErrorHandler((err) => {\n  if (err.status === 404) {\n    notify({ title: i18n.t('errors.not_found'), type: 'error' });\n    return;\n  }\n  notify({ title: err.message || i18n.t('unknown_error'), type: 'error' });\n});\n\nconst layout = computed(() => route.meta.layout ?? 'default');\n\nconst { locale } = useI18n();\nwatch(\n  locale,\n  () => {\n    document.documentElement.setAttribute('lang', locale.value);\n  },\n  { immediate: true },\n);\n</script>\n\n<style scoped>\n.app {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n#scroll-component {\n  scrollbar-gutter: stable;\n}\n\n.slide-right-enter-active,\n.slide-right-leave-active {\n  transition: all 0.3s ease;\n}\n.slide-right-enter-from,\n.slide-right-leave-to {\n  transform: translate(100%, 0);\n}\n</style>\n"
  },
  {
    "path": "web/src/assets/locales/bar.json",
    "content": "{\n    \"repo\": {\n        \"deploy_pipeline\": {\n            \"trigger\": \"Ausrolln\",\n            \"variables\": {\n                \"delete\": \"Variable löschn\",\n                \"title\": \"Zusätzliche Pipeline-Variabln\",\n                \"desc\": \"Gib extra Variabln o de wo in deina Pipeline verwendt wern. Variabln mit'm gleichn Nama wern überschriebn.\",\n                \"name\": \"Variabl-Nama\",\n                \"value\": \"Variabl-Wert\"\n            },\n            \"title\": \"Deployment füa aktuelle Pipeline #{pipelineId} lostreten\",\n            \"enter_target\": \"Zielumgebung füas Deployment\",\n            \"enter_task\": \"Deployment-Aufgab\"\n        },\n        \"enable\": {\n            \"success\": \"Repo is jez o\",\n            \"enable\": \"Oschoidn\",\n            \"enabled\": \"Is scho o\",\n            \"disabled\": \"Is aus\",\n            \"new_forge_repo\": \"Neis Repo auf da Forge\",\n            \"stale_wp_repo\": \"Oids Woodpecker Repo\",\n            \"conflict\": \"Konflikt\",\n            \"conflict_desc\": \"Des Repo is auf da Forge mid na nein ID erstäid woan, aba in Woodpecker is no a oids repo mim selbn Nama gspeichert. Entweda schmeist's oide weg und aktivierst's neue, oda du reparierst's alte.\",\n            \"forge_repo_missing\": \"Forge repo is ned do!\"\n        },\n        \"visibility\": {\n            \"public\": {\n                \"public\": \"Für olle\",\n                \"desc\": \"Jeda ko dei Repo seng, a ohne dass er eigloggt is.\"\n            },\n            \"visibility\": \"Wer kos seng\",\n            \"private\": {\n                \"private\": \"Privat\",\n                \"desc\": \"Nur du und de andern Besitzer vom Repo kennan des seng.\"\n            },\n            \"internal\": {\n                \"internal\": \"Intern\",\n                \"desc\": \"Nur eigloggte Leit vo da Woodpecker-Instanz kennan des seng.\"\n            }\n        },\n        \"pipeline\": {\n            \"pipelines_for\": \"Pipelines fia Branch \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Pipelines fia Pull Request #{index}\",\n            \"exit_code\": \"Exit-Code {exitCode}\",\n            \"loading\": \"Lod grod…\",\n            \"no_logs\": \"Koane Logs\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"log_title\": \"Schrid-Logs\",\n            \"log_download_error\": \"Beim Runterladn vom Log-File is wos schiaf ganga\",\n            \"protected\": {\n                \"decline\": \"Oblehnen\",\n                \"awaits\": \"De Pipeline dritschld auf Freigab von am Maintainer!\",\n                \"approve\": \"Freigem\",\n                \"declined\": \"De Pipeline wurad obglehnt!\",\n                \"approve_success\": \"Pipeline freigem\",\n                \"decline_success\": \"Pipeline obglehnt\"\n            },\n            \"tasks\": \"Aufgabn\",\n            \"config\": \"Einstellung\",\n            \"files\": \"Gänderte Datein\",\n            \"no_pipelines\": \"Do san no koane Pipelines gstartet worn.\",\n            \"no_pipeline_steps\": \"Koane Pipeline-Schrid do!\",\n            \"step_not_started\": \"Der Schrid hod no ned ogfangd.\",\n            \"log_delete_confirm\": \"Wuisd du wirkli de Schrid-Logs löschn?\",\n            \"log_delete_error\": \"'Da ganze Bus is hi!'\",\n            \"actions\": {\n                \"cancel\": \"Abbrechn\",\n                \"restart\": \"Neistarten\",\n                \"canceled\": \"Der Schrid wurad abbrocha.\",\n                \"cancel_success\": \"Pipeline abbrocha\",\n                \"deploy\": \"Ausrolln\",\n                \"restart_success\": \"Pipeline neigstartet\",\n                \"log_download\": \"Runterladn\",\n                \"log_delete\": \"Löschn\",\n                \"log_auto_scroll\": \"Automatisch scrollen oschoidn\",\n                \"log_auto_scroll_off\": \"Automatisch scrollen ausschoidn\",\n                \"skipped\": \"Da Schrid is ausglosn woan.\"\n            },\n            \"event\": {\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"pr\": \"Pull Request\",\n                \"pr_closed\": \"Pull Request g'merged/zuagmacht\",\n                \"pr_metadata\": \"Pull Request-Metadaten gändert\",\n                \"deploy\": \"Deploy\",\n                \"cron\": \"Cron\",\n                \"manual\": \"Vo Hand\",\n                \"release\": \"Release\"\n            },\n            \"status\": {\n                \"status\": \"Status: {status}\",\n                \"blocked\": \"blockiert\",\n                \"pending\": \"dritschld\",\n                \"running\": \"lafts\",\n                \"started\": \"gstartet\",\n                \"skipped\": \"übersprungen\",\n                \"success\": \"hods gschafft\",\n                \"declined\": \"obglehnt\",\n                \"error\": \"Fella\",\n                \"failure\": \"is schiaf ganga\",\n                \"killed\": \"obgwürgt\",\n                \"canceled\": \"abgwürgt\"\n            },\n            \"errors\": \"Fella\",\n            \"warnings\": \"Warnunga\",\n            \"show_errors\": \"Fella zoang\",\n            \"we_got_some_errors\": \"Oh na, do hamma aba sauba z diaf ins glas gschaud!\",\n            \"duration\": \"Pipeline-Dauer: {duration}\",\n            \"created\": \"Erstäid: {created}\",\n            \"debug\": {\n                \"title\": \"Debug\",\n                \"download_metadata\": \"Metadaten runterladn\",\n                \"metadata_download_error\": \"Fella beim Runterladn vo de Metadaten\",\n                \"metadata_download_successful\": \"Metadaten erfoigreich runtergladen\",\n                \"no_permission\": \"Du deafst de Debug-Infos ned oseng\",\n                \"metadata_exec_title\": \"Pipeline lokal neistarten\",\n                \"metadata_exec_desc\": \"Load de Metadaten vo dera Pipeline runter um se lokal laufa zu lassn. So kost Probleme fixen und Änderunga testn bevors'd sie commitest. De Woodpecker CLI muas lokal in da gleichn Version wia da Server sei.\"\n            },\n            \"view\": \"Pipeline oschaun\",\n            \"load_more\": \"Zoag mea o\",\n            \"cancel_info\": {\n                \"superseded_by\": \"Ersetzt duach #{pipelineId}\",\n                \"canceled_by_user\": \"Abgewürgt duach {user}\",\n                \"canceled_by_step\": \"Abgwürgt wenga {step}\"\n            },\n            \"version\": \"De Woodpecker Version auf dea die Pipeline ausgefüad wurde.\",\n            \"version_header\": \"Woodpecker Version\"\n        },\n        \"manual_pipeline\": {\n            \"title\": \"Pipeline vo Hand starten\",\n            \"trigger\": \"Pipeline loslassn\",\n            \"select_branch\": \"Branch auswähln\",\n            \"variables\": {\n                \"delete\": \"Variable löschn\",\n                \"title\": \"Zusätzliche Pipeline-Variabln\",\n                \"desc\": \"Gib extra Variabln o de wo in deina Pipeline verwendt wern. Variabln mid'm gleichn Nama wern überschriebn.\",\n                \"name\": \"Variabl-Nama\",\n                \"value\": \"Variabl-Wert\"\n            },\n            \"show_pipelines\": \"Pipelines zoang\",\n            \"no_manual_workflows\": \"Koane basadn Workflows gfundn. Bas auf das zumindest oa Workflow auf des von hand lafa Ereignis head.\"\n        },\n        \"activity\": \"Ois da Reih nach\",\n        \"branches\": \"Branches\",\n        \"pull_requests\": \"Pull Requests\",\n        \"add\": \"Repo dazua doa\",\n        \"user_none\": \"De Organisation/da User hod no koane Projekte\",\n        \"not_allowed\": \"Du deafst do ned nei\",\n        \"open_in_forge\": \"Repo in da Forge aufmacha\",\n        \"settings\": {\n            \"not_allowed\": \"Du deafst de Eistellunga von dem Repo ned ändarn\",\n            \"general\": {\n                \"general\": \"Repo\",\n                \"project\": \"Repo-Eistellunga\",\n                \"save\": \"Eistellunga speichan\",\n                \"success\": \"Repo-Eistellunga gändert\",\n                \"pipeline_path\": {\n                    \"path\": \"Pipeline-Pfad\",\n                    \"default\": \"Standardmäßig: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Pfad zu deina Pipeline-Config (zum Beispui {0}). Ordner soin mit am {1} aufhörn.\",\n                    \"desc_path_example\": \"mei/pfad/\"\n                },\n                \"allow_pr\": {\n                    \"allow\": \"Pull Requests erlaubn\",\n                    \"desc\": \"Pipelines bei Pull Requests erlaubn.\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Deployments erlaubn\",\n                    \"desc\": \"Deployments füa erfoigreiche Pipelines erlaubn. Olle Leit mit Push-Rechte kennan des auslösn, also aufpassn!\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Vatrauenswürdige Clone-Plugins\",\n                    \"desc\": \"Plugins de wo Zuagriff auf netrc-Credentials kriagn zum Repos vo da Forge klonen oda eineschiabm.\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Vatrauenswürdig\",\n                    \"network\": {\n                        \"network\": \"Netzwerk\",\n                        \"desc\": \"Pipeline-Container kriagn Zuagriff auf Netzwerk-Privilegien wia DNS ändern.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumes\",\n                        \"desc\": \"Pipeline-Container derfa Volumes mounten.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sicherheit\",\n                        \"desc\": \"Pipeline-Container kriagn Zuagriff auf Sicherheits-Privilegien.\"\n                    }\n                },\n                \"timeout\": {\n                    \"timeout\": \"Zeitlimit\",\n                    \"minutes\": \"Minutn\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Vorherige Pipelines bleim losn\",\n                    \"desc\": \"Ausgwäids Events brechan dritschlde oder los glaffane Pipelines vom gleichn Event und Quain o.\"\n                }\n            },\n            \"crons\": {\n                \"crons\": \"Crons\",\n                \"desc\": \"Cron-Jobs kennan verwendt wern zum Pipelines regelmäßig los laffa losn.\",\n                \"show\": \"Crons zoang\",\n                \"add\": \"Cron dazua doa\",\n                \"none\": \"Do gibts no koane Crons.\",\n                \"save\": \"Cron speichan\",\n                \"created\": \"Cron erstäid\",\n                \"saved\": \"Cron gspeichert\",\n                \"deleted\": \"Cron glöscht\",\n                \"next_exec\": \"Nächste Ausführung\",\n                \"not_executed_yet\": \"No nia gloffa\",\n                \"run\": \"Jez starten\",\n                \"branch\": {\n                    \"title\": \"Branch\",\n                    \"placeholder\": \"Branch (nimmt Standard-Branch wenns la is)\"\n                },\n                \"name\": {\n                    \"name\": \"Nama\",\n                    \"placeholder\": \"Nama vom Cron-Job\"\n                },\n                \"schedule\": {\n                    \"title\": \"Zeitplan (basiert auf UTC)\",\n                    \"placeholder\": \"Zeitplan\"\n                },\n                \"edit\": \"Cron endan\",\n                \"delete\": \"Cron löschn\",\n                \"enabled\": \"Eigscheudn\"\n            },\n            \"badge\": {\n                \"badge\": \"Plakettl\",\n                \"type\": \"Syntax\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"branch\": \"Branch\",\n                \"events\": \"Events\",\n                \"step\": \"Schrid\",\n                \"workflow\": \"Workflow\"\n            },\n            \"actions\": {\n                \"actions\": \"Aktiona\",\n                \"repair\": {\n                    \"repair\": \"Repo rebarieren\",\n                    \"success\": \"Repo rebariert\"\n                },\n                \"disable\": {\n                    \"disable\": \"Repo abschoidn\",\n                    \"success\": \"Repo abgschoidn\"\n                },\n                \"enable\": {\n                    \"enable\": \"Repo oschoidn\",\n                    \"success\": \"Repo ogschoidn\"\n                },\n                \"delete\": {\n                    \"delete\": \"Repo löschn\",\n                    \"confirm\": \"Ois is weg noch da Aktion!\\n\\nWuisd du wirkli weida macha?\",\n                    \"success\": \"Repo glöscht\"\n                }\n            }\n        }\n    },\n    \"admin\": {\n        \"settings\": {\n            \"repos\": {\n                \"repos\": \"Repos\",\n                \"desc\": \"Repos de wo auf dem Server ogschoidn san oda warn.\",\n                \"none\": \"Do gibts no koane Repos.\",\n                \"view\": \"Repo oschaun\",\n                \"settings\": \"Repo-Eistellunga\",\n                \"disabled\": \"ab gschoidn\",\n                \"repair\": {\n                    \"repair\": \"Olle rebarieren\",\n                    \"success\": \"Repos rebariert\"\n                }\n            },\n            \"agents\": {\n                \"created\": \"Agent erstäid\",\n                \"saved\": \"Agent gspeichert\",\n                \"deleted\": \"Agent glöscht\",\n                \"name\": {\n                    \"name\": \"Nama\",\n                    \"placeholder\": \"Nama vom Agent\"\n                },\n                \"agents\": \"Agents\",\n                \"desc\": \"Agents de wo auf dem Server registriert san.\",\n                \"none\": \"Do gibts no koane Agents.\",\n                \"id\": \"ID\",\n                \"add\": \"Agent dazua doa\",\n                \"save\": \"Agent speichan\",\n                \"show\": \"Agents zoang\",\n                \"no_schedule\": {\n                    \"name\": \"Agent abschoidn\",\n                    \"placeholder\": \"Agent griagt koane neien Aufgabn mehr\"\n                },\n                \"token\": \"Token\",\n                \"platform\": {\n                    \"platform\": \"Plattform\",\n                    \"badge\": \"plattform\"\n                },\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"Kapazität\",\n                    \"desc\": \"Maximale Anzoi vo gleichzeidige Pipelines de wo der Agent ausführt.\",\n                    \"badge\": \"kapazität\"\n                },\n                \"custom_labels\": {\n                    \"custom_labels\": \"Eigene Labels\",\n                    \"desc\": \"De eigenen Labels de wo vom Agent-Admin beim Starten gsetzt worn san.\"\n                },\n                \"org\": {\n                    \"badge\": \"org\"\n                },\n                \"version\": \"Version\",\n                \"last_contact\": {\n                    \"last_contact\": \"z'letzt gesng\",\n                    \"badge\": \"z'letzt gesng\"\n                },\n                \"never\": \"Nia\",\n                \"delete_confirm\": \"Wuisd du wirkli den Agent löschn? Er ko dann nahad nimmer mid'm Server obandln.\",\n                \"edit_agent\": \"Agent beabadn\",\n                \"delete_agent\": \"Agent löschn\"\n            },\n            \"settings\": \"Admin-Eistellunga\",\n            \"not_allowed\": \"Du deafst de Server-Eistellunga ned ändarn\",\n            \"secrets\": {\n                \"desc\": \"Globale Secrets kennan in de Pipelines vo olle Repos verwendt wern.\",\n                \"warning\": \"De Secrets san füa olle User verfügbar.\"\n            },\n            \"registries\": {\n                \"desc\": \"Globale Registry-Credentials kennan dann dazuagfügt wern zum private Images füa olle Pipelines verwenden.\",\n                \"warning\": \"De Registry-Credentials san füa olle User verfügbar.\"\n            },\n            \"queue\": {\n                \"queue\": \"Warteschlong\",\n                \"desc\": \"Aufgabn de wo drauf wartn dass de Agents se ausführn.\",\n                \"pause\": \"Pausieren\",\n                \"resume\": \"Weida macha\",\n                \"paused\": \"Warteschlong is pausiert\",\n                \"resumed\": \"Warteschlong laft wieda\",\n                \"tasks\": \"Aufgabn\",\n                \"task_running\": \"Aufgab lafts grod\",\n                \"task_pending\": \"Aufgab dritschld\",\n                \"task_waiting_on_deps\": \"Aufgab dritschld auf n Vorarbeida\",\n                \"agent\": \"agent\",\n                \"waiting_for\": \"dritschld auf\",\n                \"stats\": {\n                    \"completed_count\": \"Fertige Aufgabn\",\n                    \"worker_count\": \"Frei\",\n                    \"running_count\": \"Am Laufa\",\n                    \"pending_count\": \"Dritschld\",\n                    \"waiting_on_deps_count\": \"Dritschld auf n Vorarbeida\"\n                }\n            },\n            \"users\": {\n                \"users\": \"Benutzer\",\n                \"desc\": \"Benutzer de wo auf dem Server registriert san.\",\n                \"login\": \"Login\",\n                \"email\": \"E-Mail\",\n                \"avatar_url\": \"Avatar-URL\",\n                \"save\": \"Benutzer speichan\",\n                \"cancel\": \"Abbrechn\",\n                \"show\": \"Benutzer zoang\",\n                \"add\": \"Benutzer dazua doa\",\n                \"none\": \"Do gibts no koane Benutzer.\",\n                \"delete_confirm\": \"Wuisd du wirkli den Benutzer löschn? Des löscht a olle Repos de wo dem Benutzer ghörn.\",\n                \"deleted\": \"Benutzer glöscht\",\n                \"created\": \"Benutzer erstäid\",\n                \"saved\": \"Benutzer gspeichert\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Benutzer is a Admin\"\n                },\n                \"delete_user\": \"Benutzer löschn\",\n                \"edit_user\": \"Benutzer beabadn\"\n            },\n            \"orgs\": {\n                \"desc\": \"Organisationen de wo Repos auf dem Server ham.\",\n                \"none\": \"Do gibts no koane Organisationen.\",\n                \"orgs\": \"Organisationen\",\n                \"org_settings\": \"Organisations-Eistellunga\",\n                \"delete_org\": \"Organisation löschn\",\n                \"deleted\": \"Organisation glöscht\",\n                \"delete_confirm\": \"Wuisd du wirkli de Organisation löschn? Des löscht a olle Repos de wo da Organisation ghörn.\",\n                \"view\": \"Organisation oschaun\"\n            }\n        }\n    },\n    \"secrets\": {\n        \"name\": \"Nama\",\n        \"secrets\": \"Secrets\",\n        \"desc\": \"Secrets kennan in olle Pipelines vo dem Repo verwendt wern.\",\n        \"none\": \"Do gibts no koane Secrets.\",\n        \"add\": \"Secret dazua doa\",\n        \"save\": \"Secret speichan\",\n        \"show\": \"Secrets zoang\",\n        \"value\": \"Wert\",\n        \"delete_confirm\": \"Wuisd du wirkli des Secret löschn?\",\n        \"deleted\": \"Secret glöscht\",\n        \"created\": \"Secret erstäid\",\n        \"saved\": \"Secret gspeichert\",\n        \"plugins\": {\n            \"images\": \"Nur verfügbar füa de folgenden Plugins\",\n            \"desc\": \"Liste vo Plugin-Images wo des Secret verfügbar is. Leer lassn zum olle Plugins und normale Schrid erlaubn.\"\n        },\n        \"events\": {\n            \"events\": \"Verfügbar bei de folgenden Events\",\n            \"warning\": \"Aufpassn: Secrets bei Pull Requests freigem ko gfährlich sei, weil böswillige Leit mid am bösen Pull Request dei Secrets klaubn kennan.\"\n        },\n        \"edit\": \"Secret beabadn\",\n        \"delete\": \"Secret löschn\",\n        \"note\": \"Notizn\"\n    },\n    \"info\": \"Info\",\n    \"cli_login_failed\": \"CLI-Login is schiaf ganga\",\n    \"cli_login_denied\": \"CLI-Login wurad obglehnt\",\n    \"return_to_cli\": \"Du kost jez den Tab zua macha und zruck zur CLI gehn.\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Addon\",\n    \"forge_type\": \"Forge-Typ\",\n    \"oauth_client_id\": \"OAuth Client ID\",\n    \"merge_ref_desc\": \"Ref zum füa de Merge-Basis verwenden. Des wird verwendt zum Diff füa Pull Requests bestimma.\",\n    \"public_only\": \"Nur öffentliche\",\n    \"public_only_desc\": \"Nur öffentliche Repos zoang.\",\n    \"git_username\": \"Git-Benutzername\",\n    \"repositories\": {\n        \"title\": \"Repositorys\",\n        \"all\": {\n            \"title\": \"Olle Repositorys\",\n            \"desc\": \"Repositorys sortiert noch da letzten Pipeline\"\n        },\n        \"last\": {\n            \"title\": \"Letztns ogschaut\",\n            \"desc\": \"De wo'd zuletzt dro waren, sortiert noch da Zeit\"\n        }\n    },\n    \"cancel\": \"Los ma's bleim\",\n    \"login_to_woodpecker_with\": \"Bei Woodpecker eilogga mid\",\n    \"login\": \"Eilogga\",\n    \"repos\": \"Repositorys\",\n    \"docs\": \"Doku\",\n    \"api\": \"API\",\n    \"logout\": \"Auslogga\",\n    \"search\": \"Suacha…\",\n    \"username\": \"Benutzanama\",\n    \"password\": \"Passwort\",\n    \"back\": \"Zruck\",\n    \"unknown_error\": \"Irgendwos is schiaf ganga\",\n    \"documentation_for\": \"Doku füa \\\"{topic}\\\"\",\n    \"pipeline_feed\": \"Pipeline-Gschichtn\",\n    \"empty_list\": \"Koane {entity} gfundn!\",\n    \"not_found\": {\n        \"not_found\": \"Oha, 404! Entweder ham mas vazapft oda du host di vatippt :-/\",\n        \"back_home\": \"Zruck zur Hauptseitn\"\n    },\n    \"errors\": {\n        \"not_found\": \"Server findt des ned wos'd wuisd\"\n    },\n    \"time\": {\n        \"not_started\": \"hod no ned ogfangd\",\n        \"just_now\": \"grod eben\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Du deafst de Eistellunga vo dera Organisation ned ändarn\",\n            \"secrets\": {\n                \"desc\": \"Organisations-Secrets kennan in de Pipelines vo olle Repos vo da Organisation verwendt wern.\"\n            },\n            \"registries\": {\n                \"desc\": \"Organisations-Registry-Credentials kennan dazuagfügt wern zum private Images fia olle Pipelines vo ana Organisation verwenden.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agents de wo fia de Organisation registriert san.\"\n            }\n        }\n    },\n    \"user\": {\n        \"settings\": {\n            \"settings\": \"Benutzer-Eistellunga\",\n            \"general\": {\n                \"general\": \"Konto\",\n                \"language\": \"Sproch\",\n                \"theme\": {\n                    \"theme\": \"Design\",\n                    \"light\": \"Hell\",\n                    \"dark\": \"Dunkel\",\n                    \"auto\": \"Automatisch\"\n                }\n            },\n            \"secrets\": {\n                \"desc\": \"User-Secrets kennan in de Pipelines vo olle Repos vom User verwendt wern.\"\n            },\n            \"registries\": {\n                \"desc\": \"User-Registry-Credentials kennan dann dazuagfügt wern zum private Images füa olle persönlichen Pipelines verwenden.\"\n            },\n            \"cli_and_api\": {\n                \"cli_and_api\": \"CLI & API\",\n                \"desc\": \"Persönlicher Access-Token, CLI und API-Verwendung\",\n                \"token\": \"Persönlicher Access-Token\",\n                \"api_usage\": \"Beispui API-Verwendung\",\n                \"cli_usage\": \"Beispui CLI-Verwendung\",\n                \"download_cli\": \"CLI runterladn\",\n                \"reset_token\": \"Token zrucksetzen\",\n                \"swagger_ui\": \"Swagger UI\"\n            },\n            \"agents\": {\n                \"desc\": \"Agents de wo füa dei Account-Repos registriert san.\"\n            }\n        }\n    },\n    \"registries\": {\n        \"registries\": \"Registries\",\n        \"credentials\": \"Registry-Credentials\",\n        \"desc\": \"Registry-Credentials kennan dann dazuagfügt wern zum private Images füa Pipelines verwenden.\",\n        \"none\": \"Do gibts no koane Registry-Credentials.\",\n        \"address\": {\n            \"address\": \"Adress\",\n            \"desc\": \"Registry-Adress (z.B. docker.io)\"\n        },\n        \"show\": \"Registries zoang\",\n        \"save\": \"Registry speichan\",\n        \"add\": \"Registry dazua doa\",\n        \"view\": \"Registry oschaun\",\n        \"edit\": \"Registry beabadn\",\n        \"delete\": \"Registry löschn\",\n        \"delete_confirm\": \"Wuisd du wirkli de Registry löschn?\",\n        \"created\": \"Registry-Credentials erstäid\",\n        \"saved\": \"Registry-Credentials gspeichert\",\n        \"deleted\": \"Registry-Credentials glöscht\"\n    },\n    \"default\": \"standard\",\n    \"running_version\": \"Du host Woodpecker {0} am Laufa\",\n    \"update_woodpecker\": \"Bitte update dei Woodpecker-Instanz auf {0}\",\n    \"global_level_secret\": \"globales Secret\",\n    \"org_level_secret\": \"Organisations-Secret\",\n    \"login_to_cli\": \"Bei da CLI eilogga\",\n    \"login_to_cli_description\": \"Wennd weitermachst, wirst bei da CLI eigloggt.\",\n    \"abort\": \"Abbrechn\",\n    \"cli_login_success\": \"CLI-Login hot klappt\",\n    \"settings\": \"Eistellunga\",\n    \"oauth_error\": \"Fella bei da OAuth-Authentifizierung\",\n    \"internal_error\": \"Interner Fella is passiert\",\n    \"registration_closed\": \"De Registrierung is zua\",\n    \"access_denied\": \"Du deafst auf de Instanz ned zuagreifen\",\n    \"org_access_denied\": \"Du deafst auf de Organisation ned zuagreifen\",\n    \"invalid_state\": \"Da OAuth-State is ungültig\",\n    \"extensions\": \"Erweiterunga\",\n    \"extensions_description\": \"Erweiterunga san HTTP-Services de wo von Woodpecker aufgruafa wern kennan anstatt de mitglifadn zu nutzn.\",\n    \"extension_endpoint_placeholder\": \"z.B. https://example.com/api\",\n    \"config_extension_endpoint\": \"Config-Erweiterungs-Endpunkt\",\n    \"extensions_signatures_public_key\": \"Öffentlicher Schlüssl fia Signaturen\",\n    \"extensions_signatures_public_key_description\": \"Der öffentliche Schlüssl soi vo deine Erweiterunga verwendt wern zum Webhook-Aufruaf vo Woodpecker verifizieren.\",\n    \"extensions_configuration_saved\": \"Erweiterunga-Konfiguration gspeichert\",\n    \"require_approval\": {\n        \"desc\": \"Vahindere dass böswillige Pipelines Secrets ausplaudern oda schädliche Sachen macha durch Freigab vor da Ausführung.\",\n        \"require_approval_for\": \"Freigab-Anforderunga\",\n        \"none\": \"Koane\",\n        \"none_desc\": \"Jeds Event lösd'd Pipelines aus, a Pull Requests. De Eistellung ko gfährlich sei und is nur füa private Instanzen empfohln.\",\n        \"forks\": \"Pull Request von am geforkten Repo\",\n        \"pull_requests\": \"Olle Pull Requests\",\n        \"all_events\": \"Olle Events vo da Forge\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Erlaubte Benutzer\",\n            \"desc\": \"Pipelines de wo vo de aufglisteten Benutzer erstäid worn san brauchan koan Pasierschein A38.\"\n        }\n    },\n    \"no_search_results\": \"Nix gfundn\",\n    \"forges\": \"Forges\",\n    \"forges_desc\": \"Forges konfigurieren de wo Repos hostn füa de wo Woodpecker laufa soi.\",\n    \"add_forge\": \"Forge dazua doa\",\n    \"show_forges\": \"Forges zoang\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"oauth_client_secret\": \"OAuth Client Secret\",\n    \"oauth_host\": \"OAuth Host\",\n    \"merge_ref\": \"Merge Ref\",\n    \"git_username_desc\": \"Benutzername fia'n Git-User.\",\n    \"git_password\": \"Git-Passwort\",\n    \"git_password_desc\": \"Passwort oda persönlicher Access-Token fia'n Git-User.\",\n    \"executable\": \"Ausführbare Datei\",\n    \"executable_desc\": \"Pfad zur Addon-Executable.\",\n    \"save\": \"Speichan\",\n    \"add\": \"Dazua doa\",\n    \"skip_verify\": \"SSL-Überprüfung überspringen\",\n    \"skip_verify_desc\": \"SSL-Überprüfung füa de API-Verbindung überspringen. Des is ned empfohln füan Produktiv-Einsatz.\",\n    \"url\": \"URL\",\n    \"forge_managed_by_env\": \"De primäre Forge wird über Umgebungsvariabln verwaltet. Olle Änderunga an dera Forge wern bei am Neustart zruckgsetzt.\",\n    \"oauth_redirect_url\": \"OAuth Redirect-URL\",\n    \"forge_created\": \"Forge erstäid\",\n    \"advanced_options\": \"Erweiterte Optionen\",\n    \"leave_empty_to_keep_current_value\": \"Leer lassn zum aktuellen Wert behalten\",\n    \"forge_deleted\": \"Forge glöscht\",\n    \"forge_delete_confirm\": \"Wuisd du wirkli de Forge löschn? Des löscht a olle Repos, User und Pipelines de wo zu dera Forge ghörn.\",\n    \"edit_forge\": \"Forge beabadn\",\n    \"delete_forge\": \"Forge löschn\",\n    \"no_forges\": \"Do gibts no koane Forges.\",\n    \"use_this_redirect_url_to_create\": \"Verwend de Redirect-URL zum de OAuth-Anwendung erstellen oda aktualisieren.\",\n    \"developer_settings_to_create\": \"Geh zu de {0} und richt de OAuth-Anwendung ei.\",\n    \"developer_settings\": \"Developer-Eistellunga\",\n    \"public_url_for_oauth_if\": \"Öffentliche URL füa OAuth falls anders ois URL ({0})\",\n    \"forge_saved\": \"Forge gspeichert\",\n    \"fullscreen\": \"Vollbild\",\n    \"exit_fullscreen\": \"Vollbild verlassn\",\n    \"help_translating\": \"Du kost helfa Woodpecker in dei Sproch zu übersetzen auf {0}.\",\n    \"weblate\": \"unserm Weblate\",\n    \"disabled\": \"Abgschoidn\",\n    \"config_extension_exclusive\": \"Ganz Alloa\",\n    \"config_extension_exclusive_desc\": \"Wenn ogschoid, werdn alle anderen konfigurations Möglichkeitn überganga, a de Forge.\",\n    \"global_level_registry\": \"Globale Registry\",\n    \"org_level_registry\": \"Organisations-Registry\",\n    \"registry_extension_endpoint\": \"Registry-Erweiterungs-Endpunkt\",\n    \"secret_extension_endpoint\": \"Secret-Erweitarung-Endpunkt\",\n    \"extension_netrc\": \"Dua Netrc Anmeldedaten dazua\",\n    \"extension_netrc_desc\": \"Schig de Forge Netrc Anmeldetaten zua Erweiterung mid.\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/cs.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Přidat agent\",\n                \"agents\": \"Agenti\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"kapacita\",\n                    \"capacity\": \"Kapacita\",\n                    \"desc\": \"Maximální počet paralelních potrubí prováděných tímto agentem.\"\n                },\n                \"created\": \"Agent vytvořen\",\n                \"delete_agent\": \"Odstranit agent\",\n                \"delete_confirm\": \"Opravdu chcete tohoto agenta odstranit? Už se nebude moci připojit k serveru.\",\n                \"deleted\": \"Agent smazán\",\n                \"desc\": \"Agenti registrovaní na tomto serveru.\",\n                \"edit_agent\": \"Upravit agent\",\n                \"id\": \"ID\",\n                \"last_contact\": \"Poslední kontakt\",\n                \"name\": {\n                    \"name\": \"Název\",\n                    \"placeholder\": \"Jméno agenta\"\n                },\n                \"never\": \"Nikdy\",\n                \"no_schedule\": {\n                    \"name\": \"Zakázat agent\",\n                    \"placeholder\": \"Zastavení přebírání nových úkolů agentem\"\n                },\n                \"none\": \"Zatím zde nejsou žádní agenti.\",\n                \"platform\": {\n                    \"badge\": \"platforma\",\n                    \"platform\": \"Platforma\"\n                },\n                \"save\": \"Uložit agent\",\n                \"saved\": \"Agent uložen\",\n                \"show\": \"Ukázat agenty\",\n                \"token\": \"Tokeny\",\n                \"version\": \"Verze\"\n            },\n            \"not_allowed\": \"K nastavení serveru nemáte přístup.\",\n            \"orgs\": {\n                \"delete_confirm\": \"Opravdu chcete tuto organizaci smazat? Tím se odstraní také všechna úložiště vlastněná touto organizací.\",\n                \"delete_org\": \"Odstranit organizaci\",\n                \"deleted\": \"Organizace vymazána\",\n                \"desc\": \"Organizace vlastnící repozitáře na tomto serveru.\",\n                \"none\": \"Zatím neexistují žádné organizace.\",\n                \"org_settings\": \"Organizační nastavení\",\n                \"orgs\": \"Organizace\",\n                \"view\": \"Zobrazit organizaci\"\n            },\n            \"queue\": {\n                \"agent\": \"agent\",\n                \"desc\": \"Úlohy čekající na provedení agenty\",\n                \"pause\": \"Pauza\",\n                \"paused\": \"Fronta je pozastavena\",\n                \"queue\": \"Fronta\",\n                \"resume\": \"Resumé\",\n                \"resumed\": \"Fronta je obnovena\",\n                \"stats\": {\n                    \"completed_count\": \"Dokončené úkoly\",\n                    \"pending_count\": \"Čeká se na\",\n                    \"running_count\": \"Běhání\",\n                    \"waiting_on_deps_count\": \"Čekání na závislosti\",\n                    \"worker_count\": \"Zdarma\"\n                },\n                \"task_pending\": \"Úkol je v řešení\",\n                \"task_running\": \"Úloha je spuštěna\",\n                \"task_waiting_on_deps\": \"Úloha čeká na závislosti\",\n                \"tasks\": \"Úkoly\",\n                \"waiting_for\": \"čekání na\"\n            },\n            \"repos\": {\n                \"desc\": \"Repozitáře, které jsou nebo byly na tomto serveru povoleny.\",\n                \"disabled\": \"Bezbariérový\",\n                \"none\": \"Zatím neexistují žádná úložiště.\",\n                \"repos\": \"Repozitáře\",\n                \"settings\": \"Repozitář nastavení\",\n                \"view\": \"Zobrazit Repozitář\"\n            },\n            \"secrets\": {\n                \"add\": \"Přidat tajemství\",\n                \"created\": \"Vytvoření globálního tajemství\",\n                \"deleted\": \"Globální tajemství odstraněno\",\n                \"desc\": \"Globální tajemství lze předat všem úložištím jednotlivých kroků pipeline za běhu jako proměnné prostředí.\",\n                \"events\": {\n                    \"events\": \"Dostupné na následujících akcích\",\n                    \"pr_warning\": \"S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství.\"\n                },\n                \"images\": {\n                    \"desc\": \"Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný\",\n                    \"images\": \"Dostupné pro následující snímky\"\n                },\n                \"name\": \"Název\",\n                \"none\": \"Zatím neexistují žádná globální tajemství.\",\n                \"plugins_only\": \"K dispozici pouze pro pluginy\",\n                \"save\": \"Uložit tajemství\",\n                \"saved\": \"Globální tajemství uloženo\",\n                \"secrets\": \"Tajemství\",\n                \"show\": \"Zobrazit tajemství\",\n                \"value\": \"Hodnoty\",\n                \"warning\": \"Tato tajemství budou k dispozici všem uživatelům serveru.\"\n            },\n            \"settings\": \"Nastavení\",\n            \"users\": {\n                \"add\": \"Přidat uživatele\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Uživatel je admin\"\n                },\n                \"avatar_url\": \"Adresa URL avatara\",\n                \"cancel\": \"Zrušit\",\n                \"created\": \"Uživatel vytvořil\",\n                \"delete_confirm\": \"Opravdu chcete tohoto uživatele odstranit? Tím se odstraní také všechna úložiště, která tento uživatel vlastní.\",\n                \"delete_user\": \"Odstranění uživatele\",\n                \"deleted\": \"Smazaný uživatel\",\n                \"desc\": \"Uživatelé registrovaní pro tento server\",\n                \"edit_user\": \"Upravit uživatele\",\n                \"email\": \"E-mail\",\n                \"login\": \"Přihlášení\",\n                \"none\": \"Zatím nejsou žádní uživatelé.\",\n                \"save\": \"Uložit uživatele\",\n                \"saved\": \"Uživatel uložil\",\n                \"show\": \"Zobrazit uživatele\",\n                \"users\": \"Uživatelé\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Zpět\",\n    \"cancel\": \"Zrušit\",\n    \"docs\": \"Doky\",\n    \"documentation_for\": \"Dokumentace k \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Server nemohl najít požadovaný objekt\"\n    },\n    \"login\": \"Přihlášení\",\n    \"logout\": \"Odhlášení\",\n    \"not_found\": {\n        \"back_home\": \"Zpět na úvod\",\n        \"not_found\": \"Páni 404, buď jsme něco rozbili, nebo jsi měl překlep :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Nemáte přístup k nastavení této organizace\",\n            \"secrets\": {\n                \"add\": \"Přidat tajemství\",\n                \"created\": \"Vytvořené organizační tajemství\",\n                \"deleted\": \"Organizační tajemství vymazáno\",\n                \"desc\": \"Tajemství organizace lze za běhu předat jednotlivým krokům pipeline úložiště všech organizací jako proměnné prostředí.\",\n                \"events\": {\n                    \"events\": \"Dostupné na následujících akcích\",\n                    \"pr_warning\": \"S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství.\"\n                },\n                \"images\": {\n                    \"desc\": \"Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný\",\n                    \"images\": \"Dostupné pro následující snímky\"\n                },\n                \"name\": \"Název\",\n                \"none\": \"Zatím neexistují žádná tajemství organizace.\",\n                \"plugins_only\": \"K dispozici pouze pro pluginy\",\n                \"save\": \"Uložit tajemství\",\n                \"saved\": \"Uložené tajemství organizace\",\n                \"secrets\": \"Tajemství\",\n                \"show\": \"Zobrazit tajemství\",\n                \"value\": \"Hodnoty\"\n            },\n            \"settings\": \"Nastavení\"\n        }\n    },\n    \"password\": \"Heslo\",\n    \"pipeline_feed\": \"Přívodní potrubí\",\n    \"repo\": {\n        \"activity\": \"Aktivita\",\n        \"add\": \"Přidat repozitář\",\n        \"branches\": \"Pobočky\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Prostředí cílového nasazení\",\n            \"title\": \"Spuštění události nasazení pro aktuální potrubí #{pipelineId}\",\n            \"trigger\": \"Nasazení\",\n            \"variables\": {\n                \"add\": \"Přidat proměnnou\",\n                \"desc\": \"Zadejte další proměnné, které chcete použít v potrubí. Proměnné se stejným názvem budou přepsány.\",\n                \"name\": \"Název proměnné\",\n                \"title\": \"Dodatečné proměnné potrubí\",\n                \"value\": \"Proměnná hodnota\"\n            }\n        },\n        \"enable\": {\n            \"disabled\": \"Bezbariérový\",\n            \"enable\": \"Povolit\",\n            \"enabled\": \"Již povoleno\",\n            \"list_reloaded\": \"Repozitář znovu načtený seznam\",\n            \"reload\": \"Znovunačtení úložišť\",\n            \"success\": \"Repozitář povoleno\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Vyberte větev\",\n            \"title\": \"Spuštění ručního spuštění potrubí\",\n            \"trigger\": \"Spustit potrubí\",\n            \"variables\": {\n                \"add\": \"Přidat proměnnou\",\n                \"desc\": \"Zadejte další proměnné, které chcete použít v potrubí. Proměnné se stejným názvem budou přepsány.\",\n                \"name\": \"Název proměnné\",\n                \"title\": \"Další proměnné potrubí\",\n                \"value\": \"Proměnná hodnota\"\n            }\n        },\n        \"not_allowed\": \"Nemáte povolen přístup k tomuto repozitář\",\n        \"open_in_forge\": \"Otevřený repozitář v systému řízení verzí\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Storno\",\n                \"cancel_success\": \"Potrubí zrušeno\",\n                \"canceled\": \"Tento krok byl zrušen.\",\n                \"deploy\": \"Nasazení\",\n                \"log_auto_scroll\": \"Automatické posouvání dolů\",\n                \"log_auto_scroll_off\": \"Vypnutí automatického posouvání\",\n                \"log_download\": \"Stáhnout\",\n                \"restart\": \"Restart\",\n                \"restart_success\": \"Opětovné spuštění potrubí\"\n            },\n            \"config\": \"Konfigurace\",\n            \"event\": {\n                \"cron\": \"cron\",\n                \"deploy\": \"Nasazení\",\n                \"manual\": \"Manuál\",\n                \"pr\": \"Žádost o stažení\",\n                \"push\": \"Push\",\n                \"tag\": \"Tag\"\n            },\n            \"exit_code\": \"Kód ukončení {exitCode}\",\n            \"files\": \"Změněné soubory ({files})\",\n            \"loading\": \"Načítání…\",\n            \"log_download_error\": \"Při stahování souboru protokolu došlo k chybě\",\n            \"log_title\": \"Krokové protokoly\",\n            \"no_files\": \"Žádné soubory nebyly změněny.\",\n            \"no_pipeline_steps\": \"Nejsou k dispozici žádné kroky v potrubí!\",\n            \"no_pipelines\": \"Žádné potrubí zatím nebylo spuštěno.\",\n            \"pipeline\": \"Potrubí #{pipelineId}\",\n            \"pipelines_for\": \"Potrubí pro větev \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Potrubí pro požadavek na stažení #{index}\",\n            \"protected\": {\n                \"approve\": \"Schválit\",\n                \"approve_success\": \"Potrubí schváleno\",\n                \"awaits\": \"Toto potrubí čeká na schválení správcem!\",\n                \"decline\": \"Pokles\",\n                \"decline_success\": \"Potrubí kleslo\",\n                \"declined\": \"Tento plynovod byl odmítnut!\",\n                \"review\": \"Přezkoumání změn\"\n            },\n            \"status\": {\n                \"blocked\": \"blokované\",\n                \"declined\": \"odmítnuto\",\n                \"error\": \"chyba\",\n                \"failure\": \"selhání\",\n                \"killed\": \"zabil\",\n                \"pending\": \"čeká na\",\n                \"running\": \"běžící\",\n                \"skipped\": \"přeskočil\",\n                \"started\": \"začal\",\n                \"status\": \"Stav: {status}\",\n                \"success\": \"úspěch\"\n            },\n            \"step_not_started\": \"Tento krok ještě nebyl zahájen.\",\n            \"tasks\": \"Úkoly\"\n        },\n        \"pull_requests\": \"Žádosti o stažení\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Akce\",\n                \"delete\": {\n                    \"confirm\": \"Po této akci budou všechna data ztracena!!!\\n\\nOpravdu chcete pokračovat?\",\n                    \"delete\": \"Odstranit repozitář\",\n                    \"success\": \"Repozitář smazáno\"\n                },\n                \"disable\": {\n                    \"disable\": \"Zakázat repozitář\",\n                    \"success\": \"Repozitář zakázáno\"\n                },\n                \"enable\": {\n                    \"enable\": \"Povolit repozitář\",\n                    \"success\": \"Repozitář povoleno\"\n                },\n                \"repair\": {\n                    \"repair\": \"Oprava repozitář\",\n                    \"success\": \"Repozitář opravené\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Odznak\",\n                \"branch\": \"Pobočka\",\n                \"type\": \"Syntaxe\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Přidat cron\",\n                \"branch\": {\n                    \"placeholder\": \"Větev (pokud je prázdná, použije se výchozí větev)\",\n                    \"title\": \"Pobočka\"\n                },\n                \"created\": \"Cron vytvořil\",\n                \"crons\": \"Crons\",\n                \"delete\": \"Odstranit cron\",\n                \"deleted\": \"Cron odstraněn\",\n                \"desc\": \"K pravidelnému spouštění potrubí lze použít úlohy Cron.\",\n                \"edit\": \"Upravit cron\",\n                \"name\": {\n                    \"name\": \"Název\",\n                    \"placeholder\": \"Název úlohy cron\"\n                },\n                \"next_exec\": \"Další provedení\",\n                \"none\": \"Zatím zde nejsou žádné crony.\",\n                \"not_executed_yet\": \"Zatím neprovedeno\",\n                \"run\": \"Běžte nyní\",\n                \"save\": \"Uložit cron\",\n                \"saved\": \"Cron uložen\",\n                \"schedule\": {\n                    \"placeholder\": \"Harmonogram\",\n                    \"title\": \"Harmonogram (na základě UTC)\"\n                },\n                \"show\": \"Zobrazit crons\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Povolit žádosti o stažení\",\n                    \"desc\": \"Potrubí lze spouštět na základě požadavků na stažení.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Zrušení předchozích potrubí\",\n                    \"desc\": \"Umožňuje zrušit čekající a spuštěné pipeline stejné události a kontextu před spuštěním nově spuštěné pipeline.\"\n                },\n                \"general\": \"Obecné\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Pověření netrc vkládejte pouze do důvěryhodných kontejnerů (doporučeno).\",\n                    \"netrc_only_trusted\": \"Pověření netrc vkládat pouze do důvěryhodných kontejnerů\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Ve výchozím nastavení: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Cesta ke konfiguraci potrubí (například {0}). Složky by měly končit znakem {1}.\",\n                    \"desc_path_example\": \"moje/cesta/\",\n                    \"path\": \"Cesta potrubí\"\n                },\n                \"project\": \"Nastavení projektu\",\n                \"protected\": {\n                    \"desc\": \"Každý plynovod musí být před provedením schválen.\",\n                    \"protected\": \"Chráněný\"\n                },\n                \"save\": \"Uložit nastavení\",\n                \"success\": \"Repozitář aktualizace nastavení\",\n                \"timeout\": {\n                    \"minutes\": \"minuty\",\n                    \"timeout\": \"Časový limit\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Základní kontejnery potrubí získávají přístup k rozšířeným možnostem, jako je například připojování svazků.\",\n                    \"trusted\": \"Důvěryhodný\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Tento projekt mohou vidět pouze ověření uživatelé instance programu Woodpecker.\",\n                        \"internal\": \"Interní\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Pouze vy a ostatní majitelé repozitáře mohou tento projekt vidět.\",\n                        \"private\": \"Soukromé\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Každý uživatel může vidět váš projekt, aniž by byl přihlášen.\",\n                        \"public\": \"Veřejnost\"\n                    },\n                    \"visibility\": \"Zviditelnění projektu\"\n                }\n            },\n            \"not_allowed\": \"Nemáte přístup k nastavení tohoto úložiště\",\n            \"registries\": {\n                \"add\": \"Přidat rejstřík\",\n                \"address\": {\n                    \"address\": \"Adresa\",\n                    \"placeholder\": \"Adresa registru (např. docker.io)\"\n                },\n                \"created\": \"Vytvořená pověření k registru\",\n                \"credentials\": \"Pověření k registraci\",\n                \"delete\": \"Odstranění registru\",\n                \"deleted\": \"Odstranění pověření registru\",\n                \"desc\": \"Lze přidat pověření k registrům a používat soukromé obrazy pro potrubí.\",\n                \"edit\": \"Upravit registr\",\n                \"none\": \"V registru zatím nejsou žádná pověření.\",\n                \"registries\": \"Registry\",\n                \"save\": \"Uložit registr\",\n                \"saved\": \"Uložená pověření k registru\",\n                \"show\": \"Zobrazit registry\"\n            },\n            \"secrets\": {\n                \"add\": \"Přidat tajemství\",\n                \"created\": \"Tajemství vytvořeno\",\n                \"delete\": \"Odstranit tajemství\",\n                \"delete_confirm\": \"Opravdu chcete toto tajemství vymazat?\",\n                \"deleted\": \"Tajemství odstraněno\",\n                \"desc\": \"Tajemství lze předávat jednotlivým krokům potrubí za běhu jako proměnné prostředí.\",\n                \"edit\": \"Upravit tajemství\",\n                \"events\": {\n                    \"events\": \"Dostupné na následujících akcích\",\n                    \"pr_warning\": \"S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství.\"\n                },\n                \"images\": {\n                    \"desc\": \"Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný\",\n                    \"images\": \"Dostupné pro následující obrázky\"\n                },\n                \"name\": \"Název\",\n                \"none\": \"Žádná tajemství zatím neexistují.\",\n                \"plugins_only\": \"K dispozici pouze pro pluginy\",\n                \"save\": \"Uložit tajemství\",\n                \"saved\": \"Tajemství uloženo\",\n                \"secrets\": \"Tajemství\",\n                \"show\": \"Zobrazit tajemství\",\n                \"value\": \"Hodnoty\"\n            },\n            \"settings\": \"Nastavení\"\n        },\n        \"user_none\": \"Tato organizace / uživatel zatím nemá žádné projekty.\"\n    },\n    \"repos\": \"Repozitář\",\n    \"repositories\": \"Repozitáře\",\n    \"search\": \"Hledání…\",\n    \"time\": {\n        \"days_short\": \"d\",\n        \"hours_short\": \"h\",\n        \"min_short\": \"min\",\n        \"not_started\": \"zatím nezačal\",\n        \"sec_short\": \"sek\",\n        \"template\": \"MMM D, RRRR, HH:mm z\",\n        \"weeks_short\": \"t\"\n    },\n    \"unknown_error\": \"Došlo k neznámé chybě\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Nejste oprávněni se přihlásit\",\n        \"internal_error\": \"Došlo k nějaké interní chybě\",\n        \"oauth_error\": \"Chyba při ověřování proti poskytovateli OAuth\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Příklad využití API\",\n                \"cli_usage\": \"Příklad použití CLI\",\n                \"desc\": \"Osobní přístupový token a používání API\",\n                \"dl_cli\": \"Stáhnout CLI\",\n                \"reset_token\": \"Resetovat token\",\n                \"shell_setup\": \"Nastavení shellu\",\n                \"shell_setup_before\": \"proveďte kroky nastavení shellu před\",\n                \"swagger_ui\": \"Rozhraní Swagger UI\",\n                \"token\": \"Osobní přístupový token\"\n            },\n            \"general\": {\n                \"general\": \"Obecné\",\n                \"language\": \"Jazyk\"\n            },\n            \"secrets\": {\n                \"add\": \"Přidat tajemství\",\n                \"created\": \"Vytvoření uživatelského tajemství\",\n                \"deleted\": \"Vymazání uživatelského tajemství\",\n                \"desc\": \"Uživatelská tajemství lze za běhu předávat jednotlivým krokům pipeline všech uživatelských úložišť jako proměnné prostředí.\",\n                \"events\": {\n                    \"events\": \"Dostupné na následujících akcích\",\n                    \"pr_warning\": \"S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství.\"\n                },\n                \"images\": {\n                    \"desc\": \"Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný\",\n                    \"images\": \"Dostupné pro následující snímky\"\n                },\n                \"name\": \"Název\",\n                \"none\": \"Zatím nejsou k dispozici žádná uživatelská tajemství.\",\n                \"plugins_only\": \"Dostupné pouze pro zásuvné moduly\",\n                \"save\": \"Uložit tajemství\",\n                \"saved\": \"Uložený uživatelský sekret\",\n                \"secrets\": \"Tajemství\",\n                \"show\": \"Zobrazit tajemství\",\n                \"value\": \"Hodnoty\"\n            },\n            \"settings\": \"Uživatelská nastavení\"\n        }\n    },\n    \"username\": \"Uživatelské jméno\",\n    \"welcome\": \"Vítejte ve Woodpecker\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/de.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Agent hinzufügen\",\n                \"agents\": \"Agenten\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"Backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"Kapazität\",\n                    \"capacity\": \"Kapazität\",\n                    \"desc\": \"Die maximale Anzahl von Pipelines, die ein Agent ausführt.\"\n                },\n                \"created\": \"Agent erstellt\",\n                \"delete_agent\": \"Agent löschen\",\n                \"delete_confirm\": \"Wollen Sie diesen Agent wirklich löschen? Dieser kann sich dann nicht mehr mit dem Server verbinden.\",\n                \"deleted\": \"Agent gelöscht\",\n                \"desc\": \"Für diesen Server registrierte Agenten.\",\n                \"edit_agent\": \"Agent bearbeiten\",\n                \"id\": \"ID\",\n                \"last_contact\": {\n                    \"last_contact\": \"Letzter Kontakt\",\n                    \"badge\": \"letzter Kontakt\"\n                },\n                \"name\": {\n                    \"name\": \"Name\",\n                    \"placeholder\": \"Name des Agenten\"\n                },\n                \"never\": \"Nie\",\n                \"no_schedule\": {\n                    \"name\": \"Agent deaktivieren\",\n                    \"placeholder\": \"Agent daran hindern, neue Aufgaben zu nehmen\"\n                },\n                \"none\": \"Es gibt noch keine Agenten.\",\n                \"platform\": {\n                    \"badge\": \"Plattform\",\n                    \"platform\": \"Plattform\"\n                },\n                \"save\": \"Agent speichern\",\n                \"saved\": \"Agent gespeichert\",\n                \"show\": \"Agenten anzeigen\",\n                \"token\": \"Schlüssel\",\n                \"version\": \"Version\",\n                \"org\": {\n                    \"badge\": \"Organisation\"\n                },\n                \"custom_labels\": {\n                    \"desc\": \"Die benutzerdefinierten Labels, die vom Agent-Administrator beim Start des Agenten festgelegt wurden.\",\n                    \"custom_labels\": \"Benutzerdefinierte Labels\"\n                }\n            },\n            \"not_allowed\": \"Du darfst nicht auf die Server-Einstellungen zugreifen\",\n            \"orgs\": {\n                \"delete_confirm\": \"Möchtest du diese Organisation wirklich löschen? Das wird auch alle Repositorys löschen, die dieser Organisation gehören.\",\n                \"delete_org\": \"Organisation löschen\",\n                \"deleted\": \"Organisation gelöscht\",\n                \"desc\": \"Organisationen, die Repositorys auf diesem Server besitzen.\",\n                \"none\": \"Es gibt noch keine Organisationen.\",\n                \"org_settings\": \"Organisations-Einstellungen\",\n                \"orgs\": \"Organisationen\",\n                \"view\": \"Organisation anzeigen\"\n            },\n            \"queue\": {\n                \"agent\": \"Agent\",\n                \"desc\": \"Aufgaben, die darauf warten, von Agenten ausgeführt zu werden.\",\n                \"pause\": \"Pausieren\",\n                \"paused\": \"Warteschlange wurde pausiert\",\n                \"queue\": \"Warteschlange\",\n                \"resume\": \"Wieder aufnehmen\",\n                \"resumed\": \"Warteschlange wurde wieder aufgenommen\",\n                \"stats\": {\n                    \"completed_count\": \"Beendete Aufgaben\",\n                    \"pending_count\": \"Ausstehend\",\n                    \"running_count\": \"Läuft\",\n                    \"waiting_on_deps_count\": \"Wartet auf Abhängigkeiten\",\n                    \"worker_count\": \"Frei\"\n                },\n                \"task_pending\": \"Aufgabe steht aus\",\n                \"task_running\": \"Aufgabe läuft\",\n                \"task_waiting_on_deps\": \"Aufgabe wartet auf Abhängigkeiten\",\n                \"tasks\": \"Aufgaben\",\n                \"waiting_for\": \"wartet auf\"\n            },\n            \"repos\": {\n                \"desc\": \"Repositorys, die auf dem Server aktiviert sind oder waren.\",\n                \"disabled\": \"Deaktiviert\",\n                \"none\": \"Es gibt noch keine Repositorys.\",\n                \"repair\": {\n                    \"repair\": \"Alle reparieren\",\n                    \"success\": \"Repositorys repariert\"\n                },\n                \"repos\": \"Repositorys\",\n                \"settings\": \"Repository-Einstellungen\",\n                \"view\": \"Repository anzeigen\"\n            },\n            \"secrets\": {\n                \"add\": \"Geheimnis hinzufügen\",\n                \"created\": \"Globales Geheimnis erstellt\",\n                \"deleted\": \"Globales Geheimnis gelöscht\",\n                \"desc\": \"Globale Geheimnisse können in Pipelines in allen Repositorys genutzt werden.\",\n                \"events\": {\n                    \"events\": \"Verfügbar für folgende Ereignisse\",\n                    \"pr_warning\": \"Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben\",\n                    \"images\": \"Verfügbar für folgende Images\"\n                },\n                \"name\": \"Name\",\n                \"none\": \"Es gibt noch keine globalen Geheimnisse.\",\n                \"plugins_only\": \"Nur für Plugins verfügbar\",\n                \"save\": \"Geheimnis speichern\",\n                \"saved\": \"Globales Geheimnis gespeichert\",\n                \"secrets\": \"Geheimnisse\",\n                \"show\": \"Geheimnisse anzeigen\",\n                \"value\": \"Wert\",\n                \"warning\": \"Diese Geheimnisse können von allen Nutzern eingesehen werden.\"\n            },\n            \"settings\": \"Einstellungen\",\n            \"users\": {\n                \"add\": \"Benutzer hinzufügen\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Benutzer ist ein Admin\"\n                },\n                \"avatar_url\": \"URL des Profilbilds\",\n                \"cancel\": \"Abbrechen\",\n                \"created\": \"Benutzer erstellt\",\n                \"delete_confirm\": \"Möchtest du diesen Benutzer wirklich löschen? Das wird auch alle Repositorys löschen, die diesem Benutzer gehören.\",\n                \"delete_user\": \"Benutzer löschen\",\n                \"deleted\": \"Benutzer gelöscht\",\n                \"desc\": \"Auf diesem Server registrierte Benutzer.\",\n                \"edit_user\": \"Benutzer bearbeiten\",\n                \"email\": \"E-Mail\",\n                \"login\": \"Benutzername\",\n                \"none\": \"Es gibt noch keine Benutzer.\",\n                \"save\": \"Benutzer speichern\",\n                \"saved\": \"Benutzer gespeichert\",\n                \"show\": \"Benutzer anzeigen\",\n                \"users\": \"Benutzer\"\n            },\n            \"registries\": {\n                \"desc\": \"Globale Registry-Zugangsdaten können hinzugefügt werden, um für private Images für alle Pipelines zu verwenden.\",\n                \"warning\": \"Diese Register-Zugangsdaten werden für alle Benutzer verfügbar sein.\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Zurück\",\n    \"cancel\": \"Abbrechen\",\n    \"default\": \"Standard\",\n    \"docs\": \"Dokumentation\",\n    \"documentation_for\": \"Dokumentation für „{topic}“\",\n    \"empty_list\": \"Keine {entity} gefunden!\",\n    \"errors\": {\n        \"not_found\": \"Angefragtes Objekt wurde nicht gefunden\"\n    },\n    \"global_level_secret\": \"globales Geheimnis\",\n    \"info\": \"Info\",\n    \"login\": \"Anmelden\",\n    \"logout\": \"Abmelden\",\n    \"not_found\": {\n        \"back_home\": \"Zurück zum Start\",\n        \"not_found\": \"Whoa 404, entweder haben wir etwas kaputt gemacht oder du hattest einen Tippfehler :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Du darfst nicht auf die Einstellungen dieser Organisation zugreifen\",\n            \"secrets\": {\n                \"add\": \"Geheimnis hinzufügen\",\n                \"created\": \"Organisations-Geheimnis erstellt\",\n                \"deleted\": \"Organisations-Geheimnis gelöscht\",\n                \"desc\": \"Organisation-Geheimnisse können in allen Pipelines in Repositorys, die der Organisation gehören, genutzt werden.\",\n                \"events\": {\n                    \"events\": \"Verfügbar für folgende Ereignisse\",\n                    \"pr_warning\": \"Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben\",\n                    \"images\": \"Verfügbar für die folgenden Images\"\n                },\n                \"name\": \"Name\",\n                \"none\": \"Es existieren noch keine Organisations-Geheimnisse.\",\n                \"plugins_only\": \"Nur für Plugins verfügbar\",\n                \"save\": \"Geheimnis speichern\",\n                \"saved\": \"Organisations-Geheimnis gespeichert\",\n                \"secrets\": \"Geheimnisse\",\n                \"show\": \"Geheimnisse anzeigen\",\n                \"value\": \"Wert\"\n            },\n            \"settings\": \"Einstellungen\",\n            \"registries\": {\n                \"desc\": \"Zugangsdaten zum Register der Organisation können für private Images aller Pipelines der Organisation verwendet werden.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agenten registriert für diese Organisation.\"\n            }\n        }\n    },\n    \"org_level_secret\": \"Organisationsgeheimnis\",\n    \"password\": \"Passwort\",\n    \"pipeline_feed\": \"Pipeline-Feed\",\n    \"repo\": {\n        \"activity\": \"Aktivitäten\",\n        \"add\": \"Repository hinzufügen\",\n        \"branches\": \"Branches\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Zielumgebung des Deployments\",\n            \"title\": \"Ein Deployment-Event für die aktuelle Pipeline #{pipelineId} starten\",\n            \"trigger\": \"Deploy\",\n            \"variables\": {\n                \"add\": \"Variable hinzufügen\",\n                \"desc\": \"Zusätzliche Variablen für diese Pipeline hinzufügen. Variablen mit dem gleichen Namen werden überschrieben.\",\n                \"name\": \"Variablenname\",\n                \"title\": \"Zusätzliche Pipeline-Variablen\",\n                \"value\": \"Variablenwert\",\n                \"delete\": \"Variable löschen\"\n            },\n            \"enter_task\": \"Aufgabe des Deployments\"\n        },\n        \"enable\": {\n            \"disabled\": \"Deaktiviert\",\n            \"enable\": \"Aktivieren\",\n            \"enabled\": \"Bereits aktiviert\",\n            \"list_reloaded\": \"Repository-Liste neu geladen\",\n            \"reload\": \"Repositorys neu laden\",\n            \"success\": \"Repository aktiviert\",\n            \"new_forge_repo\": \"neues Repo auf der Code-Plattform\",\n            \"stale_wp_repo\": \"veraltetes Woodpecker-Repo\",\n            \"conflict\": \"Konflikt\",\n            \"conflict_desc\": \"Dieses Repository wurde auf der Code-Plattform mit einer neuen ID erstellt, aber ein alter Eintrag mit demselben Namen existiert noch in Woodpecker. Lösche das alte Repo, um das neue zu aktivieren, oder repariere das alte.\",\n            \"forge_repo_missing\": \"Repo auf der Code-Plattform fehlt!\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Branch auswählen\",\n            \"title\": \"Löse einen manuellen Pipeline Durchlauf aus\",\n            \"trigger\": \"Pipeline ausführen\",\n            \"variables\": {\n                \"add\": \"Variable hinzufügen\",\n                \"desc\": \"Füge weitere Variablen für deine Pipeline hinzu. Variablen mit demselben Namen werden überschrieben.\",\n                \"name\": \"Variablenname\",\n                \"title\": \"Zusätzliche Pipeline-Variablen\",\n                \"value\": \"Variablenwert\",\n                \"delete\": \"Variable löschen\"\n            },\n            \"show_pipelines\": \"Pipelines anzeigen\",\n            \"no_manual_workflows\": \"Keine manuell ausführbaren Workflows gefunden. Stelle sicher, dass es mindestens einen Workflow gibt, der beim „manual“-Event läuft.\"\n        },\n        \"not_allowed\": \"Zugriff auf dieses Repository nicht erlaubt\",\n        \"open_in_forge\": \"Repository in der Code-Plattform öffnen\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Abbrechen\",\n                \"cancel_success\": \"Pipeline abgebrochen\",\n                \"canceled\": \"Dieser Schritt wurde abgebrochen.\",\n                \"deploy\": \"Deploy\",\n                \"log_auto_scroll\": \"Automatisches Scrollen aktivieren\",\n                \"log_auto_scroll_off\": \"Automatisches Scrollen deaktivieren\",\n                \"log_download\": \"Herunterladen\",\n                \"restart\": \"Neustarten\",\n                \"restart_success\": \"Pipeline neu gestartet\",\n                \"log_delete\": \"Löschen\",\n                \"skipped\": \"Dieser Schritt wurde übersprungen.\",\n                \"expand_all\": \"Alle ausklappen\",\n                \"collapse_all\": \"Alle einklappen\"\n            },\n            \"config\": \"Konfiguration\",\n            \"errors\": \"Fehler\",\n            \"event\": {\n                \"cron\": \"Cron\",\n                \"deploy\": \"Deploy\",\n                \"manual\": \"Manuell\",\n                \"pr\": \"Pull-Request\",\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"release\": \"Release\",\n                \"pr_closed\": \"Pull-Request zusammengeführt/geschlossen\",\n                \"pr_metadata\": \"Pull-Request-Metadaten geändert\"\n            },\n            \"exit_code\": \"Exit-Code {exitCode}\",\n            \"files\": \"Geänderte Dateien\",\n            \"loading\": \"Laden…\",\n            \"log_download_error\": \"Beim Herunterladen der Log-Datei ist ein Fehler aufgetreten\",\n            \"log_title\": \"Logs des Schrittes\",\n            \"no_files\": \"Es wurden keine Dateien geändert.\",\n            \"no_pipeline_steps\": \"Keine Schritte in der Pipeline vorhanden!\",\n            \"no_pipelines\": \"Bisher wurden noch keine Pipelines gestartet.\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"pipelines_for\": \"Pipelines für den Branch „{branch}“\",\n            \"pipelines_for_pr\": \"Pipelines für Pull-Request #{index}\",\n            \"protected\": {\n                \"approve\": \"Genehmigen\",\n                \"approve_success\": \"Pipeline genehmigt\",\n                \"awaits\": \"Diese Pipeline wartet auf die Genehmigung durch einen Maintainer!\",\n                \"decline\": \"Ablehnen\",\n                \"decline_success\": \"Pipeline abgelehnt\",\n                \"declined\": \"Diese Pipeline ist abgelehnt worden!\",\n                \"review\": \"Änderungen überprüfen\"\n            },\n            \"show_errors\": \"Fehler anzeigen\",\n            \"status\": {\n                \"blocked\": \"blockiert\",\n                \"declined\": \"abgelehnt\",\n                \"error\": \"Fehler\",\n                \"failure\": \"fehlgeschlagen\",\n                \"killed\": \"getötet\",\n                \"pending\": \"ausstehend\",\n                \"running\": \"laufend\",\n                \"skipped\": \"übersprungen\",\n                \"started\": \"gestartet\",\n                \"status\": \"Status: {status}\",\n                \"success\": \"erfolgreich\",\n                \"canceled\": \"abgebrochen\"\n            },\n            \"step_not_started\": \"Dieser Schritt hat noch nicht begonnen.\",\n            \"tasks\": \"Vorgänge\",\n            \"warnings\": \"Warnungen\",\n            \"we_got_some_errors\": \"Oh nein, ein Fehler ist aufgetreten!\",\n            \"log_delete_confirm\": \"Möchtest du die Logs dieses Schrittes wirklich löschen?\",\n            \"log_delete_error\": \"Ein Fehler ist beim Löschen der Logs des Schrittes aufgetreten\",\n            \"duration\": \"Pipeline-Dauer: {duration}\",\n            \"created\": \"Erstellt: {created}\",\n            \"no_logs\": \"Keine Logs\",\n            \"debug\": {\n                \"no_permission\": \"Du bist nicht berechtigt, auf die Debug-Informationen zuzugreifen.\",\n                \"download_metadata\": \"Metadaten herunterladen\",\n                \"metadata_download_successful\": \"Die Metadaten wurden erfolgreich heruntergeladen\",\n                \"metadata_download_error\": \"Fehler beim Herunterladen der Metadaten\",\n                \"title\": \"Debug\",\n                \"metadata_exec_title\": \"Pipeline lokal erneut ausführen\",\n                \"metadata_exec_desc\": \"Lade die Metadaten dieser Pipeline herunter, um diese lokal auszuführen. Dies erlaubt dir, Fehler zu beheben und Änderungen zu testen, bevor du einen Commit erstellst. Das Woodpecker-CLI muss in der zum Server passenden Version lokal installiert sein.\"\n            },\n            \"view\": \"Pipeline anzeigen\",\n            \"cancel_info\": {\n                \"superseded_by\": \"Ersetzt durch #{pipelineId}\",\n                \"canceled_by_user\": \"Abgebrochen von {user}\",\n                \"canceled_by_step\": \"Wegen Schritt {step} abgebrochen\"\n            },\n            \"load_more\": \"Mehr laden\",\n            \"version\": \"Die Woodpecker-Version, mit der diese Pipeline ausgeführt wurde.\",\n            \"version_header\": \"Woodpecker-Version\"\n        },\n        \"pull_requests\": \"Pull-Requests\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Aktionen\",\n                \"delete\": {\n                    \"confirm\": \"Alle Daten sind nach dieser Aktion verloren!\\n\\nMöchtest du wirklich fortfahren?\",\n                    \"delete\": \"Repository löschen\",\n                    \"success\": \"Repository gelöscht\"\n                },\n                \"disable\": {\n                    \"disable\": \"Repository deaktivieren\",\n                    \"success\": \"Repository deaktiviert\"\n                },\n                \"enable\": {\n                    \"enable\": \"Repository aktivieren\",\n                    \"success\": \"Repository aktiviert\"\n                },\n                \"repair\": {\n                    \"repair\": \"Repository reparieren\",\n                    \"success\": \"Repository repariert\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Abzeichen\",\n                \"branch\": \"Branch\",\n                \"type\": \"Syntax\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\",\n                \"events\": \"Events\",\n                \"workflow\": \"Workflow\",\n                \"step\": \"Schritt\"\n            },\n            \"crons\": {\n                \"add\": \"Cron hinzufügen\",\n                \"branch\": {\n                    \"placeholder\": \"Branch (verwendet Standard-Branch wenn leer)\",\n                    \"title\": \"Branch\"\n                },\n                \"created\": \"Cron erstellt\",\n                \"crons\": \"Crons\",\n                \"delete\": \"Cron löschen\",\n                \"deleted\": \"Cron gelöscht\",\n                \"desc\": \"Cron-Jobs können dazu verwendet werden in regelmäßigen Abständen Pipelines zu starten.\",\n                \"edit\": \"Cron bearbeiten\",\n                \"name\": {\n                    \"name\": \"Name\",\n                    \"placeholder\": \"Name des Cron\"\n                },\n                \"next_exec\": \"Nächste Ausführung\",\n                \"none\": \"Es gibt noch keine Crons.\",\n                \"not_executed_yet\": \"Noch nicht ausgeführt\",\n                \"run\": \"Jetzt ausführen\",\n                \"save\": \"Cron speichern\",\n                \"saved\": \"Cron gespeichert\",\n                \"schedule\": {\n                    \"placeholder\": \"Zeitplan\",\n                    \"title\": \"Zeitplan (basierend auf UTC)\"\n                },\n                \"show\": \"Crons anzeigen\",\n                \"enabled\": \"Aktiv\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Pull-Requests zulassen\",\n                    \"desc\": \"Die Ausführung von Pipelines für Pull-Requests erlauben.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Ältere Pipelines abbrechen\",\n                    \"desc\": \"Bei ausgewählten Events werden laufende Pipelines desselben Ereignisses abgebrochen, bevor die neue Pipeline startet.\"\n                },\n                \"general\": \"Projekt\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Plugins, die Zugriff auf Netrc-Zugangsdaten erhalten, die genutzt werden können, um Repositorys von der Code-Plattform zu klonen oder zu pushen.\",\n                    \"netrc_only_trusted\": \"Eigene vertrauenswürdige Plugins zum Klonen\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Standardmäßig: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Pfad zu deiner Pipeline-Konfiguration (z. B. {0}). Verzeichnisse sollten mit einem {1} enden.\",\n                    \"desc_path_example\": \"mein/pfad/\",\n                    \"path\": \"Pipeline-Pfad\"\n                },\n                \"project\": \"Projekt-Einstellungen\",\n                \"protected\": {\n                    \"desc\": \"Jede Pipeline muss genehmigt werden, bevor sie ausgeführt wird.\",\n                    \"protected\": \"Geschützt\"\n                },\n                \"save\": \"Einstellungen speichern\",\n                \"success\": \"Projekt-Einstellungen aktualisiert\",\n                \"timeout\": {\n                    \"minutes\": \"Minuten\",\n                    \"timeout\": \"Zeitlimit\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Die zugrundeliegenden Pipeline-Container erhalten Zugriff auf ausgeweitete Funktionen (z. B. das Einhängen von Laufwerken).\",\n                    \"trusted\": \"Vertrauenswürdig\",\n                    \"network\": {\n                        \"network\": \"Netzwerk\",\n                        \"desc\": \"Pipeline-Container erhalten Zugriff auf Netzwerk-Privilegien wie das Ändern des DNS.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumen\",\n                        \"desc\": \"Pipeline-Container können Volumen einhängen.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sicherheit\",\n                        \"desc\": \"Pipeline-Container erhalten Zugriff auf Sicherheits-Privilegien.\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Nur authentifizierte Benutzer der Woodpecker-Instanz können dieses Projekt sehen.\",\n                        \"internal\": \"Intern\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Nur du und andere Besitzer des Repositorys können dieses Projekt sehen.\",\n                        \"private\": \"Privat\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Jeder Benutzer kann dein Projekt sehen, ohne eingeloggt zu sein.\",\n                        \"public\": \"Öffentlich\"\n                    },\n                    \"visibility\": \"Sichtbarkeit des Projekts\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Deployment-Events erlauben\",\n                    \"desc\": \"Deployments von erfolgreichen Pipelines erlauben. Alle Benutzer mit Push-Zugriff können diese auslösen, mit Vorsicht verwenden.\"\n                }\n            },\n            \"not_allowed\": \"Zugriff auf die Einstellungen dieses Repositorys nicht erlaubt\",\n            \"registries\": {\n                \"add\": \"Registry hinzufügen\",\n                \"address\": {\n                    \"address\": \"Adresse\",\n                    \"placeholder\": \"Registry-Adresse (z. B. docker.io)\"\n                },\n                \"created\": \"Registry-Zugangsdaten erstellt\",\n                \"credentials\": \"Zugangsdaten für die Registry\",\n                \"delete\": \"Registry löschen\",\n                \"deleted\": \"Registry-Zugangsdaten gelöscht\",\n                \"desc\": \"Zugangsdaten für die Registries können hinzugefügt werden, um private Images für deine Pipelines zu verwenden.\",\n                \"edit\": \"Registry bearbeiten\",\n                \"none\": \"Es gibt noch keine Zugangsdaten für die Registry.\",\n                \"registries\": \"Registries\",\n                \"save\": \"Registry speichern\",\n                \"saved\": \"Registry-Zugangsdaten gespeichert\",\n                \"show\": \"Registries anzeigen\"\n            },\n            \"secrets\": {\n                \"add\": \"Geheimnis hinzufügen\",\n                \"created\": \"Geheimnis erstellt\",\n                \"delete\": \"Geheimnis löschen\",\n                \"delete_confirm\": \"Möchtest du dieses Geheimnis wirklich löschen?\",\n                \"deleted\": \"Geheimnis gelöscht\",\n                \"desc\": \"Geheimnisse können zur Laufzeit als Umgebungsvariablen an einzelne Pipelineschritte übergeben werden.\",\n                \"edit\": \"Geheimnis bearbeiten\",\n                \"events\": {\n                    \"events\": \"Verfügbar bei folgenden Ereignissen\",\n                    \"pr_warning\": \"Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste der Images, für die dieses Geheimnis verfügbar ist; leer lassen, um alle Images zuzulassen\",\n                    \"images\": \"Verfügbar für folgende Images\"\n                },\n                \"name\": \"Name\",\n                \"none\": \"Es gibt noch keine Geheimnisse.\",\n                \"plugins_only\": \"Nur für Plugins verfügbar\",\n                \"save\": \"Geheimnis speichern\",\n                \"saved\": \"Geheimnis gespeichert\",\n                \"secrets\": \"Geheimnisse\",\n                \"show\": \"Geheimnisse anzeigen\",\n                \"value\": \"Wert\"\n            },\n            \"settings\": \"Einstellungen\"\n        },\n        \"user_none\": \"Diese Organisation / dieser Benutzer hat noch keine Repositorys\",\n        \"visibility\": {\n            \"visibility\": \"Projektsichtbarkeit\",\n            \"public\": {\n                \"public\": \"Öffentlich\",\n                \"desc\": \"Jeder kann dein Projekt sehen, ohne eingeloggt zu sein.\"\n            },\n            \"private\": {\n                \"private\": \"Privat\",\n                \"desc\": \"Nur du und andere Besitzer des Repositorys können dieses Projekt sehen.\"\n            },\n            \"internal\": {\n                \"internal\": \"Intern\",\n                \"desc\": \"Nur authentifizierte Benutzer der Woodpecker-Instanz können dieses Projekt sehen.\"\n            }\n        }\n    },\n    \"repos\": \"Repos\",\n    \"repositories\": {\n        \"title\": \"Repositorys\",\n        \"all\": {\n            \"title\": \"Alle Repositorys\",\n            \"desc\": \"Repositorys nach der letzten Erstellung einer Pipeline sortiert\"\n        },\n        \"last\": {\n            \"title\": \"Zuletzt besucht\",\n            \"desc\": \"Zuletzt besuchte Repositorys nach Zugriffszeit sortiert\"\n        }\n    },\n    \"running_version\": \"Du verwendest Woodpecker {0}\",\n    \"search\": \"Suche…\",\n    \"time\": {\n        \"days_short\": \"t\",\n        \"hours_short\": \"h\",\n        \"min_short\": \"min\",\n        \"not_started\": \"noch nicht gestartet\",\n        \"sec_short\": \"sek\",\n        \"template\": \"DD.MM.YYYY, HH:mm z\",\n        \"weeks_short\": \"w\",\n        \"just_now\": \"gerade eben\"\n    },\n    \"unknown_error\": \"Ein unbekannter Fehler ist aufgetreten\",\n    \"update_woodpecker\": \"Du solltest deine Woodpecker-Instanz auf {0} aktualisieren\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Du bist nicht berechtigt, dich anzumelden\",\n        \"internal_error\": \"Ein interner Fehler ist aufgetreten\",\n        \"oauth_error\": \"Fehler bei der Authentifizierung gegen OAuth-Anbieter\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Beispiel zur Nutzung der API\",\n                \"cli_usage\": \"Beispiel zur Nutzung des CLI\",\n                \"desc\": \"Persönlicher Zugangsschlüssel und API-Nutzung\",\n                \"dl_cli\": \"CLI herunterladen\",\n                \"reset_token\": \"Zugangsschlüssel zurücksetzen\",\n                \"shell_setup\": \"Kommandozeilen-Einrichtung\",\n                \"shell_setup_before\": \"führe bitte die Schritte zur Einrichtung der Kommandozeile vorher aus\",\n                \"swagger_ui\": \"Swagger-UI\",\n                \"token\": \"Persönlicher Zugangsschlüssel\"\n            },\n            \"general\": {\n                \"general\": \"Konto\",\n                \"language\": \"Sprache\",\n                \"theme\": {\n                    \"auto\": \"Automatisch\",\n                    \"dark\": \"Dunkel\",\n                    \"light\": \"Hell\",\n                    \"theme\": \"Thema\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Geheimnis hinzufügen\",\n                \"created\": \"Benutzer-Geheimnis erstellt\",\n                \"deleted\": \"Benutzer-Geheimnis gelöscht\",\n                \"desc\": \"Benutzer-Geheimnisse können in allen Pipelines in Repositorys, die dem Benutzer gehören, genutzt werden.\",\n                \"events\": {\n                    \"events\": \"Verfügbar für folgende Ereignisse\",\n                    \"pr_warning\": \"Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte.\"\n                },\n                \"images\": {\n                    \"desc\": \"Komma getrennte Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben\",\n                    \"images\": \"Verfügbar für die folgenden Images\"\n                },\n                \"name\": \"Name\",\n                \"none\": \"Es existieren noch keine Benutzer-Geheimnisse.\",\n                \"plugins_only\": \"Nur für Plugins verfügbar\",\n                \"save\": \"Geheimnis speichern\",\n                \"saved\": \"Benutzer-Geheimnis gespeichert\",\n                \"secrets\": \"Geheimnisse\",\n                \"show\": \"Geheimnisse anzeigen\",\n                \"value\": \"Wert\"\n            },\n            \"settings\": \"Benutzereinstellungen\",\n            \"cli_and_api\": {\n                \"desc\": \"Persönlicher Zugangsschlüssel, CLI- und API-Nutzung\",\n                \"token\": \"Persönlicher Zugangsschlüssel\",\n                \"api_usage\": \"Beispiel zur Nutzung des API\",\n                \"download_cli\": \"CLI herunterladen\",\n                \"reset_token\": \"Schlüssel zurücksetzen\",\n                \"swagger_ui\": \"Swagger-UI\",\n                \"cli_and_api\": \"CLI/API\",\n                \"cli_usage\": \"Beispiel zur Nutzung des CLI\"\n            },\n            \"registries\": {\n                \"desc\": \"Register-Zugangsdaten des Benutzers können für private Images aller persönlichen Pipelines verwendet werden.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agenten, die für die Repositorys deines Benutzers registriert sind.\"\n            }\n        }\n    },\n    \"username\": \"Benutzername\",\n    \"welcome\": \"Willkommen bei Woodpecker\",\n    \"login_to_cli\": \"In CLI anmelden\",\n    \"login_to_cli_description\": \"Wenn du fortfährst, wirst du im CLI angemeldet.\",\n    \"abort\": \"Abbrechen\",\n    \"cli_login_success\": \"Anmelden im CLI erfolgreich\",\n    \"cli_login_failed\": \"Anmelden im CLI fehlgeschlagen\",\n    \"cli_login_denied\": \"Anmelden im CLI abgelehnt\",\n    \"return_to_cli\": \"Du kannst diesen Tab jetzt schließen und zur CLI zurückkehren.\",\n    \"secrets\": {\n        \"secrets\": \"Geheimnisse\",\n        \"desc\": \"Geheimnisse können in allen Pipelines des Repositorys genutzt werden.\",\n        \"none\": \"Es gibt noch keine Geheimnisse.\",\n        \"add\": \"Geheimnis hinzufügen\",\n        \"save\": \"Geheimnis speichern\",\n        \"show\": \"Geheimnisse anzeigen\",\n        \"name\": \"Name\",\n        \"value\": \"Wert\",\n        \"delete_confirm\": \"Möchtest du dieses Geheimnis wirklich löschen?\",\n        \"deleted\": \"Geheimnis gelöscht\",\n        \"created\": \"Geheimnis erstellt\",\n        \"saved\": \"Geheimnis gespeichert\",\n        \"images\": {\n            \"images\": \"Verfügbar für die folgenden Images\",\n            \"desc\": \"Liste der Images, für die dieses Geheimnis verfügbar ist; leer lassen, um für alle Images zuzulassen.\"\n        },\n        \"events\": {\n            \"events\": \"Verfügbar bei den folgenden Ereignissen\",\n            \"pr_warning\": \"Bitte sei vorsichtig mit dieser Option: Eine böswillige Person könnte über einen Pull-Request deine Geheimnisse erhalten.\",\n            \"warning\": \"Geheimnisse in Pull-Requests zu erlauben könnte dazu führen, dass ein böswilliger Akteur diese mit einem Pull-Request stiehlt.\"\n        },\n        \"plugins_only\": \"Nur für Plugins verfügbar\",\n        \"edit\": \"Geheimnis bearbeiten\",\n        \"delete\": \"Geheimnis löschen\",\n        \"plugins\": {\n            \"images\": \"Nur für die folgenden Plugins verfügbar\",\n            \"desc\": \"Liste aller Plugin-Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Plugins und allgemeine Schritte zu erlauben.\"\n        },\n        \"note\": \"Notiz\"\n    },\n    \"settings\": \"Einstellungen\",\n    \"oauth_error\": \"Fehler bei der Authentifizierung mit OAuth-Anbieter\",\n    \"internal_error\": \"Interner Fehler aufgetreten\",\n    \"registration_closed\": \"Die Registrierung ist geschlossen\",\n    \"access_denied\": \"Du darfst nicht auf diese Instanz zugreifen\",\n    \"registries\": {\n        \"delete_confirm\": \"Möchtest du dieses Register wirklich löschen?\",\n        \"address\": {\n            \"desc\": \"Register-Adresse (z. B. docker.io)\",\n            \"address\": \"Adresse\"\n        },\n        \"credentials\": \"Register-Zugangsdaten\",\n        \"none\": \"Es sind noch keine Registry-Zugangsdaten verfügbar.\",\n        \"registries\": \"Register\",\n        \"desc\": \"Register-Zugangsdaten können hinzugefügt werden, um private Images für Pipelines zu nutzen.\",\n        \"deleted\": \"Register-Zugangsdaten gelöscht\",\n        \"save\": \"Speicher Registry\",\n        \"add\": \"Register hinzufügen\",\n        \"view\": \"Register ansehen\",\n        \"edit\": \"Register bearbeiten\",\n        \"delete\": \"Register löschen\",\n        \"created\": \"Register-Zugangsdaten erstellt\",\n        \"saved\": \"Register-Zugangsdaten gespeichert\",\n        \"show\": \"Register anzeigen\"\n    },\n    \"invalid_state\": \"Der OAuth-Status ist ungültig\",\n    \"by_user\": \"von {user}\",\n    \"pushed_to\": \"gepusht auf\",\n    \"closed\": \"geschlossen\",\n    \"deployed_to\": \"Deployment ausgeführt auf\",\n    \"created\": \"erstellt\",\n    \"triggered\": \"ausgelöst\",\n    \"pipeline_duration\": \"Pipeline-Dauer\",\n    \"pipeline_since\": \"Die Pipeline wurde vor {created} Minuten erstellt\",\n    \"pipeline_has_warnings\": \"Die Pipeline hat Warnungen\",\n    \"pipeline_has_errors\": \"Die Pipeline hat Fehler\",\n    \"login_with\": \"Mit {forge} anmelden\",\n    \"all_repositories\": \"Alle Repositorys\",\n    \"no_search_results\": \"Keine Ergebnisse gefunden\",\n    \"require_approval\": {\n        \"forks\": \"Pull-Request von geforkten Repositorys\",\n        \"require_approval_for\": \"Erfordert Genehmigung für\",\n        \"none\": \"Keine Genehmigung erforderlich\",\n        \"none_desc\": \"Diese Einstellung kann gefährlich sein und sollte nur in privaten Repositories verwendet werden, in denen allen Benutzern vertraut wird.\",\n        \"pull_requests\": \"Alle Pull-Requests\",\n        \"all_events\": \"Alle Ereignisse von der Code-Plattform\",\n        \"desc\": \"Verhindere, dass bösartige Pipelines Geheimnisse preisgeben oder schädliche Aufgaben ausführen, indem du diese vor der Ausführung genehmigst.\",\n        \"allowed_users\": {\n            \"desc\": \"Pipelines von diesen Benutzern erfordern nie eine Genehmigung.\",\n            \"allowed_users\": \"Zugelassene Benutzer\"\n        }\n    },\n    \"org_access_denied\": \"Zugriff auf diese Organisation nicht erlaubt\",\n    \"exit_fullscreen\": \"Vollbild verlassen\",\n    \"fullscreen\": \"Vollbild\",\n    \"oauth_host\": \"OAuth-Host\",\n    \"merge_ref\": \"Zusammenführungs-Referenz\",\n    \"merge_ref_desc\": \"Referenz, die für die Zusammenführungs-Basis genutzt wird. Dies wird für die Unterschiede bei Pull-Requests verwerdet.\",\n    \"public_only\": \"Nur öffentlich\",\n    \"public_only_desc\": \"Nur öffentliche Repositorys anzeigen.\",\n    \"git_username\": \"Git-Benutzername\",\n    \"git_password\": \"Git-Passwort\",\n    \"executable_desc\": \"Pfad zur Programmdatei des Add-ons.\",\n    \"save\": \"Speichern\",\n    \"add\": \"Hinzufügen\",\n    \"skip_verify\": \"SSL-Verifikation überspringen\",\n    \"advanced_options\": \"Erweiterte Optionen\",\n    \"leave_empty_to_keep_current_value\": \"Leer lassen, um den aktuellen Wert beizubehalten\",\n    \"forge_deleted\": \"Plattform gelöscht\",\n    \"login_to_woodpecker_with\": \"In Woodpecker anmelden mit\",\n    \"forges\": \"Plattformen\",\n    \"forges_desc\": \"Die Plattformen konfigurieren, auf denen Repositorys gehostet sind, für die Woodpecker verwendet wird.\",\n    \"add_forge\": \"Plattform hinzufügen\",\n    \"show_forges\": \"Plattformen anzeigen\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Add-on\",\n    \"forge_type\": \"Plattform-Typ\",\n    \"oauth_client_id\": \"OAuth-Client-ID\",\n    \"oauth_client_secret\": \"OAuth-Client-Geheimnis\",\n    \"git_username_desc\": \"Benutzername für den Git-Benutzer.\",\n    \"git_password_desc\": \"Password oder persönlicher Zugangsschlüssel für den Git-Benutzer.\",\n    \"executable\": \"Programmdatei\",\n    \"skip_verify_desc\": \"SSL-Verifikation für API-Verbindungen überspringen. Im Produktiveinsatz nicht empfohlen.\",\n    \"forge_managed_by_env\": \"Die primäre Plattform wird durch Umgebungsvariablen verwaltet. Jede Änderung dieser Plattform wird bei einem Neustart verworfen.\",\n    \"oauth_redirect_uri\": \"OAuth-Weiterleitungs-URI\",\n    \"forge_created\": \"Plattform erstellt\",\n    \"forge_delete_confirm\": \"Möchtest du diese Plattform wirklich löschen? Dies wird auch alle Repositorys, Benutzer und Pipelines dieser Plattform löschen.\",\n    \"edit_forge\": \"Plattform bearbeiten\",\n    \"delete_forge\": \"Plattform löschen\",\n    \"no_forges\": \"Es gibt noch keine Plattformen.\",\n    \"use_this_redirect_uri_to_create\": \"Nutze diese Weiterleitungs-URL, um die OAuth-Anwendung zu erstellen oder zu aktualisieren. Gehe zu den {0} und richte die OAuth-Anwendung ein.\",\n    \"developer_settings\": \"Entwicklereinstellungen\",\n    \"public_url_for_oauth_if\": \"Öffentliche URL für OAuth, wenn unterschiedlich von URL ({0})\",\n    \"forge_saved\": \"Plattform gespeichert\",\n    \"oauth_redirect_url\": \"OAuth-Weiterleitungs-URL\",\n    \"developer_settings_to_create\": \"Gehe zu den {0} und richte die OAuth-Anwendung ein.\",\n    \"weblate\": \"unserem Weblate\",\n    \"use_this_redirect_url_to_create\": \"Nutze diese Weiterleitungs-URL, um die OAuth-Anwendung zu erstellen oder zu aktualisieren.\",\n    \"help_translating\": \"Du kannst auf {0} helfen, Woodpecker in deine Sprache zu übersetzen.\",\n    \"extensions\": \"Erweiterungen\",\n    \"extension_endpoint_placeholder\": \"z. B. https://example.com/api\",\n    \"config_extension_endpoint\": \"Endpunkt der Konfigurations-Erweiterung\",\n    \"extensions_description\": \"Erweiterungen sind HTTP-Dienste, die von Woodpecker anstelle der integrierten aufgerufen werden können.\",\n    \"extensions_signatures_public_key\": \"Öffentlicher Schlüssel für Signaturen\",\n    \"extensions_configuration_saved\": \"Konfiguration der Erweiterungen gespeichert\",\n    \"extensions_signatures_public_key_description\": \"Dieser öffentliche Schlüssel sollte von deinen Erweiterungen verwendet werden, um Webhook-Aufrufe von Woodpecker zu verifizieren.\",\n    \"disabled\": \"Deaktiviert\",\n    \"config_extension_exclusive\": \"Exklusiv\",\n    \"config_extension_exclusive_desc\": \"Wenn aktiviert, werden alle anderen Möglichkeiten zum Laden der Konfiguration, einschließlich der Code-Plattform, übersprungen.\",\n    \"registry_extension_endpoint\": \"Endpunkt der Register-Erweiterung\",\n    \"global_level_registry\": \"globales Register\",\n    \"org_level_registry\": \"Organisationsregister\",\n    \"secret_extension_endpoint\": \"Endpunkt der Geheimnis-Erweiterung\",\n    \"secret_extension_netrc\": \"Netrc-Zugangsdaten senden\",\n    \"secret_extension_netrc_desc\": \"Die Netrc-Zugangsdaten für die Code-Plattform an die Geheimnis-Erweiterung senden.\",\n    \"extension_netrc\": \"Netrc-Zugangsdaten bereitstellen\",\n    \"extension_netrc_desc\": \"Die Netrc-Zugangsdaten der Code-Plattform an die Erweiterung weitergeben.\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/en.json",
    "content": "{\n  \"cancel\": \"Cancel\",\n  \"login_to_woodpecker_with\": \"Login to Woodpecker with\",\n  \"login\": \"Login\",\n  \"repos\": \"Repos\",\n  \"repositories\": {\n    \"title\": \"Repositories\",\n    \"all\": {\n      \"title\": \"All repositories\",\n      \"desc\": \"Repositories sorted by last pipeline creation\"\n    },\n    \"last\": {\n      \"title\": \"Last visited\",\n      \"desc\": \"Most recently visited repositories sorted by access time\"\n    }\n  },\n  \"docs\": \"Docs\",\n  \"api\": \"API\",\n  \"logout\": \"Logout\",\n  \"search\": \"Search…\",\n  \"username\": \"Username\",\n  \"password\": \"Password\",\n  \"back\": \"Back\",\n  \"unknown_error\": \"An unknown error occurred\",\n  \"documentation_for\": \"Documentation for \\\"{topic}\\\"\",\n  \"pipeline_feed\": \"Pipeline feed\",\n  \"empty_list\": \"No {entity} found!\",\n  \"not_found\": {\n    \"not_found\": \"Whoa 404, either we broke something or you had a typing mishap :-/\",\n    \"back_home\": \"Back to home\"\n  },\n  \"errors\": {\n    \"not_found\": \"Server could not find requested object\"\n  },\n  \"time\": {\n    \"not_started\": \"not started yet\",\n    \"just_now\": \"just now\"\n  },\n  \"repo\": {\n    \"manual_pipeline\": {\n      \"title\": \"Trigger a manual pipeline run\",\n      \"trigger\": \"Run pipeline\",\n      \"select_branch\": \"Select branch\",\n      \"variables\": {\n        \"delete\": \"Delete variable\",\n        \"title\": \"Additional pipeline variables\",\n        \"desc\": \"Specify additional variables to be used in your pipeline. Variables with the same name are overwritten.\",\n        \"name\": \"Variable name\",\n        \"value\": \"Variable value\"\n      },\n      \"show_pipelines\": \"Show pipelines\",\n      \"no_manual_workflows\": \"No matching workflows found. Make sure at least one workflow runs on the manual event.\"\n    },\n    \"deploy_pipeline\": {\n      \"title\": \"Trigger a deployment for current pipeline #{pipelineId}\",\n      \"enter_target\": \"Target environment for deployment\",\n      \"enter_task\": \"Deployment task\",\n      \"trigger\": \"Deploy\",\n      \"variables\": {\n        \"delete\": \"Delete variable\",\n        \"title\": \"Additional pipeline variables\",\n        \"desc\": \"Specify additional variables to be used in your pipeline. Variables with the same name are overwritten.\",\n        \"name\": \"Variable name\",\n        \"value\": \"Variable value\"\n      }\n    },\n    \"activity\": \"Activity\",\n    \"branches\": \"Branches\",\n    \"pull_requests\": \"Pull requests\",\n    \"add\": \"Add repository\",\n    \"user_none\": \"This organization/user has no projects yet\",\n    \"not_allowed\": \"You are not allowed to access this repository\",\n    \"enable\": {\n      \"enable\": \"Enable\",\n      \"enabled\": \"Already enabled\",\n      \"disabled\": \"Disabled\",\n      \"success\": \"Repository enabled\",\n      \"new_forge_repo\": \"new repo on forge\",\n      \"stale_wp_repo\": \"outdated Woodpecker repo\",\n      \"conflict\": \"Conflict\",\n      \"conflict_desc\": \"This repository was recreated on the forge with a new ID, but an outdated entry with the same name still exists in Woodpecker. Delete the outdated to enable the new one or Repair the old.\",\n      \"forge_repo_missing\": \"Forge repo is missing!\"\n    },\n    \"open_in_forge\": \"Open repository in forge\",\n    \"visibility\": {\n      \"visibility\": \"Project visibility\",\n      \"public\": {\n        \"public\": \"Public\",\n        \"desc\": \"Anyone can see your project without being logged in.\"\n      },\n      \"private\": {\n        \"private\": \"Private\",\n        \"desc\": \"Only you and other owners of the repository can see this project.\"\n      },\n      \"internal\": {\n        \"internal\": \"Internal\",\n        \"desc\": \"Only authenticated users of the Woodpecker instance can see this project.\"\n      }\n    },\n    \"settings\": {\n      \"not_allowed\": \"You are not allowed to access the settings of this repository\",\n      \"general\": {\n        \"general\": \"Project\",\n        \"project\": \"Project settings\",\n        \"save\": \"Save settings\",\n        \"success\": \"Project settings updated\",\n        \"pipeline_path\": {\n          \"path\": \"Pipeline path\",\n          \"default\": \"By default: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n          \"desc\": \"Path to your pipeline config (for example {0}). Folders should end with a {1}.\",\n          \"desc_path_example\": \"my/path/\"\n        },\n        \"allow_pr\": {\n          \"allow\": \"Allow Pull Requests\",\n          \"desc\": \"Allow the execution of pipelines on pull requests.\"\n        },\n        \"allow_deploy\": {\n          \"allow\": \"Allow Deployments\",\n          \"desc\": \"Allow deployments for successful pipelines. All users with push permissions can trigger these, so use with caution.\"\n        },\n        \"netrc_only_trusted\": {\n          \"netrc_only_trusted\": \"Custom trusted clone plugins\",\n          \"desc\": \"Plugins that get access to netrc credentials that can be used to clone repositories from the forge or push them into the forge.\"\n        },\n        \"trusted\": {\n          \"trusted\": \"Trusted\",\n          \"network\": {\n            \"network\": \"Network\",\n            \"desc\": \"Pipeline containers get access to network privileges like changing DNS.\"\n          },\n          \"volumes\": {\n            \"volumes\": \"Volumes\",\n            \"desc\": \"Pipeline containers are allowed to mount volumes.\"\n          },\n          \"security\": {\n            \"security\": \"Security\",\n            \"desc\": \"Pipeline containers get access to security privileges.\"\n          }\n        },\n        \"timeout\": {\n          \"timeout\": \"Timeout\",\n          \"minutes\": \"minutes\"\n        },\n        \"cancel_prev\": {\n          \"cancel\": \"Cancel previous pipelines\",\n          \"desc\": \"Selected event triggers cancel pending and running pipelines of the same event before starting the next one.\"\n        }\n      },\n      \"crons\": {\n        \"crons\": \"Crons\",\n        \"desc\": \"Cron jobs can be used to trigger pipelines on a regular basis.\",\n        \"show\": \"Show crons\",\n        \"add\": \"Add cron\",\n        \"none\": \"There are no crons yet.\",\n        \"save\": \"Save cron\",\n        \"created\": \"Cron created\",\n        \"saved\": \"Cron saved\",\n        \"deleted\": \"Cron deleted\",\n        \"next_exec\": \"Next execution\",\n        \"not_executed_yet\": \"Not executed yet\",\n        \"run\": \"Run now\",\n        \"branch\": {\n          \"title\": \"Branch\",\n          \"placeholder\": \"Branch (uses default branch if empty)\"\n        },\n        \"name\": {\n          \"name\": \"Name\",\n          \"placeholder\": \"Name of the cron job\"\n        },\n        \"schedule\": {\n          \"title\": \"Schedule (based on UTC)\",\n          \"placeholder\": \"Schedule\"\n        },\n        \"edit\": \"Edit cron\",\n        \"delete\": \"Delete cron\",\n        \"enabled\": \"Enabled\"\n      },\n      \"badge\": {\n        \"badge\": \"Badge\",\n        \"type\": \"Syntax\",\n        \"type_url\": \"URL\",\n        \"type_markdown\": \"Markdown\",\n        \"type_html\": \"HTML\",\n        \"branch\": \"Branch\",\n        \"events\": \"Events\",\n        \"workflow\": \"Workflow\",\n        \"step\": \"Step\"\n      },\n      \"actions\": {\n        \"actions\": \"Actions\",\n        \"repair\": {\n          \"repair\": \"Repair repository\",\n          \"success\": \"Repository repaired\"\n        },\n        \"disable\": {\n          \"disable\": \"Disable repository\",\n          \"success\": \"Repository disabled\"\n        },\n        \"enable\": {\n          \"enable\": \"Enable repository\",\n          \"success\": \"Repository enabled\"\n        },\n        \"delete\": {\n          \"delete\": \"Delete repository\",\n          \"confirm\": \"All data will be lost after this action!\\n\\nDo you really want to proceed?\",\n          \"success\": \"Repository deleted\"\n        }\n      }\n    },\n    \"pipeline\": {\n      \"tasks\": \"Tasks\",\n      \"config\": \"Config\",\n      \"files\": \"Changed files\",\n      \"no_pipelines\": \"No pipelines have been started yet.\",\n      \"load_more\": \"Load more\",\n      \"no_pipeline_steps\": \"No pipeline steps available!\",\n      \"step_not_started\": \"This step hasn't started yet.\",\n      \"pipelines_for\": \"Pipelines for branch \\\"{branch}\\\"\",\n      \"pipelines_for_pr\": \"Pipelines for pull request #{index}\",\n      \"exit_code\": \"Exit Code {exitCode}\",\n      \"loading\": \"Loading…\",\n      \"no_logs\": \"No logs\",\n      \"pipeline\": \"Pipeline #{pipelineId}\",\n      \"log_title\": \"Step Logs\",\n      \"log_download_error\": \"An error occurred while downloading the log file\",\n      \"log_delete_confirm\": \"Do you really want to delete the step logs?\",\n      \"log_delete_error\": \"An error occurred when deleting the step logs\",\n      \"actions\": {\n        \"cancel\": \"Cancel\",\n        \"restart\": \"Restart\",\n        \"canceled\": \"This step has been canceled.\",\n        \"skipped\": \"This step has been skipped.\",\n        \"cancel_success\": \"Pipeline canceled\",\n        \"deploy\": \"Deploy\",\n        \"restart_success\": \"Pipeline restarted\",\n        \"log_download\": \"Download\",\n        \"log_delete\": \"Delete\",\n        \"log_auto_scroll\": \"Enable automatic scrolling\",\n        \"log_auto_scroll_off\": \"Disable automatic scrolling\",\n        \"expand_all\": \"Expand all\",\n        \"collapse_all\": \"Collapse all\"\n      },\n      \"protected\": {\n        \"awaits\": \"This pipeline is awaiting approval from a maintainer!\",\n        \"approve\": \"Approve\",\n        \"decline\": \"Decline\",\n        \"declined\": \"This pipeline has been declined!\",\n        \"approve_success\": \"Pipeline approved\",\n        \"decline_success\": \"Pipeline declined\"\n      },\n      \"event\": {\n        \"push\": \"Push\",\n        \"tag\": \"Tag\",\n        \"pr\": \"Pull Request\",\n        \"pr_closed\": \"Pull Request merged/closed\",\n        \"pr_metadata\": \"Pull Request metadata changed\",\n        \"deploy\": \"Deploy\",\n        \"cron\": \"Cron\",\n        \"manual\": \"Manual\",\n        \"release\": \"Release\"\n      },\n      \"status\": {\n        \"status\": \"Status: {status}\",\n        \"blocked\": \"blocked\",\n        \"pending\": \"pending\",\n        \"running\": \"running\",\n        \"started\": \"started\",\n        \"skipped\": \"skipped\",\n        \"canceled\": \"canceled\",\n        \"success\": \"success\",\n        \"declined\": \"declined\",\n        \"error\": \"error\",\n        \"failure\": \"failure\",\n        \"killed\": \"killed\"\n      },\n      \"errors\": \"Errors\",\n      \"warnings\": \"Warnings\",\n      \"show_errors\": \"Show errors\",\n      \"we_got_some_errors\": \"Oh no, an error occurred!\",\n      \"duration\": \"Pipeline duration: {duration}\",\n      \"created\": \"Created: {created}\",\n      \"version\": \"The Woodpecker version this pipeline was executed on.\",\n      \"version_header\": \"Woodpecker version\",\n      \"cancel_info\": {\n        \"superseded_by\": \"Superseded by #{pipelineId}\",\n        \"canceled_by_user\": \"Canceled by {user}\",\n        \"canceled_by_step\": \"Canceled due to {step}\"\n      },\n      \"debug\": {\n        \"title\": \"Debug\",\n        \"download_metadata\": \"Download metadata\",\n        \"metadata_download_error\": \"Error downloading metadata\",\n        \"metadata_download_successful\": \"Metadata downloaded successfully\",\n        \"no_permission\": \"You are not allowed to access the debug information\",\n        \"metadata_exec_title\": \"Re-run pipeline locally\",\n        \"metadata_exec_desc\": \"Download the metadata of this pipeline to run it locally. This allows you to fix problems and test changes before committing them. The Woodpecker CLI must be installed locally in the same version as the server.\"\n      },\n      \"view\": \"View pipeline\"\n    }\n  },\n  \"org\": {\n    \"settings\": {\n      \"not_allowed\": \"You are not allowed to access the settings of this organization\",\n      \"secrets\": {\n        \"desc\": \"Organization secrets can be used in the pipelines of all repositories owned by the organization.\"\n      },\n      \"registries\": {\n        \"desc\": \"Organization registry credentials can be added to use private images for all pipelines of an organization.\"\n      },\n      \"agents\": {\n        \"desc\": \"Agents registered for this organization.\"\n      }\n    }\n  },\n  \"admin\": {\n    \"settings\": {\n      \"settings\": \"Admin Settings\",\n      \"not_allowed\": \"You are not allowed to access server settings\",\n      \"secrets\": {\n        \"desc\": \"Global secrets can be used in the pipelines of all repositories.\",\n        \"warning\": \"These secrets are available to all users.\"\n      },\n      \"registries\": {\n        \"desc\": \"Global registry credentials can be added to use private images for all pipelines.\",\n        \"warning\": \"These registry credentials are available to all users.\"\n      },\n      \"agents\": {\n        \"agents\": \"Agents\",\n        \"desc\": \"Agents registered on this server.\",\n        \"none\": \"There are no agents yet.\",\n        \"id\": \"ID\",\n        \"add\": \"Add agent\",\n        \"save\": \"Save agent\",\n        \"show\": \"Show agents\",\n        \"created\": \"Agent created\",\n        \"saved\": \"Agent saved\",\n        \"deleted\": \"Agent deleted\",\n        \"name\": {\n          \"name\": \"Name\",\n          \"placeholder\": \"Name of the agent\"\n        },\n        \"no_schedule\": {\n          \"name\": \"Disable agent\",\n          \"placeholder\": \"Stop agent from taking new tasks\"\n        },\n        \"token\": \"Token\",\n        \"platform\": {\n          \"platform\": \"Platform\",\n          \"badge\": \"platform\"\n        },\n        \"backend\": {\n          \"backend\": \"Backend\",\n          \"badge\": \"backend\"\n        },\n        \"capacity\": {\n          \"capacity\": \"Capacity\",\n          \"desc\": \"The maximum amount of parallel pipelines executed by this agent.\",\n          \"badge\": \"capacity\"\n        },\n        \"custom_labels\": {\n          \"custom_labels\": \"Custom Labels\",\n          \"desc\": \"The custom labels set by the agent admin on agent startup.\"\n        },\n        \"org\": {\n          \"badge\": \"org\"\n        },\n        \"version\": \"Version\",\n        \"last_contact\": {\n          \"last_contact\": \"Last contact\",\n          \"badge\": \"last contact\"\n        },\n        \"never\": \"Never\",\n        \"delete_confirm\": \"Do you really want to delete this agent? It will no longer be able to connect to the server.\",\n        \"edit_agent\": \"Edit agent\",\n        \"delete_agent\": \"Delete agent\"\n      },\n      \"queue\": {\n        \"queue\": \"Queue\",\n        \"desc\": \"Tasks waiting to be executed by agents.\",\n        \"pause\": \"Pause\",\n        \"resume\": \"Resume\",\n        \"paused\": \"Queue is paused\",\n        \"resumed\": \"Queue is resumed\",\n        \"tasks\": \"Tasks\",\n        \"task_running\": \"Task is running\",\n        \"task_pending\": \"Task is pending\",\n        \"task_waiting_on_deps\": \"Task is waiting on dependencies\",\n        \"agent\": \"agent\",\n        \"waiting_for\": \"waiting for\",\n        \"stats\": {\n          \"completed_count\": \"Completed Tasks\",\n          \"worker_count\": \"Free\",\n          \"running_count\": \"Running\",\n          \"pending_count\": \"Pending\",\n          \"waiting_on_deps_count\": \"Waiting on dependencies\"\n        }\n      },\n      \"users\": {\n        \"users\": \"Users\",\n        \"desc\": \"Users registered for this server.\",\n        \"login\": \"Login\",\n        \"email\": \"Email\",\n        \"avatar_url\": \"Avatar URL\",\n        \"save\": \"Save user\",\n        \"cancel\": \"Cancel\",\n        \"show\": \"Show users\",\n        \"add\": \"Add user\",\n        \"none\": \"There are no users yet.\",\n        \"delete_confirm\": \"Do you really want to delete this user? This will also delete all repositories owned by this user.\",\n        \"deleted\": \"User deleted\",\n        \"created\": \"User created\",\n        \"saved\": \"User saved\",\n        \"admin\": {\n          \"admin\": \"Admin\",\n          \"placeholder\": \"User is an admin\"\n        },\n        \"delete_user\": \"Delete user\",\n        \"edit_user\": \"Edit user\"\n      },\n      \"orgs\": {\n        \"orgs\": \"Organizations\",\n        \"desc\": \"Organizations owning repositories on this server.\",\n        \"none\": \"There are no organizations yet.\",\n        \"org_settings\": \"Organization settings\",\n        \"delete_org\": \"Delete organization\",\n        \"deleted\": \"Organization deleted\",\n        \"delete_confirm\": \"Do you really want to delete this organization? This will also delete all repositories owned by this organization.\",\n        \"view\": \"View organization\"\n      },\n      \"repos\": {\n        \"repos\": \"Repositories\",\n        \"desc\": \"Repositories that are or were activated on this server.\",\n        \"none\": \"There are no repositories yet.\",\n        \"view\": \"View repository\",\n        \"settings\": \"Repository settings\",\n        \"disabled\": \"Disabled\",\n        \"repair\": {\n          \"repair\": \"Repair all\",\n          \"success\": \"Repositories repaired\"\n        }\n      }\n    }\n  },\n  \"user\": {\n    \"settings\": {\n      \"settings\": \"User Settings\",\n      \"general\": {\n        \"general\": \"Account\",\n        \"language\": \"Language\",\n        \"theme\": {\n          \"theme\": \"Theme\",\n          \"light\": \"Light\",\n          \"dark\": \"Dark\",\n          \"auto\": \"Auto\"\n        }\n      },\n      \"secrets\": {\n        \"desc\": \"User secrets can be used in the pipelines of all repositories owned by the user.\"\n      },\n      \"registries\": {\n        \"desc\": \"User registry credentials can be added to use private images for all personal pipelines.\"\n      },\n      \"cli_and_api\": {\n        \"cli_and_api\": \"CLI & API\",\n        \"desc\": \"Personal Access Token, CLI and API usage\",\n        \"token\": \"Personal Access Token\",\n        \"api_usage\": \"Example API Usage\",\n        \"cli_usage\": \"Example CLI Usage\",\n        \"download_cli\": \"Download CLI\",\n        \"reset_token\": \"Reset token\",\n        \"swagger_ui\": \"Swagger UI\"\n      },\n      \"agents\": {\n        \"desc\": \"Agents registered to your account repos.\"\n      }\n    }\n  },\n  \"secrets\": {\n    \"secrets\": \"Secrets\",\n    \"desc\": \"Secrets can be used in all pipelines of this repository.\",\n    \"none\": \"There are no secrets yet.\",\n    \"add\": \"Add secret\",\n    \"save\": \"Save secret\",\n    \"show\": \"Show secrets\",\n    \"name\": \"Name\",\n    \"value\": \"Value\",\n    \"note\": \"Note\",\n    \"delete_confirm\": \"Do you really want to delete this secret?\",\n    \"deleted\": \"Secret deleted\",\n    \"created\": \"Secret created\",\n    \"saved\": \"Secret saved\",\n    \"plugins\": {\n      \"images\": \"Available only for the following plugins\",\n      \"desc\": \"List of plugin images where this secret is available. Leave empty to allow all plugins and normal steps.\"\n    },\n    \"events\": {\n      \"events\": \"Available at the following events\",\n      \"warning\": \"Exposing secrets to pull requests could allow bad actors to steal your secrets with a malicious pull request.\"\n    },\n    \"edit\": \"Edit secret\",\n    \"delete\": \"Delete secret\"\n  },\n  \"registries\": {\n    \"registries\": \"Registries\",\n    \"credentials\": \"Registry credentials\",\n    \"desc\": \"Registry credentials can be added to use private images for pipelines.\",\n    \"none\": \"There are no registry credentials yet.\",\n    \"address\": {\n      \"address\": \"Address\",\n      \"desc\": \"Registry Address (e.g. docker.io)\"\n    },\n    \"show\": \"Show registries\",\n    \"save\": \"Save registry\",\n    \"add\": \"Add registry\",\n    \"view\": \"View registry\",\n    \"edit\": \"Edit registry\",\n    \"delete\": \"Delete registry\",\n    \"delete_confirm\": \"Do you really want to delete this registry?\",\n    \"created\": \"Registry credentials created\",\n    \"saved\": \"Registry credentials saved\",\n    \"deleted\": \"Registry credentials deleted\"\n  },\n  \"default\": \"default\",\n  \"info\": \"Info\",\n  \"running_version\": \"You are running Woodpecker {0}\",\n  \"update_woodpecker\": \"Please update your Woodpecker instance to {0}\",\n  \"global_level_secret\": \"global secret\",\n  \"org_level_secret\": \"organization secret\",\n  \"global_level_registry\": \"global registry\",\n  \"org_level_registry\": \"organization registry\",\n  \"login_to_cli\": \"Login to CLI\",\n  \"login_to_cli_description\": \"If you continue, you will be logged in to the CLI.\",\n  \"abort\": \"Abort\",\n  \"cli_login_success\": \"Login to CLI successful\",\n  \"cli_login_failed\": \"Login to CLI failed\",\n  \"cli_login_denied\": \"Login to CLI denied\",\n  \"return_to_cli\": \"You can now close this tab and return to the CLI.\",\n  \"settings\": \"Settings\",\n  \"oauth_error\": \"Error while authenticating against OAuth provider\",\n  \"internal_error\": \"Internal error occurred\",\n  \"registration_closed\": \"The registration is closed\",\n  \"access_denied\": \"You are not allowed to access this instance\",\n  \"org_access_denied\": \"You are not allowed to access this organization\",\n  \"invalid_state\": \"The OAuth state is invalid\",\n  \"extensions\": \"Extensions\",\n  \"extensions_description\": \"Extensions are HTTP services that can be called by Woodpecker instead of using the builtin ones.\",\n  \"extension_endpoint_placeholder\": \"e.g. https://example.com/api\",\n  \"config_extension_endpoint\": \"Config extension endpoint\",\n  \"registry_extension_endpoint\": \"Registry extension endpoint\",\n  \"config_extension_exclusive\": \"Exclusive\",\n  \"config_extension_exclusive_desc\": \"If enabled, will skip all other ways of configuration fetching, including the forge.\",\n  \"secret_extension_endpoint\": \"Secret extension endpoint\",\n  \"extension_netrc\": \"Include netrc credentials\",\n  \"extension_netrc_desc\": \"Send forge netrc credentials to the extension.\",\n  \"extensions_signatures_public_key\": \"Public key for signatures\",\n  \"extensions_signatures_public_key_description\": \"This public key should be used by your extensions to verify webhook calls from Woodpecker.\",\n  \"extensions_configuration_saved\": \"Extensions configuration saved\",\n  \"require_approval\": {\n    \"desc\": \"Prevent malicious pipelines from exposing secrets or running harmful tasks by approving them before execution.\",\n    \"require_approval_for\": \"Approval requirements\",\n    \"none\": \"None\",\n    \"none_desc\": \"Every event triggers pipelines, including pull requests. This setting can be dangerous and is only recommended for private instances.\",\n    \"forks\": \"Pull request from forked repository\",\n    \"pull_requests\": \"All pull requests\",\n    \"all_events\": \"All events from forge\",\n    \"allowed_users\": {\n      \"allowed_users\": \"Allowed users\",\n      \"desc\": \"Pipelines created by the listed users never require approval.\"\n    }\n  },\n  \"no_search_results\": \"No results found\",\n  \"forges\": \"Forges\",\n  \"forges_desc\": \"Configure forges hosting repositories Woodpecker should run for.\",\n  \"add_forge\": \"Add forge\",\n  \"show_forges\": \"Show forges\",\n  \"github\": \"GitHub\",\n  \"gitlab\": \"GitLab\",\n  \"bitbucket\": \"Bitbucket\",\n  \"bitbucket_dc\": \"Bitbucket Data Center\",\n  \"gitea\": \"Gitea\",\n  \"forgejo\": \"Forgejo\",\n  \"addon\": \"Addon\",\n  \"forge_type\": \"Forge type\",\n  \"oauth_client_id\": \"OAuth Client ID\",\n  \"oauth_client_secret\": \"OAuth Client Secret\",\n  \"oauth_host\": \"OAuth host\",\n  \"merge_ref\": \"Merge ref\",\n  \"merge_ref_desc\": \"Ref to use for merge base. This is used to determine the diff for pull requests.\",\n  \"public_only\": \"Public only\",\n  \"public_only_desc\": \"Only show public repositories.\",\n  \"git_username\": \"Git username\",\n  \"git_username_desc\": \"Username for the Git user.\",\n  \"git_password\": \"Git password\",\n  \"git_password_desc\": \"Password or personal access token for the Git user.\",\n  \"executable\": \"Executable\",\n  \"executable_desc\": \"Path to the addon executable.\",\n  \"save\": \"Save\",\n  \"add\": \"Add\",\n  \"skip_verify\": \"Skip SSL verification\",\n  \"skip_verify_desc\": \"Skip SSL verification for the API connection. This is not recommended for production use.\",\n  \"url\": \"URL\",\n  \"forge_managed_by_env\": \"The primary forge is managed by environment variables. Any changes to this forge will be reverted on a restart.\",\n  \"oauth_redirect_url\": \"OAuth redirect URL\",\n  \"forge_created\": \"Forge created\",\n  \"advanced_options\": \"Advanced options\",\n  \"leave_empty_to_keep_current_value\": \"Leave empty to keep current value\",\n  \"forge_deleted\": \"Forge deleted\",\n  \"forge_delete_confirm\": \"Do you really want to delete this forge? This will also delete all repositories, users and pipelines related to this forge.\",\n  \"edit_forge\": \"Edit forge\",\n  \"delete_forge\": \"Delete forge\",\n  \"no_forges\": \"There are no forges yet.\",\n  \"use_this_redirect_url_to_create\": \"Use this redirect URL to create or update the OAuth application.\",\n  \"developer_settings_to_create\": \"Go to the {0} and set up the OAuth application.\",\n  \"developer_settings\": \"developer settings\",\n  \"public_url_for_oauth_if\": \"Public URL for OAuth if different from URL ({0})\",\n  \"forge_saved\": \"Forge saved\",\n  \"fullscreen\": \"Fullscreen\",\n  \"exit_fullscreen\": \"Exit fullscreen\",\n  \"help_translating\": \"You can help translate Woodpecker into your language on {0}.\",\n  \"weblate\": \"our Weblate\",\n  \"disabled\": \"Disabled\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/eo.json",
    "content": "{\n    \"repos\": \"Repozitorioj\",\n    \"search\": \"Serĉi…\",\n    \"unknown_error\": \"Nekonata eraro okazis\",\n    \"time\": {\n        \"not_started\": \"ankoraŭ ne komencita\",\n        \"just_now\": \"ĵus nun\"\n    },\n    \"repo\": {\n        \"manual_pipeline\": {\n            \"title\": \"Ekigi permanan pipeline-rulon\",\n            \"trigger\": \"Ruli pipeline-on\",\n            \"select_branch\": \"Elekti branĉon\",\n            \"variables\": {\n                \"title\": \"Kromaj pipeline-variabloj\",\n                \"desc\": \"Specifi kromajn variablojn por uzi en via pipeline. Variabloj kun sama nomo estas anstataŭigitaj.\",\n                \"name\": \"Variabla nomo\",\n                \"value\": \"Variabla valoro\",\n                \"delete\": \"Forigi variablon\"\n            },\n            \"show_pipelines\": \"Montri pipeline-ojn\"\n        },\n        \"deploy_pipeline\": {\n            \"title\": \"Ekigi deployment-on por nuna pipeline #{pipelineId}\",\n            \"enter_target\": \"Celmedion por deployment\",\n            \"trigger\": \"Deploy\",\n            \"variables\": {\n                \"title\": \"Kromaj pipeline-variabloj\",\n                \"desc\": \"Specifi kromajn variablojn por uzi en via pipeline. Variabloj kun sama nomo estas anstataŭigitaj.\",\n                \"name\": \"Variabla nomo\",\n                \"value\": \"Variabla valoro\",\n                \"delete\": \"Forigi variablon\"\n            },\n            \"enter_task\": \"Deployment-tasko\"\n        },\n        \"activity\": \"Agado\",\n        \"branches\": \"Branĉoj\",\n        \"add\": \"Aldoni repozitorion\",\n        \"enable\": {\n            \"enable\": \"Ŝalti\",\n            \"enabled\": \"Jam ŝaltita\",\n            \"success\": \"Repozitorio ŝaltita\",\n            \"disabled\": \"Malŝaltita\"\n        },\n        \"open_in_forge\": \"Malfermi repozitorion en Forĝo\",\n        \"settings\": {\n            \"not_allowed\": \"Vi ne rajtas aliri la agordojn de ĉi tiu repozitorio\",\n            \"general\": {\n                \"general\": \"Projekto\",\n                \"project\": \"Projektaj agordoj\",\n                \"pipeline_path\": {\n                    \"path\": \"Pipeline-vojo\",\n                    \"default\": \"Defaŭlte: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Vojo al via pipeline-agordo (ekzemple {0}). Dosierujoj devas finiĝi per {1}.\",\n                    \"desc_path_example\": \"mia/vojo/\"\n                },\n                \"allow_pr\": {\n                    \"allow\": \"Permesi Pull Request-ojn\",\n                    \"desc\": \"Permesi la plenumigon de pipeline-oj por pull request-oj.\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Fidata\",\n                    \"network\": {\n                        \"network\": \"Reto\",\n                        \"desc\": \"Pipeline-ujoj ricevas aliron al retprivilegioj kiel ŝanĝi DNS.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumoj\",\n                        \"desc\": \"Pipeline-ujoj rajtas munti volumojn.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sekureco\",\n                        \"desc\": \"Pipeline-ujoj ricevas aliron al sekurecprivilegioj.\"\n                    }\n                },\n                \"timeout\": {\n                    \"timeout\": \"Tempolimo\",\n                    \"minutes\": \"minutoj\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Nuligi antaŭajn pipeline-ojn\",\n                    \"desc\": \"Elektitaj eventoj nuligas atendantajn kaj rulantajn pipeline-ojn de la sama evento antaŭ ol komenci la sekvan.\"\n                },\n                \"save\": \"Konservi agordojn\",\n                \"success\": \"Projektaj agordoj ĝisdatigitaj\",\n                \"allow_deploy\": {\n                    \"allow\": \"Permesi Deployment-ojn\",\n                    \"desc\": \"Permesi deployment-ojn por sukcesaj pipeline-oj. Ĉiuj uzantoj kun push-permesoj povas ekigi ilin, do uzu zorgeme.\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Propraj fidataj klon-plugin-oj\",\n                    \"desc\": \"Plugin-oj kiuj ricevas aliron al netrc-legitimaĵoj kiuj povas esti uzataj por kloni repozitoriojn el la Forĝo aŭ push-i ilin al la Forĝo.\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Insigno\",\n                \"type\": \"Sintakso\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"branch\": \"Branĉo\"\n            },\n            \"actions\": {\n                \"actions\": \"Agoj\",\n                \"repair\": {\n                    \"repair\": \"Ripari repozitorion\",\n                    \"success\": \"Repozitorio riparita\"\n                },\n                \"disable\": {\n                    \"disable\": \"Malŝalti repozitorion\",\n                    \"success\": \"Repozitorio malŝaltita\"\n                },\n                \"delete\": {\n                    \"delete\": \"Forigi repozitorion\",\n                    \"confirm\": \"Ĉiuj datenoj perdiĝos post ĉi tiu ago!\\n\\nĈu vi vere volas daŭrigi?\",\n                    \"success\": \"Repozitorio forigita\"\n                },\n                \"enable\": {\n                    \"enable\": \"Ŝalti repozitorion\",\n                    \"success\": \"Repozitorio ŝaltita\"\n                }\n            },\n            \"crons\": {\n                \"crons\": \"Cron-oj\",\n                \"desc\": \"Cron-taskoj povas esti uzataj por ekigi pipeline-ojn regule.\",\n                \"show\": \"Montri cron-ojn\",\n                \"add\": \"Aldoni cron-on\",\n                \"none\": \"Ankoraŭ estas neniuj cron-oj.\",\n                \"save\": \"Konservi cron-on\",\n                \"created\": \"Cron-o kreita\",\n                \"saved\": \"Cron-o konservita\",\n                \"deleted\": \"Cron-o forigita\",\n                \"next_exec\": \"Sekva plenumo\",\n                \"not_executed_yet\": \"Ankoraŭ ne plenumita\",\n                \"run\": \"Ruli nun\",\n                \"branch\": {\n                    \"title\": \"Branĉo\",\n                    \"placeholder\": \"Branĉo (uzas defaŭltan branĉon se malplena)\"\n                },\n                \"name\": {\n                    \"name\": \"Nomo\",\n                    \"placeholder\": \"Nomo de la cron-tasko\"\n                },\n                \"schedule\": {\n                    \"title\": \"Plano (bazita sur UTC)\",\n                    \"placeholder\": \"Plano\"\n                },\n                \"edit\": \"Redakti cron-on\",\n                \"delete\": \"Forigi cron-on\"\n            }\n        },\n        \"pipeline\": {\n            \"config\": \"Agordo\",\n            \"files\": \"Ŝanĝitaj dosieroj\",\n            \"no_pipelines\": \"Neniuj pipeline-oj ankoraŭ komenciĝis.\",\n            \"no_pipeline_steps\": \"Neniuj pipeline-stepoj disponeblaj!\",\n            \"step_not_started\": \"Ĉi tiu stepo ankoraŭ ne komenciĝis.\",\n            \"loading\": \"Ŝarĝante…\",\n            \"actions\": {\n                \"cancel\": \"Nuligi\",\n                \"restart\": \"Rekomenci\",\n                \"canceled\": \"Ĉi tiu stepo estis nuligita.\",\n                \"cancel_success\": \"Pipeline nuligita\",\n                \"deploy\": \"Deploy\",\n                \"restart_success\": \"Pipeline rekomencita\",\n                \"log_download\": \"Elŝuti\",\n                \"log_delete\": \"Forigi\",\n                \"log_auto_scroll\": \"Ŝalti aŭtomatan rulumigon\",\n                \"log_auto_scroll_off\": \"Malŝalti aŭtomatan rulumigon\"\n            },\n            \"protected\": {\n                \"awaits\": \"Ĉi tiu pipeline atendas aprobon de prizorganto!\",\n                \"approve\": \"Aprobi\",\n                \"decline\": \"Rifuzi\",\n                \"declined\": \"Ĉi tiu pipeline estis rifuzita!\",\n                \"approve_success\": \"Pipeline aprobita\",\n                \"decline_success\": \"Pipeline rifuzita\"\n            },\n            \"status\": {\n                \"status\": \"Stato: {status}\",\n                \"blocked\": \"blokita\",\n                \"pending\": \"atendanta\",\n                \"running\": \"rulanta\",\n                \"started\": \"komencita\",\n                \"skipped\": \"saltita\",\n                \"success\": \"sukceso\",\n                \"declined\": \"rifuzita\",\n                \"error\": \"eraro\",\n                \"failure\": \"malsukceso\",\n                \"killed\": \"mortigita\"\n            },\n            \"we_got_some_errors\": \"Ho ve, eraro okazis!\",\n            \"created\": \"Kreita: {created}\",\n            \"tasks\": \"Taskoj\",\n            \"pipelines_for\": \"Pipeline-oj por branĉo \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Pipeline-oj por Pull Request #{index}\",\n            \"exit_code\": \"Elirkodo {exitCode}\",\n            \"no_logs\": \"Neniuj protokoloj\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"log_title\": \"Stepaj protokoloj\",\n            \"log_download_error\": \"Eraro okazis dum elŝuti la protokoldosieron\",\n            \"log_delete_confirm\": \"Ĉu vi vere volas forigi la stepajn protokolojn?\",\n            \"log_delete_error\": \"Eraro okazis dum forigi la stepajn protokolojn\",\n            \"event\": {\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"pr\": \"Pull Request\",\n                \"pr_closed\": \"Pull Request kunfandita/fermita\",\n                \"pr_metadata\": \"Pull Request-metadatenoj ŝanĝitaj\",\n                \"deploy\": \"Deploy\",\n                \"cron\": \"Cron\",\n                \"manual\": \"Permana\",\n                \"release\": \"Release\"\n            },\n            \"errors\": \"Eraroj\",\n            \"warnings\": \"Avertoj\",\n            \"show_errors\": \"Montri erarojn\",\n            \"duration\": \"Pipeline-daŭro: {duration}\",\n            \"debug\": {\n                \"title\": \"Senerarigi\",\n                \"download_metadata\": \"Elŝuti metadatenojn\",\n                \"metadata_download_error\": \"Eraro dum elŝuti metadatenojn\",\n                \"metadata_download_successful\": \"Metadatenoj sukcese elŝutitaj\",\n                \"no_permission\": \"Vi ne rajtas aliri la senerarigajn informojn\",\n                \"metadata_exec_title\": \"Reruli pipeline-on loke\",\n                \"metadata_exec_desc\": \"Elŝuti la metadatenojn de ĉi tiu pipeline por ruli ĝin loke. Ĉi tio permesas fiksi problemojn kaj testi ŝanĝojn antaŭ ol commit-i ilin. La Woodpecker CLI devas esti instalita loke en la sama versio kiel la servilo.\"\n            },\n            \"view\": \"Vidi pipeline-on\"\n        },\n        \"pull_requests\": \"Pull Request-oj\",\n        \"user_none\": \"Ĉi tiu organizo/uzanto ankoraŭ havas neniujn projektojn\",\n        \"not_allowed\": \"Vi ne rajtas aliri ĉi tiun repozitorion\",\n        \"visibility\": {\n            \"visibility\": \"Projekta videbleco\",\n            \"public\": {\n                \"public\": \"Publika\",\n                \"desc\": \"Iu ajn povas vidi vian projekton sen ensaluti.\"\n            },\n            \"private\": {\n                \"private\": \"Privata\",\n                \"desc\": \"Nur vi kaj aliaj posedantoj de la repozitorio povas vidi ĉi tiun projekton.\"\n            },\n            \"internal\": {\n                \"internal\": \"Interna\",\n                \"desc\": \"Nur aŭtentikigitaj uzantoj de la Woodpecker-instanco povas vidi ĉi tiun projekton.\"\n            }\n        }\n    },\n    \"org\": {\n        \"settings\": {\n            \"secrets\": {\n                \"desc\": \"Organizaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj poseditaj de la organizo.\"\n            },\n            \"not_allowed\": \"Vi ne rajtas aliri la agordojn de ĉi tiu organizo\",\n            \"registries\": {\n                \"desc\": \"Organizaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj pipeline-oj de organizo.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agentoj registritaj por ĉi tiu organizo.\"\n            }\n        }\n    },\n    \"admin\": {\n        \"settings\": {\n            \"settings\": \"Administraj agordoj\",\n            \"secrets\": {\n                \"desc\": \"Tutmondaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj.\",\n                \"warning\": \"Ĉi tiuj sekretoj estas disponeblaj por ĉiuj uzantoj.\"\n            },\n            \"agents\": {\n                \"agents\": \"Agentoj\",\n                \"desc\": \"Agentoj registritaj sur ĉi tiu servilo.\",\n                \"none\": \"Ankoraŭ estas neniuj agentoj.\",\n                \"id\": \"ID\",\n                \"created\": \"Agento kreita\",\n                \"version\": \"Versio\",\n                \"never\": \"Neniam\",\n                \"delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun agenton? Ĝi ne plu povos konektiĝi al la servilo.\",\n                \"delete_agent\": \"Forigi agenton\",\n                \"add\": \"Aldoni agenton\",\n                \"save\": \"Konservi agenton\",\n                \"show\": \"Montri agentojn\",\n                \"saved\": \"Agento konservita\",\n                \"deleted\": \"Agento forigita\",\n                \"name\": {\n                    \"name\": \"Nomo\",\n                    \"placeholder\": \"Nomo de la agento\"\n                },\n                \"no_schedule\": {\n                    \"name\": \"Malŝalti agenton\",\n                    \"placeholder\": \"Haltigi agenton de preni novajn taskojn\"\n                },\n                \"token\": \"Ĵetono\",\n                \"platform\": {\n                    \"platform\": \"Platformo\",\n                    \"badge\": \"platformo\"\n                },\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"Kapacito\",\n                    \"desc\": \"La maksimuma kvanto de paralelaj pipeline-oj plenumitaj de ĉi tiu agento.\",\n                    \"badge\": \"kapacito\"\n                },\n                \"custom_labels\": {\n                    \"custom_labels\": \"Propraj etikedoj\",\n                    \"desc\": \"La propraj etikedoj agordita de la agenta administranto ĉe agenta starto.\"\n                },\n                \"org\": {\n                    \"badge\": \"organizo\"\n                },\n                \"last_contact\": {\n                    \"last_contact\": \"Lasta kontakto\",\n                    \"badge\": \"lasta kontakto\"\n                },\n                \"edit_agent\": \"Redakti agenton\"\n            },\n            \"queue\": {\n                \"queue\": \"Vicumo\",\n                \"desc\": \"Taskoj atendantaj plenumigon de agentoj.\",\n                \"agent\": \"agento\",\n                \"waiting_for\": \"atendante\",\n                \"pause\": \"Paŭzigi\",\n                \"resume\": \"Daŭrigi\",\n                \"paused\": \"Vicumo estas paŭzigita\",\n                \"resumed\": \"Vicumo estas daŭrigita\",\n                \"tasks\": \"Taskoj\",\n                \"task_running\": \"Tasko rulas\",\n                \"task_pending\": \"Tasko atendas\",\n                \"task_waiting_on_deps\": \"Tasko atendas dependecojn\",\n                \"stats\": {\n                    \"completed_count\": \"Finitaj taskoj\",\n                    \"worker_count\": \"Libera\",\n                    \"running_count\": \"Rulanta\",\n                    \"pending_count\": \"Atendanta\",\n                    \"waiting_on_deps_count\": \"Atendante dependecojn\"\n                }\n            },\n            \"users\": {\n                \"users\": \"Uzantoj\",\n                \"desc\": \"Uzantoj registritaj por ĉi tiu servilo.\",\n                \"login\": \"Salutnomo\",\n                \"email\": \"Retpoŝto\",\n                \"avatar_url\": \"Avatar-URL\",\n                \"save\": \"Konservi uzanton\",\n                \"cancel\": \"Nuligi\",\n                \"add\": \"Aldoni uzanton\",\n                \"none\": \"Ankoraŭ estas neniuj uzantoj.\",\n                \"delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun uzanton? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn poseditajn de ĉi tiu uzanto.\",\n                \"deleted\": \"Uzanto forigita\",\n                \"created\": \"Uzanto kreita\",\n                \"saved\": \"Uzanto konservita\",\n                \"delete_user\": \"Forigi uzanton\",\n                \"show\": \"Montri uzantojn\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Uzanto estas administranto\"\n                },\n                \"edit_user\": \"Redakti uzanton\"\n            },\n            \"orgs\": {\n                \"orgs\": \"Organizoj\",\n                \"view\": \"Vidi organizon\",\n                \"desc\": \"Organizoj posedantaj repozitoriojn sur ĉi tiu servilo.\",\n                \"none\": \"Ankoraŭ estas neniuj organizoj.\",\n                \"org_settings\": \"Organizaj agordoj\",\n                \"delete_org\": \"Forigi organizon\",\n                \"deleted\": \"Organizo forigita\",\n                \"delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun organizon? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn poseditajn de ĉi tiu organizo.\"\n            },\n            \"repos\": {\n                \"repos\": \"Repozitorioj\",\n                \"desc\": \"Repozitorioj kiuj estas aŭ estis aktivigitaj sur ĉi tiu servilo.\",\n                \"none\": \"Ankoraŭ estas neniuj repozitorioj.\",\n                \"disabled\": \"Malŝaltita\",\n                \"view\": \"Vidi repozitorion\",\n                \"settings\": \"Repozitoriaj agordoj\",\n                \"repair\": {\n                    \"repair\": \"Ripari ĉiujn\",\n                    \"success\": \"Repozitorioj riparitaj\"\n                }\n            },\n            \"not_allowed\": \"Vi ne rajtas aliri la servilajn agordojn\",\n            \"registries\": {\n                \"desc\": \"Tutmondaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj pipeline-oj.\",\n                \"warning\": \"Ĉi tiuj registrejaj legitimaĵoj estas disponeblaj por ĉiuj uzantoj.\"\n            }\n        }\n    },\n    \"user\": {\n        \"settings\": {\n            \"settings\": \"Uzantaj agordoj\",\n            \"general\": {\n                \"general\": \"Konto\",\n                \"language\": \"Lingvo\",\n                \"theme\": {\n                    \"theme\": \"Etoso\",\n                    \"light\": \"Hela\",\n                    \"dark\": \"Malhela\",\n                    \"auto\": \"Aŭtomata\"\n                }\n            },\n            \"secrets\": {\n                \"desc\": \"Uzantaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj poseditaj de la uzanto.\"\n            },\n            \"registries\": {\n                \"desc\": \"Uzantaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj personaj pipeline-oj.\"\n            },\n            \"cli_and_api\": {\n                \"cli_and_api\": \"CLI & API\",\n                \"desc\": \"Persona Alir-Ĵetono, CLI kaj API-uzo\",\n                \"token\": \"Persona Alir-Ĵetono\",\n                \"api_usage\": \"Ekzempla API-uzo\",\n                \"cli_usage\": \"Ekzempla CLI-uzo\",\n                \"download_cli\": \"Elŝuti CLI\",\n                \"reset_token\": \"Reagordi ĵetonon\",\n                \"swagger_ui\": \"Swagger UI\"\n            },\n            \"agents\": {\n                \"desc\": \"Agentoj registritaj al viaj kontaj repozitorioj.\"\n            }\n        }\n    },\n    \"url\": \"URL\",\n    \"cancel\": \"Nuligi\",\n    \"login_to_woodpecker_with\": \"Ensaluti al Woodpecker per\",\n    \"login\": \"Ensaluti\",\n    \"repositories\": {\n        \"title\": \"Repozitorioj\",\n        \"all\": {\n            \"title\": \"Ĉiuj repozitorioj\",\n            \"desc\": \"Repozitorioj ordigitaj laŭ lasta pipeline-kreo\"\n        },\n        \"last\": {\n            \"title\": \"Laste vizititaj\",\n            \"desc\": \"Plej freŝdate vizititaj repozitorioj ordigitaj laŭ alirtempo\"\n        }\n    },\n    \"docs\": \"Dokumentaro\",\n    \"api\": \"API\",\n    \"logout\": \"Elsaluti\",\n    \"username\": \"Uzantonomo\",\n    \"password\": \"Pasvorto\",\n    \"back\": \"Reen\",\n    \"documentation_for\": \"Dokumentaro por \\\"{topic}\\\"\",\n    \"pipeline_feed\": \"Pipeline-fluo\",\n    \"empty_list\": \"Neniuj {entity} troviĝis!\",\n    \"not_found\": {\n        \"not_found\": \"Hu, 404, aŭ ni ion rompis aŭ vi mistajpis :-/\",\n        \"back_home\": \"Reen al hejmo\"\n    },\n    \"errors\": {\n        \"not_found\": \"Servilo ne povis trovi petitan objekton\"\n    },\n    \"secrets\": {\n        \"secrets\": \"Sekretoj\",\n        \"desc\": \"Sekretoj povas esti uzataj en ĉiuj pipeline-oj de ĉi tiu repozitorio.\",\n        \"none\": \"Ankoraŭ estas neniuj sekretoj.\",\n        \"add\": \"Aldoni sekreton\",\n        \"save\": \"Konservi sekreton\",\n        \"show\": \"Montri sekretojn\",\n        \"name\": \"Nomo\",\n        \"value\": \"Valoro\",\n        \"delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun sekreton?\",\n        \"deleted\": \"Sekreto forigita\",\n        \"created\": \"Sekreto kreita\",\n        \"saved\": \"Sekreto konservita\",\n        \"plugins\": {\n            \"images\": \"Disponebla nur por la sekvaj plugin-oj\",\n            \"desc\": \"Listo de plugin-bildoj kie ĉi tiu sekreto estas disponebla. Lasu malplena por permesi ĉiujn plugin-ojn kaj normalajn stepojn.\"\n        },\n        \"events\": {\n            \"events\": \"Disponeblaj ĉe la sekvaj eventoj\",\n            \"warning\": \"Malkaŝi sekretojn al pull request-oj povus permesi malicajn agantojn ŝteli viajn sekretojn per malica pull request.\"\n        },\n        \"edit\": \"Redakti sekreton\",\n        \"delete\": \"Forigi sekreton\"\n    },\n    \"registries\": {\n        \"registries\": \"Registrejoj\",\n        \"credentials\": \"Registrejaj legitimaĵoj\",\n        \"desc\": \"Registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por pipeline-oj.\",\n        \"none\": \"Ankoraŭ estas neniuj registrejaj legitimaĵoj.\",\n        \"address\": {\n            \"address\": \"Adreso\",\n            \"desc\": \"Registreja adreso (ekz. docker.io)\"\n        },\n        \"show\": \"Montri registrejojn\",\n        \"save\": \"Konservi registrejon\",\n        \"add\": \"Aldoni registrejon\",\n        \"view\": \"Vidi registrejon\",\n        \"edit\": \"Redakti registrejon\",\n        \"delete\": \"Forigi registrejon\",\n        \"delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun registrejon?\",\n        \"created\": \"Registrejaj legitimaĵoj kreitaj\",\n        \"saved\": \"Registrejaj legitimaĵoj konservitaj\",\n        \"deleted\": \"Registrejaj legitimaĵoj forigitaj\"\n    },\n    \"default\": \"defaŭlta\",\n    \"info\": \"Info\",\n    \"running_version\": \"Vi uzas Woodpecker {0}\",\n    \"update_woodpecker\": \"Bonvolu ĝisdatigi vian Woodpecker-instancon al {0}\",\n    \"global_level_secret\": \"tutmonda sekreto\",\n    \"org_level_secret\": \"organiza sekreto\",\n    \"login_to_cli\": \"Ensaluti al CLI\",\n    \"login_to_cli_description\": \"Se vi daŭrigas, vi ensalutos al la CLI.\",\n    \"abort\": \"Ĉesigi\",\n    \"cli_login_success\": \"Ensaluto al CLI sukcesis\",\n    \"cli_login_failed\": \"Ensaluto al CLI malsukcesis\",\n    \"cli_login_denied\": \"Ensaluto al CLI rifuzita\",\n    \"return_to_cli\": \"Vi nun povas fermi ĉi tiun langeton kaj reveni al la CLI.\",\n    \"settings\": \"Agordoj\",\n    \"oauth_error\": \"Eraro dum aŭtentikigi kontraŭ OAuth-provizanto\",\n    \"internal_error\": \"Interna eraro okazis\",\n    \"registration_closed\": \"La registriĝo estas fermita\",\n    \"access_denied\": \"Vi ne rajtas aliri ĉi tiun instancon\",\n    \"org_access_denied\": \"Vi ne rajtas aliri ĉi tiun organizon\",\n    \"invalid_state\": \"La OAuth-stato estas nevalida\",\n    \"extensions\": \"Etendaĵoj\",\n    \"extensions_description\": \"Etendaĵoj estas HTTP-servoj kiuj povas esti vokitaj de Woodpecker anstataŭ uzi la integritajn.\",\n    \"extension_endpoint_placeholder\": \"ekz. https://example.com/api\",\n    \"config_extension_endpoint\": \"Agordo-etendaĵa finpunkto\",\n    \"extensions_signatures_public_key\": \"Publika ŝlosilo por subskriboj\",\n    \"extensions_signatures_public_key_description\": \"Ĉi tiu publika ŝlosilo devus esti uzata de viaj etendaĵoj por verifi webhook-vokojn el Woodpecker.\",\n    \"extensions_configuration_saved\": \"Etendaĵa agordo konservita\",\n    \"require_approval\": {\n        \"desc\": \"Malhelpi malicajn pipeline-ojn malkaŝi sekretojn aŭ ruli malutilajn taskojn per aprobi ilin antaŭ plenumo.\",\n        \"require_approval_for\": \"Aprob-postuloj\",\n        \"none\": \"Neniu\",\n        \"none_desc\": \"Ĉiu evento ekigas pipeline-ojn, inkluzive de pull request-oj. Ĉi tiu agordo povas esti danĝera kaj estas nur rekomendita por privataj instancoj.\",\n        \"forks\": \"Pull Request el forkita repozitorio\",\n        \"pull_requests\": \"Ĉiuj Pull Request-oj\",\n        \"all_events\": \"Ĉiuj eventoj el Forĝo\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Permesitaj uzantoj\",\n            \"desc\": \"Pipeline-oj kreitaj de la listigitaj uzantoj neniam bezonas aprobon.\"\n        }\n    },\n    \"no_search_results\": \"Neniuj rezultoj troviĝis\",\n    \"forges\": \"Forĝoj\",\n    \"forges_desc\": \"Agordi Forĝojn gastigantajn repozitoriojn por kiuj Woodpecker devus ruli.\",\n    \"add_forge\": \"Aldoni Forĝon\",\n    \"show_forges\": \"Montri Forĝojn\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Kromprogramo\",\n    \"forge_type\": \"Forĝa tipo\",\n    \"oauth_client_id\": \"OAuth-Klienta ID\",\n    \"oauth_client_secret\": \"OAuth-Klienta sekreto\",\n    \"oauth_host\": \"OAuth-gastiganto\",\n    \"merge_ref\": \"Kunfanda ref\",\n    \"merge_ref_desc\": \"Ref uzota por kunfanda bazo. Ĉi tio estas uzata por determini la diferon por pull request-oj.\",\n    \"public_only\": \"Nur publikaj\",\n    \"public_only_desc\": \"Nur montri publikajn repozitoriojn.\",\n    \"git_username\": \"Git-uzantonomo\",\n    \"git_username_desc\": \"Uzantonomo por la Git-uzanto.\",\n    \"git_password\": \"Git-pasvorto\",\n    \"git_password_desc\": \"Pasvorto aŭ persona alir-ĵetono por la Git-uzanto.\",\n    \"executable\": \"Plenumebla\",\n    \"executable_desc\": \"Vojo al la kromprograma plenumebla dosiero.\",\n    \"save\": \"Konservi\",\n    \"add\": \"Aldoni\",\n    \"skip_verify\": \"Saltigi SSL-kontrolon\",\n    \"skip_verify_desc\": \"Saltigi SSL-kontrolon por la API-konekto. Ĉi tio ne estas rekomendita por produkta uzo.\",\n    \"forge_managed_by_env\": \"La ĉefa Forĝo estas administrata per mediaj variabloj. Ĉiuj ŝanĝoj al ĉi tiu Forĝo estos malfaritaj ĉe rekomencigo.\",\n    \"oauth_redirect_url\": \"OAuth-alidirekta URL\",\n    \"forge_created\": \"Forĝo kreita\",\n    \"advanced_options\": \"Altnivelajn opciojn\",\n    \"leave_empty_to_keep_current_value\": \"Lasu malplena por konservi nunan valoron\",\n    \"forge_deleted\": \"Forĝo forigita\",\n    \"forge_delete_confirm\": \"Ĉu vi vere volas forigi ĉi tiun Forĝon? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn, uzantojn kaj pipeline-ojn rilatajn al ĉi tiu Forĝo.\",\n    \"edit_forge\": \"Redakti Forĝon\",\n    \"delete_forge\": \"Forigi Forĝon\",\n    \"no_forges\": \"Ankoraŭ estas neniuj Forĝoj.\",\n    \"use_this_redirect_url_to_create\": \"Uzu ĉi tiun alidirekt-URL por krei aŭ ĝisdatigi la OAuth-aplikaĵon.\",\n    \"developer_settings_to_create\": \"Iru al {0} kaj agordu la OAuth-aplikaĵon.\",\n    \"developer_settings\": \"evoluigistaj agordoj\",\n    \"public_url_for_oauth_if\": \"Publika URL por OAuth se malsama ol URL ({0})\",\n    \"forge_saved\": \"Forĝo konservita\",\n    \"fullscreen\": \"Plenekrano\",\n    \"exit_fullscreen\": \"Eliri plenekranon\",\n    \"help_translating\": \"Vi povas helpi traduki Woodpecker en vian lingvon ĉe {0}.\",\n    \"weblate\": \"nia Weblate\",\n    \"disabled\": \"Malŝaltita\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/es.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Añadir agente\",\n                \"agents\": \"Agentes\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"capacidad\",\n                    \"capacity\": \"Capacidad\",\n                    \"desc\": \"La cantidad máxima de pipelines paralelos ejecutados por este agente.\"\n                },\n                \"created\": \"Agente creado\",\n                \"delete_agent\": \"Eliminar agente\",\n                \"delete_confirm\": \"¿Realmente quieres borrar este agente? Ya no podrá conectarse al servidor.\",\n                \"deleted\": \"Agente eliminado\",\n                \"desc\": \"Agentes registrados en este servidor\",\n                \"edit_agent\": \"Editar agente\",\n                \"id\": \"ID\",\n                \"last_contact\": \"Último contacto\",\n                \"name\": {\n                    \"name\": \"Nombre\",\n                    \"placeholder\": \"Nombre del agente\"\n                },\n                \"never\": \"Nunca\",\n                \"no_schedule\": {\n                    \"name\": \"Desactivar agente\",\n                    \"placeholder\": \"Impedir que el agente acepte nuevas tareas\"\n                },\n                \"none\": \"Aún no hay agentes.\",\n                \"platform\": {\n                    \"badge\": \"plataforma\",\n                    \"platform\": \"Plataforma\"\n                },\n                \"save\": \"Guardar agente\",\n                \"saved\": \"Agente guardado\",\n                \"show\": \"Mostrar agentes\",\n                \"token\": \"Token\",\n                \"version\": \"Versión\"\n            },\n            \"not_allowed\": \"No puede acceder a la configuración del servidor\",\n            \"orgs\": {\n                \"delete_confirm\": \"¿Realmente desea eliminar esta organización? Esto también eliminará todos los repositorios de esta organización.\",\n                \"delete_org\": \"Eliminar organización\",\n                \"deleted\": \"Organización eliminada\",\n                \"desc\": \"Organizaciones propietarias de repositorios en este servidor\",\n                \"none\": \"Aún no hay organizaciones.\",\n                \"org_settings\": \"Configuración de la organización\",\n                \"orgs\": \"Organizaciones\",\n                \"view\": \"Ver organización\"\n            },\n            \"queue\": {\n                \"agent\": \"agente\",\n                \"desc\": \"Tareas en espera de ejecución por los agentes\",\n                \"pause\": \"Pausa\",\n                \"paused\": \"La cola está en pausa\",\n                \"queue\": \"Cola\",\n                \"resume\": \"Continuar\",\n                \"resumed\": \"Se reanuda la cola\",\n                \"stats\": {\n                    \"completed_count\": \"Tareas completadas\",\n                    \"pending_count\": \"Pendiente\",\n                    \"running_count\": \"Ejecutando\",\n                    \"waiting_on_deps_count\": \"A la espera de dependencias\",\n                    \"worker_count\": \"Libre\"\n                },\n                \"task_pending\": \"Tarea pendiente\",\n                \"task_running\": \"Tarea en ejecución\",\n                \"task_waiting_on_deps\": \"Tarea en espera de dependencias\",\n                \"tasks\": \"Tareas\",\n                \"waiting_for\": \"a la espera de\"\n            },\n            \"repos\": {\n                \"desc\": \"Repositorios que están o estaban activados en este servidor\",\n                \"disabled\": \"Desactivado\",\n                \"none\": \"Aún no hay repositorios.\",\n                \"repair\": {\n                    \"repair\": \"Reparar todos\",\n                    \"success\": \"Repositorios reparados\"\n                },\n                \"repos\": \"Repositorios\",\n                \"settings\": \"Configuración del repositorio\",\n                \"view\": \"Ver repositorio\"\n            },\n            \"secrets\": {\n                \"add\": \"Añadir secreto\",\n                \"created\": \"Secreto global creado\",\n                \"deleted\": \"Secreto global eliminado\",\n                \"desc\": \"Los secretos globales pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales del pipeline de cualquier repositorio.\",\n                \"events\": {\n                    \"events\": \"Disponible en los siguientes eventos\",\n                    \"pr_warning\": \"Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes\",\n                    \"images\": \"Disponible para las siguientes imágenes\"\n                },\n                \"name\": \"Nombre\",\n                \"none\": \"Aún no hay secretos globales.\",\n                \"plugins_only\": \"Sólo disponible para plugins\",\n                \"save\": \"Guardar secreto\",\n                \"saved\": \"Secreto global guardado\",\n                \"secrets\": \"Secretos\",\n                \"show\": \"Mostrar secretos\",\n                \"value\": \"Valor\",\n                \"warning\": \"Estos secretos estarán disponibles para todos los usuarios del servidor.\"\n            },\n            \"settings\": \"Configuración\",\n            \"users\": {\n                \"add\": \"Añadir usuario\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"El usuario es un administrador\"\n                },\n                \"avatar_url\": \"URL avatar\",\n                \"cancel\": \"Cancelar\",\n                \"created\": \"Usuario creado\",\n                \"delete_confirm\": \"¿Realmente desea eliminar este usuario? Esto también eliminará todos los repositorios de este usuario.\",\n                \"delete_user\": \"Eliminar usuario\",\n                \"deleted\": \"Usuario eliminado\",\n                \"desc\": \"Usuarios registrados en este servidor\",\n                \"edit_user\": \"Editar usuario\",\n                \"email\": \"Email\",\n                \"login\": \"Iniciar sesión\",\n                \"none\": \"Aún no hay usuarios.\",\n                \"save\": \"Guardar usuario\",\n                \"saved\": \"Usuario guardado\",\n                \"show\": \"Mostrar usuarios\",\n                \"users\": \"Usuarios\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Atrás\",\n    \"cancel\": \"Cancelar\",\n    \"default\": \"por defecto\",\n    \"docs\": \"Documentación\",\n    \"documentation_for\": \"Documentación de \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"El servidor no ha podido encontrar el objeto solicitado\"\n    },\n    \"info\": \"Info\",\n    \"login\": \"Iniciar sesión\",\n    \"logout\": \"Cerrar sesión\",\n    \"not_found\": {\n        \"back_home\": \"Volver a la página principal\",\n        \"not_found\": \"404, ya sea rompimos algo o la dirección es incorrecta :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"No tiene permiso para acceder a los ajustes de esta organización\",\n            \"secrets\": {\n                \"add\": \"Añadir secreto\",\n                \"created\": \"Secreto de organización creado\",\n                \"deleted\": \"Secreto de organización eliminado\",\n                \"desc\": \"Los secretos de la organización pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales de cualquier pipeline de la organización.\",\n                \"events\": {\n                    \"events\": \"Disponible en los siguientes eventos\",\n                    \"pr_warning\": \"Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes\",\n                    \"images\": \"Disponible para las siguientes imágenes\"\n                },\n                \"name\": \"Nombre\",\n                \"none\": \"Aún no hay secretos de organización.\",\n                \"plugins_only\": \"Sólo disponible para plugins\",\n                \"save\": \"Guardar secreto\",\n                \"saved\": \"Secreto de organización guardado\",\n                \"secrets\": \"Secretos\",\n                \"show\": \"Mostrar secretos\",\n                \"value\": \"Valor\"\n            },\n            \"settings\": \"Configuración\"\n        }\n    },\n    \"password\": \"Contraseña\",\n    \"pipeline_feed\": \"Reporte de actividad de Pipeline\",\n    \"repo\": {\n        \"activity\": \"Actividad\",\n        \"add\": \"Añadir repositorio\",\n        \"branches\": \"Ramas\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Entorno de despliegue de destino\",\n            \"title\": \"Iniciar despliegue para el pipeline actual #{pipelineId}\",\n            \"trigger\": \"Despliegue\",\n            \"variables\": {\n                \"add\": \"Añadir variable\",\n                \"desc\": \"Especifique variables adicionales para utilizar en su pipeline. Las variables con el mismo nombre se sobrescribirán.\",\n                \"name\": \"Nombre de la variable\",\n                \"title\": \"Variables adicionales del pipeline\",\n                \"value\": \"Valor de la variable\",\n                \"delete\": \"Eliminar variable\"\n            },\n            \"enter_task\": \"Tarea de despliegue\"\n        },\n        \"enable\": {\n            \"disabled\": \"Desactivado\",\n            \"enable\": \"Activar\",\n            \"enabled\": \"Ya está activado\",\n            \"list_reloaded\": \"Lista de repositorios actualizada\",\n            \"reload\": \"Actualizar repositorios\",\n            \"success\": \"Repositorio activado\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Escoger rama\",\n            \"title\": \"Iniciar un pipeline manual\",\n            \"trigger\": \"Corre el pipeline\",\n            \"variables\": {\n                \"add\": \"Añadir variable\",\n                \"desc\": \"Especifique variables adiciónales para usar en su pipeline. Las variables con el mismo nombre se sobrescribirán.\",\n                \"name\": \"Nombre de la variable\",\n                \"title\": \"Variables adicionales del pipeline\",\n                \"value\": \"Valor de la variable\",\n                \"delete\": \"Eliminar variable\"\n            },\n            \"show_pipelines\": \"Mostrar las pipelines\"\n        },\n        \"not_allowed\": \"No tienes acceso a este repositorio\",\n        \"open_in_forge\": \"Abrir repositorio en la forja\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Cancelar\",\n                \"cancel_success\": \"Pipeline cancelado\",\n                \"canceled\": \"Este paso ha sido cancelado.\",\n                \"deploy\": \"Despliegue\",\n                \"log_auto_scroll\": \"Desplazarse automáticamente hacia abajo\",\n                \"log_auto_scroll_off\": \"Desactivar el desplazamiento automático\",\n                \"log_download\": \"Descargar\",\n                \"restart\": \"Reiniciar\",\n                \"restart_success\": \"Pipeline reiniciado\"\n            },\n            \"config\": \"Config\",\n            \"errors\": \"Errores ({count})\",\n            \"event\": {\n                \"cron\": \"Cron\",\n                \"deploy\": \"Despliegue\",\n                \"manual\": \"Manual\",\n                \"pr\": \"Pull Request\",\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"release\": \"Release\"\n            },\n            \"exit_code\": \"Código de salida {exitCode}\",\n            \"files\": \"Archivos modificados ({files})\",\n            \"loading\": \"Cargando…\",\n            \"log_download_error\": \"Se ha producido un error al descargar el archivo de registro\",\n            \"log_title\": \"Registros de pasos\",\n            \"no_files\": \"No se ha modificado ningún archivo.\",\n            \"no_pipeline_steps\": \"¡No hay pasos de pipeline disponibles!\",\n            \"no_pipelines\": \"Aún no se ha lanzado ningún pipeline.\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"pipelines_for\": \"Pipelines para la rama \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Pipelines para pull request #{index}\",\n            \"protected\": {\n                \"approve\": \"Aprobar\",\n                \"approve_success\": \"Pipeline aprobado\",\n                \"awaits\": \"¡Este pipeline está a la espera de la aprobación de un mantenedor!\",\n                \"decline\": \"Rechazar\",\n                \"decline_success\": \"Pipeline rechazado\",\n                \"declined\": \"¡Este pipeline ha sido rechazado!\",\n                \"review\": \"Revisar cambios\"\n            },\n            \"show_errors\": \"Mostrar errores\",\n            \"status\": {\n                \"blocked\": \"bloqueado\",\n                \"declined\": \"rechazado\",\n                \"error\": \"error\",\n                \"failure\": \"fallo\",\n                \"killed\": \"terminado\",\n                \"pending\": \"pendiente\",\n                \"running\": \"ejecutando\",\n                \"skipped\": \"omitido\",\n                \"started\": \"iniciado\",\n                \"status\": \"Estado: {status}\",\n                \"success\": \"éxito\"\n            },\n            \"step_not_started\": \"Este paso aún no se ha iniciado.\",\n            \"tasks\": \"Tareas\",\n            \"warnings\": \"Avisos ({count})\",\n            \"we_got_some_errors\": \"¡Oh no, tenemos algunos errores!\"\n        },\n        \"pull_requests\": \"Pull Request\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Acciones\",\n                \"delete\": {\n                    \"confirm\": \"¡¡¡Todos los datos se perderán después de esta acción!!!\\n\\n¿Realmente quieres proceder?\",\n                    \"delete\": \"Eliminar repositorio\",\n                    \"success\": \"Repositorio eliminado\"\n                },\n                \"disable\": {\n                    \"disable\": \"Desactivar repositorio\",\n                    \"success\": \"Repositorio desactivado\"\n                },\n                \"enable\": {\n                    \"enable\": \"Activar repositorio\",\n                    \"success\": \"Repositorio activado\"\n                },\n                \"repair\": {\n                    \"repair\": \"Reparar repositorio\",\n                    \"success\": \"Repositorio reparado\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Placa\",\n                \"branch\": \"Rama\",\n                \"type\": \"Sintaxis\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Añadir cron\",\n                \"branch\": {\n                    \"placeholder\": \"Rama (utiliza la rama por defecto si está vacía)\",\n                    \"title\": \"Rama\"\n                },\n                \"created\": \"Cron creado\",\n                \"crons\": \"Crons\",\n                \"delete\": \"Borrar cron\",\n                \"deleted\": \"Cron borrado\",\n                \"desc\": \"Las tareas Cron pueden utilizarse para activar pipelines de forma regular.\",\n                \"edit\": \"Editar cron\",\n                \"name\": {\n                    \"name\": \"Nombre\",\n                    \"placeholder\": \"Nombre de la tarea cron\"\n                },\n                \"next_exec\": \"Siguiente ejecución\",\n                \"none\": \"Aún no hay crons.\",\n                \"not_executed_yet\": \"No se ha ejecutado todavía\",\n                \"run\": \"Ejecutar ahora\",\n                \"save\": \"Guardar cron\",\n                \"saved\": \"Cron guardado\",\n                \"schedule\": {\n                    \"placeholder\": \"Programación\",\n                    \"title\": \"Programación (basado en UTC)\"\n                },\n                \"show\": \"Mostrar crons\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Permitir solicitudes de cambios\",\n                    \"desc\": \"Pipelines pueden ejecutar en las solicitudes de cambios.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Anular pipelines anteriores\",\n                    \"desc\": \"Permite cancelar los pipelines pendientes y en ejecución del mismo evento y contexto antes de iniciar el recién lanzado.\"\n                },\n                \"general\": \"General\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Sólo inyectar credenciales netrc en contenedores de confianza (recomendado).\",\n                    \"netrc_only_trusted\": \"Sólo inyectar credenciales netrc en contenedores de confianza\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Por defecto: .woodpecker/*.yml -> .woodpecker.yml\",\n                    \"desc\": \"Ruta a la configuración de su pipeline (por ejemplo {0}). Las carpetas deben terminar en {1}.\",\n                    \"desc_path_example\": \"mi/directorie/\",\n                    \"path\": \"Pasos del pipeline\"\n                },\n                \"project\": \"Configuración del proyecto\",\n                \"protected\": {\n                    \"desc\": \"Todas las pipelines se tienen que estar aprobados antes de que se ejecuten.\",\n                    \"protected\": \"Protegido\"\n                },\n                \"save\": \"Guardar configuración\",\n                \"success\": \"Configuraciónes del proyecto actualizadas\",\n                \"timeout\": {\n                    \"minutes\": \"minutos\",\n                    \"timeout\": \"Tiempo de espera\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Los contenedores de pipeline subyacentes obtienen acceso a capacidades escaladas como el montaje de volúmenes.\",\n                    \"trusted\": \"Confiado\",\n                    \"volumes\": {\n                        \"volumes\": \"Volúmenes\"\n                    },\n                    \"security\": {\n                        \"security\": \"Seguridad\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Sólo los usuarios autentificados de la instancia Woodpecker pueden ver este proyecto.\",\n                        \"internal\": \"Interno\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Sólo usted y otros propietarios del repositorio pueden ver este proyecto.\",\n                        \"private\": \"Privado\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Todos los usuarios pueden ver tu proyecto sin necesidad de iniciar sesión.\",\n                        \"public\": \"Público\"\n                    },\n                    \"visibility\": \"Visibilidad del proyecto\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Permitir despliegues\"\n                }\n            },\n            \"not_allowed\": \"No tienes acceso a las configuraciónes del repositorio\",\n            \"registries\": {\n                \"add\": \"Añadir registry\",\n                \"address\": {\n                    \"address\": \"Dirección\",\n                    \"placeholder\": \"Dirección del registry (por ejemplo, docker.io)\"\n                },\n                \"created\": \"Credenciales del registry creadas\",\n                \"credentials\": \"Credenciales del registry\",\n                \"delete\": \"Eliminar registry\",\n                \"deleted\": \"Credenciales del registry eliminadas\",\n                \"desc\": \"Se pueden añadir credenciales de registries para utilizar imágenes privadas para su pipeline.\",\n                \"edit\": \"Editar registry\",\n                \"none\": \"Aún no hay credenciales de registry.\",\n                \"registries\": \"Registries\",\n                \"save\": \"Guardar registry\",\n                \"saved\": \"Credenciales de registry guardadas\",\n                \"show\": \"Mostrar registries\"\n            },\n            \"secrets\": {\n                \"add\": \"Añadir secreto\",\n                \"created\": \"Secreto creado\",\n                \"delete\": \"Eliminar secreto\",\n                \"delete_confirm\": \"¿Realmente quieres eliminar este secreto?\",\n                \"deleted\": \"Secreto eliminado\",\n                \"desc\": \"Los secretos pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales del pipeline.\",\n                \"edit\": \"Editar secreto\",\n                \"events\": {\n                    \"events\": \"Disponible en los siguientes eventos\",\n                    \"pr_warning\": \"Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes\",\n                    \"images\": \"Disponible para las siguientes imágenes\"\n                },\n                \"name\": \"Nombre\",\n                \"none\": \"No hay secretos aún.\",\n                \"plugins_only\": \"Sólo disponible para plugins\",\n                \"save\": \"Guardar secreto\",\n                \"saved\": \"Secreto guardado\",\n                \"secrets\": \"Secretos\",\n                \"show\": \"Mostrar secretos\",\n                \"value\": \"Valor\"\n            },\n            \"settings\": \"Configuración\"\n        },\n        \"user_none\": \"Esta organización / usuario aún no tiene proyectos.\",\n        \"visibility\": {\n            \"visibility\": \"Visibilidad del proyecto\",\n            \"public\": {\n                \"public\": \"Publico\",\n                \"desc\": \"Cualquier persona puede ver su proyecto sin iniciar sesión.\"\n            },\n            \"private\": {\n                \"private\": \"Privado\",\n                \"desc\": \"Solo usted y los otros propietarios de el repositorio pueden ver este proyecto.\"\n            },\n            \"internal\": {\n                \"internal\": \"Interno\",\n                \"desc\": \"Solo los usuarios autenticados de la instancia de Woodpecker pueden ver el proyecto.\"\n            }\n        }\n    },\n    \"repos\": \"Repositorios\",\n    \"repositories\": {\n        \"title\": \"Repositorios\",\n        \"all\": {\n            \"title\": \"Todos los repositorios\",\n            \"desc\": \"Repositorios ordenados por última creación de flujo\"\n        },\n        \"last\": {\n            \"title\": \"Última visita\",\n            \"desc\": \"Repositorios visitados recientemente ordenados por tiempo de acceso\"\n        }\n    },\n    \"running_version\": \"Está ejecutando Woodpecker {0}\",\n    \"search\": \"Buscar…\",\n    \"time\": {\n        \"days_short\": \"d\",\n        \"hours_short\": \"h\",\n        \"min_short\": \"min\",\n        \"not_started\": \"no iniciado aún\",\n        \"sec_short\": \"s\",\n        \"template\": \"MMM D, YYYY, HH:mm z\",\n        \"weeks_short\": \"w\",\n        \"just_now\": \"Justo ahora\"\n    },\n    \"unknown_error\": \"Se ha producido un error desconocido\",\n    \"update_woodpecker\": \"Por favor, actualice su instancia de Woodpecker a {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"No puede iniciar sesión\",\n        \"internal_error\": \"Se ha producido algún error interno\",\n        \"oauth_error\": \"Error al autenticarse con el proveedor OAuth\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Ejemplo de uso de la API\",\n                \"cli_usage\": \"Ejemplo de uso de la CLI\",\n                \"desc\": \"Token de acceso personal y uso de la API\",\n                \"dl_cli\": \"Descargar CLI\",\n                \"reset_token\": \"Restablecer token\",\n                \"shell_setup\": \"Configuración de Shell\",\n                \"shell_setup_before\": \"realice los pasos de configuración del shell antes\",\n                \"swagger_ui\": \"Swagger UI\",\n                \"token\": \"Token de acceso personal\"\n            },\n            \"general\": {\n                \"general\": \"General\",\n                \"language\": \"Idioma\",\n                \"theme\": {\n                    \"auto\": \"Auto\",\n                    \"dark\": \"Oscuro\",\n                    \"light\": \"Claro\",\n                    \"theme\": \"Tema\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Añadir secreto\",\n                \"created\": \"Secreto de usuario creado\",\n                \"deleted\": \"Secreto de usuario eliminado\",\n                \"desc\": \"Los secretos de usuario pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales de cualquier pipeline del usuario.\",\n                \"events\": {\n                    \"events\": \"Disponible en los siguientes eventos\",\n                    \"pr_warning\": \"Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes\",\n                    \"images\": \"Disponible para las siguientes imágenes\"\n                },\n                \"name\": \"Nombre\",\n                \"none\": \"Aún no hay secretos de usuario.\",\n                \"plugins_only\": \"Sólo disponible para plugins\",\n                \"save\": \"Guardar secreto\",\n                \"saved\": \"Secreto de usuario guardado\",\n                \"secrets\": \"Secretos\",\n                \"show\": \"Mostrar secretos\",\n                \"value\": \"Valor\"\n            },\n            \"settings\": \"Configuración de usuario\"\n        }\n    },\n    \"username\": \"Nombre de usuario\",\n    \"welcome\": \"Bienvenido a Woodpecker\",\n    \"empty_list\": \"¡No se ha encontrado {entidad}!\",\n    \"org_level_secret\": \"secreto de organización\",\n    \"global_level_secret\": \"secreto global\",\n    \"login_with\": \"Iniciar sesión con {forge}\",\n    \"extensions\": \"Extensiones\",\n    \"extensions_configuration_saved\": \"Configuración de extensiones guardada\",\n    \"require_approval\": {\n        \"require_approval_for\": \"Requisitos aprobados\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Usuarios permitidos\"\n        }\n    },\n    \"no_search_results\": \"No se han encontrado resultados\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"public_only\": \"Solo público\",\n    \"extensions_signatures_public_key\": \"Clave pública para la firma\",\n    \"login_to_woodpecker_with\": \"Iniciar sesión en Woodpecker con\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/fi.json",
    "content": "{\n  \"back\": \"Takaisin\",\n  \"cancel\": \"Peru\",\n  \"docs\": \"Dokumentaatio\",\n  \"login\": \"Kirjaudu\",\n  \"logout\": \"Kirjaudu ulos\",\n  \"password\": \"Salasana\",\n  \"search\": \"Etsi…\",\n  \"url\": \"URL\",\n  \"username\": \"Käyttäjätunnus\",\n  \"welcome\": \"Tervetuloa Woodpeckeriin\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/fr.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Ajouter un agent\",\n                \"agents\": \"Agents\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"capacité\",\n                    \"capacity\": \"Capacité\",\n                    \"desc\": \"Le nombre maximum de pipelines exécutées en parallèle par cet agent.\"\n                },\n                \"created\": \"Agent crée\",\n                \"delete_agent\": \"Supprimer l'agent\",\n                \"delete_confirm\": \"Voulez vous vraiment supprimer cet agent ? Il ne pourra plus se connecter sur le serveur.\",\n                \"deleted\": \"Agent supprimé\",\n                \"desc\": \"Agents enregistrés sur ce serveur.\",\n                \"edit_agent\": \"Éditer l'agent\",\n                \"id\": \"ID\",\n                \"last_contact\": \"Dernier contact\",\n                \"name\": {\n                    \"name\": \"Nom\",\n                    \"placeholder\": \"Nom de l'agent\"\n                },\n                \"never\": \"Jamais\",\n                \"no_schedule\": {\n                    \"name\": \"Désactiver l'agent\",\n                    \"placeholder\": \"Bloquer l'assignation de nouvelles tâches sur l'agent\"\n                },\n                \"none\": \"Il n'y a pas d'agent pour le moment.\",\n                \"platform\": {\n                    \"badge\": \"platforme\",\n                    \"platform\": \"Platforme\"\n                },\n                \"save\": \"Enregistrer l'agent\",\n                \"saved\": \"Agent enregistré\",\n                \"show\": \"Afficher les agents\",\n                \"token\": \"Jeton\",\n                \"version\": \"Version\"\n            },\n            \"not_allowed\": \"Vous n'êtes pas autorisé à accéder aux réglages du serveur.\",\n            \"orgs\": {\n                \"delete_confirm\": \"Voulez-vous vraiment supprimer cette organisation ? Cela supprimera tous les dépôts que possède cette organisation.\",\n                \"delete_org\": \"Supprimer l'organisation\",\n                \"deleted\": \"Organisation supprimée\",\n                \"desc\": \"Organisations possédant des dépôts sur ce serveur.\",\n                \"none\": \"Il n'y a pas encore d'organisation.\",\n                \"org_settings\": \"Réglages de l'organisation\",\n                \"orgs\": \"Organisations\",\n                \"view\": \"Voir l'organisation\"\n            },\n            \"queue\": {\n                \"agent\": \"agent\",\n                \"desc\": \"Tâches en attente d’exécution par des agents\",\n                \"pause\": \"Mettre en pause\",\n                \"paused\": \"La queue est en pause\",\n                \"queue\": \"Queue\",\n                \"resume\": \"Relancer\",\n                \"resumed\": \"La queue est repartie\",\n                \"stats\": {\n                    \"completed_count\": \"Tâches complétées\",\n                    \"pending_count\": \"En attente\",\n                    \"running_count\": \"En cours d’exécution\",\n                    \"waiting_on_deps_count\": \"En attente de dépendances\",\n                    \"worker_count\": \"Libre\"\n                },\n                \"task_pending\": \"La tâche est en attente\",\n                \"task_running\": \"La tâche est en cours d’exécution\",\n                \"task_waiting_on_deps\": \"La tâche est en attente de ses dépendances\",\n                \"tasks\": \"Tâches\",\n                \"waiting_for\": \"en attente de\"\n            },\n            \"repos\": {\n                \"desc\": \"Dépôts actifs ou anciennement actifs sur ce serveur.\",\n                \"disabled\": \"Désactivé\",\n                \"none\": \"Il n'y a pas encore de dépôts.\",\n                \"repair\": {\n                    \"repair\": \"Tout réparer\",\n                    \"success\": \"Dépôts réparés\"\n                },\n                \"repos\": \"Dépôts\",\n                \"settings\": \"Réglages du dépôt\",\n                \"view\": \"Voir le dépôt\"\n            },\n            \"secrets\": {\n                \"add\": \"Ajouter un secret\",\n                \"created\": \"Secret global crée\",\n                \"deleted\": \"Secret global supprimé\",\n                \"desc\": \"Les secrets globaux sont transmis sous forme de variable d’environnement lors de l’exécution de toutes les étapes d'un pipeline.\",\n                \"events\": {\n                    \"events\": \"Disponible pour les événements suivants\",\n                    \"pr_warning\": \"Faites attention avec cette option car un acteur malicieux peut soumettre une pull request qui révélerait vos secrets.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images\",\n                    \"images\": \"Disponible pour les images suivantes\"\n                },\n                \"name\": \"Nom\",\n                \"none\": \"Il n'y a pas de secrets globaux.\",\n                \"plugins_only\": \"Disponible uniquement pour les plugins\",\n                \"save\": \"Enregistrer un secret\",\n                \"saved\": \"Secret global enregistré\",\n                \"secrets\": \"Secrets\",\n                \"show\": \"Afficher les secrets\",\n                \"value\": \"Valeur\",\n                \"warning\": \"Ces secrets seront disponibles pour tout les comptes.\"\n            },\n            \"settings\": \"Réglages\",\n            \"users\": {\n                \"add\": \"Ajouter un compte utilisateur\",\n                \"admin\": {\n                    \"admin\": \"Administrateur\",\n                    \"placeholder\": \"Le compte utilisateur est un administrateur\"\n                },\n                \"avatar_url\": \"URL de l'avatar\",\n                \"cancel\": \"Annuler\",\n                \"created\": \"Compte utilisateur créé\",\n                \"delete_confirm\": \"Voulez vous vraiment supprimer ce compte utilisateur ? Cela supprimera tout les dépôts que possède ce compte utilisateur.\",\n                \"delete_user\": \"Supprimer le compte utilisateur\",\n                \"deleted\": \"Compte utilisateur supprimé\",\n                \"desc\": \"Utilisateurs enregistrés sur le serveur\",\n                \"edit_user\": \"Éditer le compte utilisateur\",\n                \"email\": \"Courriel\",\n                \"login\": \"Login\",\n                \"none\": \"Il n'y a pas de compte utilisateur pour le moment.\",\n                \"save\": \"Enregistrer le compte utilisateur\",\n                \"saved\": \"Compte utilisateur enregistré\",\n                \"show\": \"Afficher les comptes utilisateurs\",\n                \"users\": \"Utilisateurs\"\n            },\n            \"registries\": {\n                \"warning\": \"Ces codes d’accès au registre seront disponibles pour tout les utilisateurs.\",\n                \"desc\": \"Les codes d'accès aux registres globaux peuvent être ajouter pour utiliser les images privées sur tout les pipelines.\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Revenir en arrière\",\n    \"cancel\": \"Annuler\",\n    \"default\": \"défaut\",\n    \"docs\": \"Docs\",\n    \"documentation_for\": \"Documentation sur \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Le serveur n'a pas pu trouver l'objet demandé\"\n    },\n    \"global_level_secret\": \"Secret global\",\n    \"info\": \"Information\",\n    \"login\": \"Connexion\",\n    \"logout\": \"Déconnexion\",\n    \"not_found\": {\n        \"back_home\": \"Retour à l'accueil\",\n        \"not_found\": \"Whoa 404, soit nous avons cassé quelque chose, soit vous avez fait une faute de frappe :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Vous n’êtes pas autorisé à accéder aux réglages de cette organisation\",\n            \"secrets\": {\n                \"add\": \"Ajouter un secret\",\n                \"created\": \"Secret d'organisation crée\",\n                \"deleted\": \"Secret d'organisation supprimé\",\n                \"desc\": \"Les secrets d'organisation sont transmis sous forme de variable d’environnement à chaque étape d'un pipeline de tout les dépôts de l'organisation.\",\n                \"events\": {\n                    \"events\": \"Disponible pour les événements suivants\",\n                    \"pr_warning\": \"Faites attention avec cette option car un acteur malicieux pourrait soumettre une pull request qui va afficher vos secrets.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images\",\n                    \"images\": \"Disponible pour les images suivantes\"\n                },\n                \"name\": \"Nom\",\n                \"none\": \"Il n'y a pas de secrets d'organisation.\",\n                \"plugins_only\": \"Disponible uniquement pour les plugins\",\n                \"save\": \"Enregistrer un secret\",\n                \"saved\": \"Secret d'organisation enregistré\",\n                \"secrets\": \"Secrets\",\n                \"show\": \"Afficher les secrets\",\n                \"value\": \"Valeur\"\n            },\n            \"settings\": \"Réglages\",\n            \"registries\": {\n                \"desc\": \"Les codes d'accès aux registres de l'organisation peuvent être ajouté pour utiliser les images privées sur tout les pipelines de l'organisation.\"\n            }\n        }\n    },\n    \"org_level_secret\": \"Secret d'organisation\",\n    \"password\": \"Mot de passe\",\n    \"pipeline_feed\": \"Fil du pipeline\",\n    \"repo\": {\n        \"activity\": \"Activité\",\n        \"add\": \"Ajouter un dépôt\",\n        \"branches\": \"Branches\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Environnement de 'déploiement' ciblé\",\n            \"title\": \"Déclenchement d'un événement de 'déploiement' pour le pipeline courant #{pipelineId}\",\n            \"trigger\": \"Déployer\",\n            \"variables\": {\n                \"add\": \"Ajouter une variable\",\n                \"desc\": \"Spécifiez les variables additionnelles que votre pipeline va utiliser. Les variables portant le même nom seront écrasées.\",\n                \"name\": \"Nom de la variable\",\n                \"title\": \"Variables additionnelles du pipeline\",\n                \"value\": \"Valeur de la variable\",\n                \"delete\": \"Supprimer la variable\"\n            },\n            \"enter_task\": \"Tâche de déploiement\"\n        },\n        \"enable\": {\n            \"disabled\": \"Désactivé\",\n            \"enable\": \"Activer\",\n            \"enabled\": \"Déjà activé\",\n            \"list_reloaded\": \"Liste des dépôts actualisée\",\n            \"reload\": \"Actualiser les dépôts\",\n            \"success\": \"Dépôt activé\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Sélectionner une branche\",\n            \"title\": \"Déclencher manuellement une exécution de pipeline\",\n            \"trigger\": \"Exécuter un pipeline\",\n            \"variables\": {\n                \"add\": \"Ajouter une variable\",\n                \"desc\": \"Ajoutez des variables pour le lancement du pipeline. Les variables existantes seront écrasés.\",\n                \"name\": \"Nom de la variable\",\n                \"title\": \"Variables de pipeline supplémentaire\",\n                \"value\": \"Valeur de la variable\",\n                \"delete\": \"Supprimer la variable\"\n            },\n            \"show_pipelines\": \"Afficher les pipelines\"\n        },\n        \"not_allowed\": \"Vous n'êtes pas autorisé à accéder à ce dépôt\",\n        \"open_in_forge\": \"Ouvrir le dépôt dans la forge\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Annuler\",\n                \"cancel_success\": \"Pipeline annulé\",\n                \"canceled\": \"Cette étape a été annulée.\",\n                \"deploy\": \"Déployer\",\n                \"log_auto_scroll\": \"Automatiquement défiler vers le bas\",\n                \"log_auto_scroll_off\": \"Désactiver le défilement automatique\",\n                \"log_download\": \"Télécharger\",\n                \"restart\": \"Redémarrer\",\n                \"restart_success\": \"Pipeline redémarré\",\n                \"log_delete\": \"Supprimer\"\n            },\n            \"config\": \"Configuration\",\n            \"errors\": \"Erreurs ({count})\",\n            \"event\": {\n                \"cron\": \"Tâche périodique\",\n                \"deploy\": \"Déploiement\",\n                \"manual\": \"Manuel\",\n                \"pr\": \"Pull Request\",\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"release\": \"Release\",\n                \"pr_closed\": \"Pull Request fusionnée / fermée\"\n            },\n            \"exit_code\": \"Code de retour {exitCode}\",\n            \"files\": \"Fichiers changés ({files})\",\n            \"loading\": \"Chargement…\",\n            \"log_download_error\": \"Il y a eu une erreur lors du téléchargement du fichier de journal\",\n            \"log_title\": \"Journal des étapes\",\n            \"no_files\": \"Aucun fichier n'a été modifié.\",\n            \"no_pipeline_steps\": \"Aucune étape disponible !\",\n            \"no_pipelines\": \"Aucun pipeline n'a démarré pour le moment.\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"pipelines_for\": \"Pipelines pour la branche \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Pipeline pour la pull request #{index}\",\n            \"protected\": {\n                \"approve\": \"Approuver\",\n                \"approve_success\": \"Pipeline approuvé\",\n                \"awaits\": \"Ce pipeline attend d'être approuvé par un mainteneur !\",\n                \"decline\": \"Refuser\",\n                \"decline_success\": \"Pipeline refusé\",\n                \"declined\": \"Le pipeline a été refusé !\",\n                \"review\": \"Vérifier les changements\"\n            },\n            \"show_errors\": \"Afficher les erreurs\",\n            \"status\": {\n                \"blocked\": \"bloqué\",\n                \"declined\": \"refusé\",\n                \"error\": \"en erreur\",\n                \"failure\": \"échoué\",\n                \"killed\": \"tué\",\n                \"pending\": \"en attente\",\n                \"running\": \"en cours\",\n                \"skipped\": \"sauté\",\n                \"started\": \"démarré\",\n                \"status\": \"État : {status}\",\n                \"success\": \"réussi\"\n            },\n            \"step_not_started\": \"L'étape n'a pas démarré encore.\",\n            \"tasks\": \"Tâches\",\n            \"warnings\": \"Avertissements ({count})\",\n            \"we_got_some_errors\": \"Oh non, il y a des erreurs !\",\n            \"log_delete_error\": \"Il y a eu une erreur lors de la suppression des logs\",\n            \"log_delete_confirm\": \"Voulez vous vraiment supprimer les logs de cette étape ?\",\n            \"no_logs\": \"Aucun logs\",\n            \"duration\": \"Durée du pipeline\",\n            \"created\": \"Crée : {created}\",\n            \"debug\": {\n                \"title\": \"Débogage\",\n                \"download_metadata\": \"Télécharger les métadonnées\",\n                \"metadata_download_error\": \"Erreur lors du téléchargement des métadonnées\"\n            }\n        },\n        \"pull_requests\": \"Pull requests\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Actions\",\n                \"delete\": {\n                    \"confirm\": \"Toutes les données vont être perdues aprés cette action ! ! !\\n\\nVoulez vous vraiment continuer ?\",\n                    \"delete\": \"Supprimer le dépôt\",\n                    \"success\": \"Dépôt supprimé\"\n                },\n                \"disable\": {\n                    \"disable\": \"Désactiver le dépôt\",\n                    \"success\": \"Dépôt désactivé\"\n                },\n                \"enable\": {\n                    \"enable\": \"Activer le dépôt\",\n                    \"success\": \"Dépôt activé\"\n                },\n                \"repair\": {\n                    \"repair\": \"Réparer un dépôt\",\n                    \"success\": \"Dépôt réparé\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Badge\",\n                \"branch\": \"Branche\",\n                \"type\": \"Syntaxe\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Ajouter une tâche planifiée\",\n                \"branch\": {\n                    \"placeholder\": \"Branche (utilise la branche par défaut si non renseigné)\",\n                    \"title\": \"Branche\"\n                },\n                \"created\": \"Tâche planifiée crée\",\n                \"crons\": \"Tâches planifiées\",\n                \"delete\": \"Supprimer la tâche planifiée\",\n                \"deleted\": \"Tâche planifiée supprimée\",\n                \"desc\": \"Les tâches planifiées peuvent déclencher des pipelines à intervalles réguliers.\",\n                \"edit\": \"Modifier la tâche planifiée\",\n                \"name\": {\n                    \"name\": \"Nom\",\n                    \"placeholder\": \"Nom de la tâche périodique\"\n                },\n                \"next_exec\": \"Prochaine exécution\",\n                \"none\": \"Il n'y a pas de tâche planifié pour le moment.\",\n                \"not_executed_yet\": \"Non exécuté pour le moment\",\n                \"run\": \"Lancer immédiatement\",\n                \"save\": \"Enregistrer la tâche planifiée\",\n                \"saved\": \"Tâche planifiée enregistrée\",\n                \"schedule\": {\n                    \"placeholder\": \"Horaire\",\n                    \"title\": \"Horaire (basé sur UTC)\"\n                },\n                \"show\": \"Afficher les tâches planifiées\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Autoriser les demandes de fusions\",\n                    \"desc\": \"Permettre aux pipelines de se déclencher sur les pull requests.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Annuler les pipelines précédents\",\n                    \"desc\": \"Activer pour forcer l'annulation des pipelines en cours et en attente pour le même contexte et le même événement avant de démarrer un nouveau pipeline déclenché par un événement.\"\n                },\n                \"general\": \"Général\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Les plugins listés ici auront accès aux identifiants netrc qui peuvent être utiliser pour cloner des dépôts depuis la forge, ou pour pousser vers celle-ci.\",\n                    \"netrc_only_trusted\": \"Plugins de clonage personnalisés de confiance\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Par défaut : .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Le chemin vers votre configuration de pipeline (par example {0}). Les dossiers doivent finir par {1}.\",\n                    \"desc_path_example\": \"mon/chemin/\",\n                    \"path\": \"Chemin vers le pipeline\"\n                },\n                \"project\": \"Paramètres du projet\",\n                \"protected\": {\n                    \"desc\": \"Chaque pipeline doit être approuvé avant d'être exécuté.\",\n                    \"protected\": \"Protégé\"\n                },\n                \"save\": \"Enregistrer les paramètres\",\n                \"success\": \"Paramètres du projet mis à jour\",\n                \"timeout\": {\n                    \"minutes\": \"minutes\",\n                    \"timeout\": \"Délai d’inactivité\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Les conteneurs du pipeline ont accès à des capacités privilégiées (comme le montage de volumes).\",\n                    \"trusted\": \"Vérifié\",\n                    \"network\": {\n                        \"network\": \"Réseau\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumes\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sécurité\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Seuls les utilisateurs authentifiés de l'instance peuvent voir ce projet.\",\n                        \"internal\": \"Interne\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Seulement vous et les autres propriétaires de ce dépôt peuvent voir ce projet.\",\n                        \"private\": \"Privée\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Tout les utilisateurs peuvent voir votre projet sans être connecté.\",\n                        \"public\": \"Publique\"\n                    },\n                    \"visibility\": \"Visibilité du projet\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Autoriser les événements de 'déploiement'.\",\n                    \"desc\": \"Permettre les déploiements depuis les pipelines ayant réussis. À utiliser que si vous avez confiance dans les utilisateurs ayant un accès en écriture.\"\n                }\n            },\n            \"not_allowed\": \"Vous n'êtes pas autorisé à accéder aux paramètres de ce dépôt\",\n            \"registries\": {\n                \"add\": \"Ajouter un registre\",\n                \"address\": {\n                    \"address\": \"Adresse\",\n                    \"placeholder\": \"Adresse du registre (e.g. docker.io)\"\n                },\n                \"created\": \"Authentifiant de connexion à un registre crée\",\n                \"credentials\": \"Authentifiants de connexion à un registre\",\n                \"delete\": \"Supprimer le registre\",\n                \"deleted\": \"Authentifiant de connexion à un registre supprimé\",\n                \"desc\": \"Des authentifiants de connexion pour les registres peuvent être ajouté pour permettre d'utiliser des images privées pour vos pipelines.\",\n                \"edit\": \"Modifier le registre\",\n                \"none\": \"Il n'y a pas d’authentifiant de connexion à un registre pour le moment.\",\n                \"registries\": \"Registres\",\n                \"save\": \"Enregistrer le registre\",\n                \"saved\": \"Authentifiant de connexion à un registre enregistré\",\n                \"show\": \"Afficher les registres\"\n            },\n            \"secrets\": {\n                \"add\": \"Ajouter un secret\",\n                \"created\": \"Secret crée\",\n                \"delete\": \"Supprimer le secret\",\n                \"delete_confirm\": \"Voulez vous vraiment supprimer ce secret ?\",\n                \"deleted\": \"Secret supprimé\",\n                \"desc\": \"Les secrets sont transmis sous forme de variable d’environnement lors de l’exécution d'une étape d'un pipeline.\",\n                \"edit\": \"Modifier le secret\",\n                \"events\": {\n                    \"events\": \"Disponible pour les événements suivants\",\n                    \"pr_warning\": \"Faites attention avec cette option car un acteur malicieux pourrait soumettre une pull request qui va afficher vos secrets.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images\",\n                    \"images\": \"Disponible pour les images suivantes\"\n                },\n                \"name\": \"Nom\",\n                \"none\": \"Il n'y a pas de secrets pour le moment.\",\n                \"plugins_only\": \"Disponible uniquement pour les plugins\",\n                \"save\": \"Enregistrer un secret\",\n                \"saved\": \"Secret enregistré\",\n                \"secrets\": \"Secrets\",\n                \"show\": \"Afficher les secrets\",\n                \"value\": \"Valeur\"\n            },\n            \"settings\": \"Paramètres\"\n        },\n        \"user_none\": \"Cet(te) organisation / utilisateur n'a pas encore de projets.\",\n        \"visibility\": {\n            \"visibility\": \"Visibilité du projet\",\n            \"public\": {\n                \"public\": \"Publique\",\n                \"desc\": \"Tout le monde peut voir le projet sans être connecté.\"\n            },\n            \"private\": {\n                \"private\": \"Privé\",\n                \"desc\": \"Seul vous et les propriétaires de ce dépôt peuvent le voir.\"\n            },\n            \"internal\": {\n                \"internal\": \"Interne\",\n                \"desc\": \"Seuls les comptes identifiés sur cet instance de Woodpecker peuvent voir ce projet.\"\n            }\n        }\n    },\n    \"repos\": \"Dépôt\",\n    \"repositories\": {\n        \"title\": \"Dépôts\",\n        \"all\": {\n            \"title\": \"Tout les dépôts\",\n            \"desc\": \"Dépôts classés par date de création du dernier pipeline\"\n        },\n        \"last\": {\n            \"title\": \"Dernière visite\"\n        }\n    },\n    \"running_version\": \"Vous utilisez Woodpecker {0}\",\n    \"search\": \"Rechercher…\",\n    \"time\": {\n        \"days_short\": \"j\",\n        \"hours_short\": \"h\",\n        \"min_short\": \"min\",\n        \"not_started\": \"pas encore démarré\",\n        \"sec_short\": \"sec\",\n        \"template\": \"D MMM, YYYY, HH:mm z\",\n        \"weeks_short\": \"s\",\n        \"just_now\": \"il y a peu de temps\"\n    },\n    \"unknown_error\": \"Une erreur inconnue est survenue\",\n    \"update_woodpecker\": \"Merci de mettre à jour votre instance Woodpecker vers la version {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Vous n'êtes pas autorisé à vous connecter\",\n        \"internal_error\": \"Une erreur interne est arrivé\",\n        \"oauth_error\": \"Erreur lors de l’authentification auprès du fournisseur OAuth\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Exemple d'utilisation de l'API\",\n                \"cli_usage\": \"Exemple d'utilisation de l'interface en ligne de commande\",\n                \"desc\": \"Jeton d'Accès Personnel et usage de l'API\",\n                \"dl_cli\": \"Télécharger l'Interface en ligne de commande\",\n                \"reset_token\": \"Réinitialiser le jeton\",\n                \"shell_setup\": \"Configuration de l'interpréteur de commande\",\n                \"shell_setup_before\": \"Faites les étapes de configuration de l'interpréteur de commande avant\",\n                \"swagger_ui\": \"Interface Swagger\",\n                \"token\": \"Jeton d'Accès Personnel\"\n            },\n            \"general\": {\n                \"general\": \"Général\",\n                \"language\": \"Langue\",\n                \"theme\": {\n                    \"auto\": \"Automatique\",\n                    \"dark\": \"Sombre\",\n                    \"light\": \"Clair\",\n                    \"theme\": \"Thème\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Ajouter un secret\",\n                \"created\": \"Secret d'utilisateur crée\",\n                \"deleted\": \"Secret d'utilisateur supprimé\",\n                \"desc\": \"Les secrets d'utilisateur peuvent être passés à toutes les étapes d'un pipeline personnel sous forme de variables d'environnement.\",\n                \"events\": {\n                    \"events\": \"Disponible pour les événements suivants\",\n                    \"pr_warning\": \"Attention, si cette option est activée, un acteur malveillant peut proposer une pull request qui affiche vos secrets.\"\n                },\n                \"images\": {\n                    \"desc\": \"Liste des où ce secret sera utilisable, laisser vide pour autoriser toutes les images\",\n                    \"images\": \"Disponible pour les images suivantes\"\n                },\n                \"name\": \"Nom\",\n                \"none\": \"Il n'y a pas encore de secrets d'utilisateur.\",\n                \"plugins_only\": \"Disponible uniquement pour les plugins\",\n                \"save\": \"Enregistrer le secret\",\n                \"saved\": \"Secret d'utilisateur enregistré\",\n                \"secrets\": \"Secrets\",\n                \"show\": \"Afficher les secrets\",\n                \"value\": \"Valeur\"\n            },\n            \"settings\": \"Paramètres du compte utilisateur\",\n            \"cli_and_api\": {\n                \"desc\": \"Jeton d'Accès Personnel, ligne de command et usage de l'API\",\n                \"token\": \"Jeton d'Accès Personnel\",\n                \"api_usage\": \"Exemple d'utilisation de l'API\",\n                \"download_cli\": \"Télécharger l'interface en ligne de commande\",\n                \"reset_token\": \"Réinitialiser le jeton\\\"\",\n                \"cli_and_api\": \"Ligne de commande et API\",\n                \"cli_usage\": \"Exemple d'utilisation de la ligne de commande\",\n                \"swagger_ui\": \"Interface Swagger\"\n            }\n        }\n    },\n    \"username\": \"Nom d'utilisateur\",\n    \"welcome\": \"Bienvenue sur Woodpecker\",\n    \"empty_list\": \"Aucune {entity} trouvée !\",\n    \"login_to_cli\": \"Login en ligne de commande\",\n    \"abort\": \"Abandonner\",\n    \"cli_login_success\": \"Connexion en ligne de commande réussie\",\n    \"cli_login_failed\": \"Connexion en ligne de commande échouée\",\n    \"cli_login_denied\": \"Connexion à la ligne de commande interdite\",\n    \"return_to_cli\": \"Vous pouvez fermer cet onglet et revenir à la ligne de commande.\",\n    \"login_to_cli_description\": \"En continuant, vous serez connecté via la ligne de commande.\",\n    \"secrets\": {\n        \"secrets\": \"Secrets\",\n        \"desc\": \"Les secrets peuvent être transmis aux différentes étapes de la pipeline en tant que variables d'environnement.\",\n        \"images\": {\n            \"desc\": \"Liste des images pour lesquelles ce secret est disponible, laisser vide pour autoriser toutes les images\",\n            \"images\": \"Disponible pour les images suivantes\"\n        },\n        \"none\": \"Il n'y a pas encore de secrets.\",\n        \"add\": \"Ajouter un secret\",\n        \"save\": \"Sauvegarder le secret\",\n        \"show\": \"Afficher les secrets\",\n        \"name\": \"Nom\",\n        \"value\": \"Valeur\",\n        \"deleted\": \"Secret supprimé\",\n        \"created\": \"Secret créé\",\n        \"saved\": \"Secret sauvegardé\",\n        \"events\": {\n            \"events\": \"Disponible lors des événements suivants\",\n            \"pr_warning\": \"Soyez prudent avec cette option : un acteur malveillant peut soumettre une pull request qui expose vos secrets.\"\n        },\n        \"plugins_only\": \"Uniquement disponible pour les plugins\",\n        \"edit\": \"Modifier le secret\",\n        \"delete\": \"Supprimer le secret\",\n        \"delete_confirm\": \"Voulez vous vraiment supprimer ce secret ?\",\n        \"plugins\": {\n            \"images\": \"Disponible pour les plugins suivants\"\n        }\n    },\n    \"internal_error\": \"Une erreur interne est survenue\",\n    \"access_denied\": \"Vous n'êtes pas autorisé à accéder à cette instance\",\n    \"oauth_error\": \"Erreur lors de l'authentification auprès du fournisseur OAuth\",\n    \"registration_closed\": \"Les inscriptions sont fermés\",\n    \"settings\": \"Réglages\",\n    \"registries\": {\n        \"registries\": \"Registres\",\n        \"delete_confirm\": \"Voulez vous vraiment supprimer ce registre ?\",\n        \"deleted\": \"Codes d'accès aux registres supprimés\",\n        \"desc\": \"Les codes d'accès aux registres peuvent être ajouté pour utiliser des images privées sur les pipelines.\",\n        \"credentials\": \"Codes d’accès aux registres\",\n        \"edit\": \"Éditer le registre\",\n        \"delete\": \"Supprimer le registre\",\n        \"add\": \"Ajouter un registre\",\n        \"view\": \"Voir le registre\",\n        \"save\": \"Enregistrer le registre\",\n        \"show\": \"Afficher les registres\",\n        \"address\": {\n            \"address\": \"Adresse\",\n            \"desc\": \"Adresse du registre (e.g. docker.io)\"\n        },\n        \"saved\": \"Codes d'accès aux registres enregistrés\",\n        \"created\": \"Codes d’accès aux registres crées\",\n        \"none\": \"Il n'y a pas de code d’accès aux registres.\"\n    },\n    \"invalid_state\": \"L'état OAuth est invalide\",\n    \"by_user\": \"par {user}\",\n    \"pushed_to\": \"poussé vers\",\n    \"closed\": \"fermé\",\n    \"deployed_to\": \"déployé vers\",\n    \"created\": \"crée\",\n    \"triggered\": \"déclenché\",\n    \"pipeline_duration\": \"Durée du pipeline\",\n    \"pipeline_since\": \"Pipeline crée {created} minutes ago\",\n    \"pipeline_has_warnings\": \"Le pipeline a des alertes\",\n    \"pipeline_has_errors\": \"Le pipeline a des erreurs\",\n    \"login_with\": \"Se connecter avec {forge}\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/hu.json",
    "content": "{\n    \"back\": \"Vissza\",\n    \"logout\": \"Kijelentkezés\",\n    \"search\": \"Keresés…\",\n    \"username\": \"Felhasználónév\",\n    \"unknown_error\": \"Ismeretlen hiba történt\",\n    \"password\": \"Jelszó\",\n    \"repo\": {\n        \"visibility\": {\n            \"public\": {\n                \"public\": \"Nyilvános\",\n                \"desc\": \"Bárki láthatja a projekted, belépés nélkül.\"\n            },\n            \"private\": {\n                \"private\": \"Privát\",\n                \"desc\": \"Csak te és a többi tulajdonos láthatjátok ezt a projektet.\"\n            },\n            \"internal\": {\n                \"internal\": \"Belső\",\n                \"desc\": \"Csak authentikált felhasználók láthatják a Woodpecker oldalát ennek a projektnek.\"\n            },\n            \"visibility\": \"Projekt láthatósága\"\n        },\n        \"settings\": {\n            \"general\": {\n                \"timeout\": {\n                    \"minutes\": \"percek\",\n                    \"timeout\": \"Időtúllépés\"\n                },\n                \"save\": \"Beállítások mentése\",\n                \"project\": \"Projekt beállítások\",\n                \"success\": \"Projekt beállítások frissítve\",\n                \"cancel_prev\": {\n                    \"cancel\": \"Előző pipelineok leállítása\",\n                    \"desc\": \"A kiválasztott esemény elődizések leállítják a függőben lévő és futó pipelineokat ugyanazon eseménynél, mielőtt elindítaná a következőt.\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Alapértelmezetten: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"path\": \"Pipeline elérési útja\",\n                    \"desc_path_example\": \"saját/elérési_utam/\",\n                    \"desc\": \"Az élérési út a pipeline konfigurációdhoz (például {0}). A Mappáknak {1}-ra kéne végződnie.\"\n                },\n                \"general\": \"Általános\",\n                \"allow_pr\": {\n                    \"allow\": \"Pull Requestek bekapcsolása\",\n                    \"desc\": \"A pipelineok lefuttatásának engedélyezése a pull requesteken.\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Megbízott\",\n                    \"network\": {\n                        \"desc\": \"Pipeline konténerek hálózati hozzáférést kapnak mint például a DNS megváltoztatásához.\",\n                        \"network\": \"Hálózat\"\n                    },\n                    \"security\": {\n                        \"security\": \"Biztonság\",\n                        \"desc\": \"A pipeline adattárolók hozzáférést kapnak a biztonsági jogosultságokhoz.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Adattárolók\",\n                        \"desc\": \"A pipeline konténerek számára engedélyezett az adattárolók csatolása.\"\n                    }\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Üzembe helyezések bekapcsolása\",\n                    \"desc\": \"Engedélyezi az üzembe helyezését a sikeres pipelineoknak. Minden felhasználó push hozzáféréssel tud ilyet előidézni, szóval csak óvatosan.\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Egyedi megbízott klón pluginok\",\n                    \"desc\": \"A pluginok amelyek hozzáférést kapnak a netrc hozzáférési adatokhoz tudnak repositorykat klónozni az adott kódplatformról vagy pusholni az adott kódplatformra.\"\n                }\n            },\n            \"actions\": {\n                \"disable\": {\n                    \"disable\": \"Tároló inaktiválása\",\n                    \"success\": \"Repository kikapcsolva\"\n                },\n                \"delete\": {\n                    \"delete\": \"Repository törlése\",\n                    \"success\": \"Repository törölve\",\n                    \"confirm\": \"Minden adat el fog veszni ez az akció után!\\n\\nBiztos, hogy folytatni akarod?\"\n                },\n                \"actions\": \"Actions\",\n                \"repair\": {\n                    \"repair\": \"Repository javítása\",\n                    \"success\": \"Repository javítva\"\n                },\n                \"enable\": {\n                    \"enable\": \"Repository bekapcsolása\",\n                    \"success\": \"Repository bekapcsolva\"\n                }\n            },\n            \"crons\": {\n                \"none\": \"Nincsenek még cronok.\",\n                \"save\": \"Cron mentése\",\n                \"created\": \"Cron létrehozva\",\n                \"saved\": \"Cron elmentve\",\n                \"desc\": \"A cron feladatok tudnak pipelineokat indítani időközönként.\",\n                \"next_exec\": \"Következő végrehajtás\",\n                \"run\": \"Futtatás most\",\n                \"deleted\": \"Cron törölve\",\n                \"not_executed_yet\": \"Még nincs végrehajtva\",\n                \"branch\": {\n                    \"title\": \"Branch\",\n                    \"placeholder\": \"Branch (az alap branchet használja ha üres)\"\n                },\n                \"name\": {\n                    \"name\": \"Név\",\n                    \"placeholder\": \"A cron feladat neve\"\n                },\n                \"schedule\": {\n                    \"title\": \"Menetrend (UTC időzónán alapozva)\",\n                    \"placeholder\": \"Menetrend\"\n                },\n                \"crons\": \"Cronok\",\n                \"edit\": \"Cron szerkesztése\",\n                \"delete\": \"Cron törlése\",\n                \"show\": \"Cronok mutatása\",\n                \"add\": \"Cron hozzáadása\"\n            },\n            \"not_allowed\": \"Nincs hozzáférésed ennek a repositorynak a beállításaihoz\",\n            \"badge\": {\n                \"branch\": \"Branch\",\n                \"type_html\": \"HTML\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"badge\": \"Jelvény\",\n                \"type\": \"Szintaxis\"\n            }\n        },\n        \"pipeline\": {\n            \"loading\": \"Betöltés…\",\n            \"actions\": {\n                \"canceled\": \"Ez a lépés le lett állítva.\",\n                \"log_download\": \"Letöltés\",\n                \"log_delete\": \"Törlés\",\n                \"restart_success\": \"Pipeline újraindítva\",\n                \"log_auto_scroll\": \"Automata görgetés bekapcsolása\",\n                \"cancel_success\": \"Pipeline leállítva\",\n                \"deploy\": \"Üzembe helyezés\",\n                \"log_auto_scroll_off\": \"Automata görgetés kikapcsolása\",\n                \"cancel\": \"Mégse\",\n                \"restart\": \"Újraindítás\"\n            },\n            \"event\": {\n                \"cron\": \"Időzítő\",\n                \"pr_closed\": \"Pull Request egyesítve/lezárva\",\n                \"release\": \"Kiadás\",\n                \"manual\": \"Manuális\",\n                \"tag\": \"Bélyeg\",\n                \"pr\": \"Pull request\",\n                \"deploy\": \"Üzembe helyezés\",\n                \"push\": \"Push\"\n            },\n            \"debug\": {\n                \"title\": \"Debuggolás\",\n                \"download_metadata\": \"Metaadat letöltése\",\n                \"metadata_download_error\": \"Hiba metaadat letöltése közben\",\n                \"metadata_download_successful\": \"Metadat sikeresen letöltve\",\n                \"no_permission\": \"Nincs jogosultságod a debug információkhoz\",\n                \"metadata_exec_title\": \"Pipeline újrafuttatása lokálisan\",\n                \"metadata_exec_desc\": \"A pipeline metaadatjának letöltése, hogy lokálisan lehessen futtatni. Így kitudsz javítani hibákat benne és változtatásokat tesztelni mielőtt véglegesítenéd őket. A `woodpecker-cli` lokális verziójának és a Woodpecker Szerver verziójának egyeznie kell.\"\n            },\n            \"no_logs\": \"Nincsen napló\",\n            \"status\": {\n                \"pending\": \"folyamatban\",\n                \"started\": \"elindítva\",\n                \"skipped\": \"átlépett\",\n                \"running\": \"fut\",\n                \"status\": \"Státusz: {status}\",\n                \"blocked\": \"blokkolva\",\n                \"success\": \"sikeres\",\n                \"declined\": \"elutasítva\",\n                \"error\": \"hiba\",\n                \"failure\": \"sikertelen\",\n                \"killed\": \"terminálva\"\n            },\n            \"log_delete_confirm\": \"Biztos, hogy akarod ehhez a lépéshez tartozó naplót törölni?\",\n            \"protected\": {\n                \"awaits\": \"Ez a pipeline a fenntartó elfogadására vár!\",\n                \"declined\": \"Ezt a pipeline-t elutasították!\",\n                \"approve_success\": \"Pipeline elfogadva\",\n                \"approve\": \"Elfogadás\",\n                \"decline\": \"Elutasítás\",\n                \"decline_success\": \"Pipeline elutasítva\"\n            },\n            \"created\": \"Készítve: {created}\",\n            \"duration\": \"Pipeline lefutási ideje\",\n            \"config\": \"Konfiguráció\",\n            \"files\": \"Megváltoztatott fájlok\",\n            \"errors\": \"Hibák\",\n            \"warnings\": \"Figyelmeztetések\",\n            \"show_errors\": \"Hibák mutatása\",\n            \"we_got_some_errors\": \"Jaj ne, hiba történt!\",\n            \"log_delete_error\": \"Hiba lépett fel a lépés naplójának törlése közben\",\n            \"exit_code\": \"Kilépési kód {exitCode}\",\n            \"log_download_error\": \"Hiba lépett fel a napló letöltése közben\",\n            \"no_pipeline_steps\": \"Nincsenek elérhető pipeline lépések!\",\n            \"pipelines_for\": \"\\\"{branch}\\\" branchhez tartozó pipelineok\",\n            \"pipelines_for_pr\": \"#{index} pull requesthez tartozó pipelineok\",\n            \"tasks\": \"Feladatok\",\n            \"no_pipelines\": \"Egy pipeline sem indult még el.\",\n            \"step_not_started\": \"Ez a lépés még nem indult el.\",\n            \"pipeline\": \"#{pipelineId} Pipeline\",\n            \"log_title\": \"Lépés napló\"\n        },\n        \"manual_pipeline\": {\n            \"variables\": {\n                \"delete\": \"Változó törlése\",\n                \"title\": \"Egyéb pipeline változók\",\n                \"desc\": \"Adj meg egyéb változókat, hogy használhasd a pipelineodban. A megegyező nevű változók felülírják egymást.\",\n                \"name\": \"Változó neve\",\n                \"value\": \"Változó értéke\"\n            },\n            \"select_branch\": \"Branch választás\",\n            \"title\": \"Manuális pipeline futtatás előidézése\",\n            \"trigger\": \"Pipeline futtatása\",\n            \"show_pipelines\": \"Pipelineok mutatása\"\n        },\n        \"deploy_pipeline\": {\n            \"variables\": {\n                \"delete\": \"Változó törlése\",\n                \"value\": \"Változó értéke\",\n                \"title\": \"Egyéb pipeline változók\",\n                \"desc\": \"Adj meg egyéb változókat, hogy használhasd a pipelineodban. A megegyező nevű változók felülírják egymást.\",\n                \"name\": \"Változó neve\"\n            },\n            \"enter_target\": \"Az üzembe helyezés célkörnyezete\",\n            \"title\": \"Idézz elő egy üzembe helyezést a jelenlegi #{pipelineId} pipeline-hoz\",\n            \"trigger\": \"Üzembe helyezés\",\n            \"enter_task\": \"Üzembe helyezési feladat\"\n        },\n        \"activity\": \"Aktivitás\",\n        \"branches\": \"Branchek\",\n        \"pull_requests\": \"Pull requestek\",\n        \"add\": \"Repository hozzáadása\",\n        \"user_none\": \"Ez az organizáció/felhasználó nem rendelkezik még semmilyen projektekkel\",\n        \"not_allowed\": \"Nincs hozzáférésed ehhez a repositoryhoz\",\n        \"enable\": {\n            \"disabled\": \"Kikapcsolva\",\n            \"success\": \"Repository bekapcsolva\",\n            \"enabled\": \"Már bekapcsolva\",\n            \"enable\": \"Bekapcsolás\"\n        },\n        \"open_in_forge\": \"Repository megnyitása a kódplatformban\"\n    },\n    \"admin\": {\n        \"settings\": {\n            \"users\": {\n                \"show\": \"Mutasd a felhasználokat\",\n                \"cancel\": \"Mégse\",\n                \"save\": \"Felhasználó mentése\",\n                \"add\": \"Felhasználó hozzáadása\",\n                \"deleted\": \"Felhasználó törölve\",\n                \"created\": \"Felhasználó létrehozva\",\n                \"saved\": \"Felhasználó mentve\",\n                \"delete_user\": \"Felhasználó törlése\",\n                \"edit_user\": \"Felhasználó szerkesztése\",\n                \"desc\": \"Regisztrált felhasználók a szerveren.\",\n                \"avatar_url\": \"Avatár URL\",\n                \"users\": \"Felhasználók\",\n                \"email\": \"Email\",\n                \"login\": \"Bejelentkezés\",\n                \"none\": \"Még nincenek felhasználók.\",\n                \"delete_confirm\": \"Biztos, hogy akarod ezt a felhasználót törölni? Minden repository ami a felhasználóhoz tartozik is törlődik.\",\n                \"admin\": {\n                    \"admin\": \"Adminisztrátor\",\n                    \"placeholder\": \"A felhasználó egy adminisztrátor\"\n                }\n            },\n            \"orgs\": {\n                \"orgs\": \"Szervezetek\",\n                \"delete_org\": \"Szervezet törlése\",\n                \"delete_confirm\": \"Biztos, hogy akarod ezt az organizációt törölni? Minden repository ami az organizációhoz tartozik is törlődik.\",\n                \"deleted\": \"Organizáció törölve\",\n                \"view\": \"Organizáció megnézése\",\n                \"desc\": \"Organizációk amelyek repositorykat tart ezen a szerveren.\",\n                \"none\": \"Még nincsenek organizációk.\",\n                \"org_settings\": \"Organizáció beállítások\"\n            },\n            \"queue\": {\n                \"resumed\": \"Feldolgozási sor folytatása\",\n                \"tasks\": \"Feladatok\",\n                \"task_running\": \"Feladat folyamatban\",\n                \"pause\": \"Szüneteltetés\",\n                \"paused\": \"Feldolgozási sor szünteltetve\",\n                \"task_pending\": \"Feladat futtatása függőben\",\n                \"queue\": \"Feldolgozási sor\",\n                \"desc\": \"Feladatok amelyek az agent végrehajtására várnak.\",\n                \"agent\": \"agent\",\n                \"resume\": \"Folytatás\",\n                \"stats\": {\n                    \"waiting_on_deps_count\": \"Függőségekre vár\",\n                    \"completed_count\": \"Elvégzett feladatok\",\n                    \"worker_count\": \"Szabad\",\n                    \"running_count\": \"Fut\",\n                    \"pending_count\": \"Függőben\"\n                },\n                \"task_waiting_on_deps\": \"A feladat a függőségekre vár\",\n                \"waiting_for\": \"várakozás a\"\n            },\n            \"agents\": {\n                \"capacity\": {\n                    \"capacity\": \"Kapacitás\",\n                    \"desc\": \"Az agent által maximum párhuzamosan futtatható pipelineok száma.\",\n                    \"badge\": \"kapacitás\"\n                },\n                \"name\": {\n                    \"name\": \"Név\",\n                    \"placeholder\": \"Az Agent neve\"\n                },\n                \"delete_confirm\": \"Biztos, hogy törölni akarod ezt az agentet? Mostantól, nem fog tudni kommunikálni a szerverrel.\",\n                \"never\": \"Soha\",\n                \"last_contact\": \"Legutóbbi kommunikáció\",\n                \"edit_agent\": \"Agent szerkesztése\",\n                \"agents\": \"Agentek\",\n                \"none\": \"Még nincsenek agentek.\",\n                \"id\": \"ID\",\n                \"add\": \"Agent hozzáadása\",\n                \"show\": \"Agentek mutatása\",\n                \"saved\": \"Agent elmentve\",\n                \"delete_agent\": \"Agent törlése\",\n                \"custom_labels\": {\n                    \"custom_labels\": \"Egyedi bélyegek\",\n                    \"desc\": \"Az egyedi bélyegek az agent adminisztrátorja állítja be az agent elindításánál.\"\n                },\n                \"desc\": \"Agentek amelyek ehhez a szerverhez vannak regisztrálva.\",\n                \"save\": \"Agent mentése\",\n                \"created\": \"Agent létrehozva\",\n                \"deleted\": \"Agent törölve\",\n                \"no_schedule\": {\n                    \"name\": \"Agent kikapcsolása\",\n                    \"placeholder\": \"Az agent megakadályozása, hogy új feladatokat vegyen fel\"\n                },\n                \"token\": \"Token\",\n                \"platform\": {\n                    \"platform\": \"Platform\",\n                    \"badge\": \"platform\"\n                },\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"version\": \"Verzió\",\n                \"org\": {\n                    \"badge\": \"Org\"\n                }\n            },\n            \"repos\": {\n                \"view\": \"Repository megnézése\",\n                \"repos\": \"Repositoryk\",\n                \"desc\": \"Repositoryk amik aktiválva vagy aktiválva voltak ezen a szerveren.\",\n                \"none\": \"Még nincsenek repositoryk.\",\n                \"settings\": \"Repository beállítások\",\n                \"disabled\": \"Kikapcsolva\",\n                \"repair\": {\n                    \"repair\": \"Minden javítása\",\n                    \"success\": \"Repositoryk javítva\"\n                }\n            },\n            \"not_allowed\": \"Nincs hozzá\",\n            \"secrets\": {\n                \"desc\": \"A globális titkos változók az összes repository összes pipelinejában használhatóak.\",\n                \"warning\": \"Ezek a titkos változók minden felhasználónak elérhetőek.\"\n            },\n            \"registries\": {\n                \"warning\": \"Ezek a regisztry hozzáférési adatok elérhetőek az összes felhasználónak.\",\n                \"desc\": \"Lehetőség van globális regisztry hozzáférési adatok hozzáadására,hogy minden pipelinenak hozzáférése legyen privát Docker környezet imagekhez.\"\n            }\n        }\n    },\n    \"cancel\": \"Mégse\",\n    \"login\": \"Bejelentkezés\",\n    \"internal_error\": \"Valamilyen belső hiba történt\",\n    \"info\": \"Információ\",\n    \"registration_closed\": \"A regisztráció zárva\",\n    \"repositories\": \"Repository-k\",\n    \"documentation_for\": \"{topic} dokumentációja\",\n    \"docs\": \"Dokumentáció\",\n    \"repos\": \"Repók\",\n    \"api\": \"API\",\n    \"pipeline_feed\": \"Pipeline felület\",\n    \"empty_list\": \"Nem található {entity}!\",\n    \"not_found\": {\n        \"not_found\": \"Hoppá 404, vagy valamit elrontottunk vagy te írtál el valamit :-/\",\n        \"back_home\": \"Vissza a kezdőlapra\"\n    },\n    \"secrets\": {\n        \"save\": \"Titkos változó mentése\",\n        \"show\": \"Titkos változók mutatása\",\n        \"add\": \"Titkos változó hozzáadása\",\n        \"name\": \"Név\",\n        \"value\": \"Érték\",\n        \"delete\": \"Titkos változó törlése\",\n        \"events\": {\n            \"events\": \"A következő eseményeknél elérhető\",\n            \"warning\": \"Ha a Pull Requesteknek is engedélyt adsz, akkor esély van rá, hogy a rosszakarók ellophassák a titkos változóid értékeit egy rosszindulatú Pull Requestel.\"\n        },\n        \"edit\": \"Titkos változó szerkesztése\",\n        \"plugins\": {\n            \"images\": \"Csak a következő pluginoknak elérhető\",\n            \"desc\": \"Azon pluginok listája amelyeknél ez a titkos változó elérhető. Hagyd üresen ha minden pluginnak és normális lépésnek elérhető legyen.\"\n        },\n        \"secrets\": \"Titkos változók\",\n        \"deleted\": \"Titkos változó törölve\",\n        \"delete_confirm\": \"Biztos, hogy törölni akarod ezt a titkos változót?\",\n        \"saved\": \"Titkos változó elmentve\",\n        \"desc\": \"A titkos változók minden a repositoryn futó pipelineban elérhetőek.\",\n        \"none\": \"Még nincsenek titkos változók.\",\n        \"created\": \"Titkos változó létrehozva\"\n    },\n    \"default\": \"alapértelmezett\",\n    \"no_search_results\": \"Nincs találat\",\n    \"all_repositories\": \"Összes Repository\",\n    \"org_access_denied\": \"Nincs hozzáférésed ehhez az organizáció megnézéséhez\",\n    \"user\": {\n        \"settings\": {\n            \"general\": {\n                \"theme\": {\n                    \"auto\": \"Automatikus\",\n                    \"light\": \"Világos\",\n                    \"theme\": \"Téma\",\n                    \"dark\": \"Sötét\"\n                },\n                \"general\": \"Általános\",\n                \"language\": \"Nyelv\"\n            },\n            \"cli_and_api\": {\n                \"download_cli\": \"CLI letöltése\",\n                \"reset_token\": \"Token visszaállítása\",\n                \"swagger_ui\": \"Swagger felület\",\n                \"token\": \"Saját Hozzáférési Token\",\n                \"api_usage\": \"Példa az API használatára\",\n                \"desc\": \"Saját Hozzáférési Token (PAT), CLI és API használat\",\n                \"cli_usage\": \"Példa a CLI használatára\",\n                \"cli_and_api\": \"CLI & API\"\n            },\n            \"registries\": {\n                \"desc\": \"Lehetőség van felhasználói regisztry hozzáférési adatok hozzáadására,hogy minden saját pipelinenak hozzáférése legyen privát Docker környezet imagekhez.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agentek amelyek a fiókod repositoryjaihoz vannak regisztrálva.\"\n            },\n            \"settings\": \"Felhasználói beállítások\",\n            \"secrets\": {\n                \"desc\": \"A felhasználó titkos változójai minden a felhasználó tulajdonában álló repositoryn futó pipelinejaiban felhasználható.\"\n            }\n        }\n    },\n    \"invalid_state\": \"Az OAuth helyzete érvénytelen\",\n    \"registries\": {\n        \"add\": \"Regisztry hozzáadása\",\n        \"delete_confirm\": \"Biztos, hogy törölni akarod ezt a regisztryt?\",\n        \"created\": \"Regisztry hozzáférési adatok létrehozva\",\n        \"saved\": \"Regisztry hozzáférési adatok elmentve\",\n        \"deleted\": \"Regisztry hozzáférési adatok törölve\",\n        \"credentials\": \"Registry hozzáférési adatok\",\n        \"desc\": \"Lehetőség van regisztry hozzáférési adatok hozzáadására,hogy minden pipelinenak hozzáférése legyen privát Docker környezet imagekhez.\",\n        \"none\": \"Nincsenek még regisztry hozzáférési adatok.\",\n        \"address\": {\n            \"address\": \"Cím\",\n            \"desc\": \"Regisztry Cím (pl.: docker.io)\"\n        },\n        \"show\": \"Regisztryk mutatása\",\n        \"registries\": \"Registryk\",\n        \"save\": \"Regisztry mentése\",\n        \"view\": \"Regisztry megnézése\",\n        \"edit\": \"Regisztry szerkesztése\",\n        \"delete\": \"Regisztry törlése\"\n    },\n    \"require_approval\": {\n        \"require_approval_for\": \"Engedélyezési feltételek\",\n        \"desc\": \"Előzz meg rosszindulatú pipelineok lefutását, amelyek titkos változókat fedhetnek fel vagy káros feladatokat futtathatnak, azáltal, hogy jóváhagyod őket a végrehajtás előtt.\",\n        \"none_desc\": \"Minden esemény előidéz pipelineokat, beleértve a Pull Requesteket. Ez a beállítás veszélyes lehet és csak privát munkamenetekhez ajánlott.\",\n        \"forks\": \"Pull Request egy forkolt repositoryból\",\n        \"pull_requests\": \"Mindegyik Pull Request\",\n        \"none\": \"Nincs\",\n        \"all_events\": \"Az összes esemény a kódplatformtól\",\n        \"allowed_users\": {\n            \"desc\": \"Pipelineok amelyeket a következő felhasználók hoztak létre, soha nem szükséges hozzájuk engedély.\",\n            \"allowed_users\": \"Engedélyezett felhasználók\"\n        }\n    },\n    \"global_level_secret\": \"globális titkos változó\",\n    \"org\": {\n        \"settings\": {\n            \"agents\": {\n                \"desc\": \"Regisztrált agentek ehhez az organizációhoz.\"\n            },\n            \"not_allowed\": \"Nincs jogosultságod ennek az organizáció beállításaihoz\",\n            \"secrets\": {\n                \"desc\": \"Az organizáció titkos változói elérhetőek az organizáció összes repositoryhoz tartózó pipelinejainak.\"\n            },\n            \"registries\": {\n                \"desc\": \"Az organizáció hozzáférési adatai tárolója hozzáadható, hogy privát Docker image-eket használhassanak az organizáció összes pipeline-jában.\"\n            }\n        }\n    },\n    \"welcome\": \"Üdvözöllek a Woodpeckerben\",\n    \"running_version\": \"A Woodpecker {0}-t futtatod\",\n    \"oauth_error\": \"Hiba történt az OAuth szolgáltatóval való kommunikálás közben\",\n    \"login_with\": \"Belépés {forge}-al\",\n    \"errors\": {\n        \"not_found\": \"A szerver nem találja a kért objektumot\"\n    },\n    \"time\": {\n        \"not_started\": \"Nem indult még el\",\n        \"just_now\": \"Most éppen\"\n    },\n    \"update_woodpecker\": \"Kérlek frissítsd a Woodpeckered {0}-ra\",\n    \"org_level_secret\": \"organizáció titkos változó\",\n    \"login_to_cli\": \"Belépés a CLIbe\",\n    \"login_to_cli_description\": \"Ha folytatod, be leszel léptetve a CLIbe.\",\n    \"abort\": \"Megszakítás\",\n    \"cli_login_success\": \"Sikeres belépés a CLIbe\",\n    \"cli_login_failed\": \"Sikertelen belépés a CLIbe\",\n    \"cli_login_denied\": \"Belépés a CLIbe megtagadva\",\n    \"return_to_cli\": \"Mostmár bezárhatod ezt az ablakot és visszaléphetsz a CLIbe.\",\n    \"settings\": \"Beállítások\",\n    \"access_denied\": \"Nincs hozzáférésed ehhez a munkamenethez\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/id.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Tambahkan agen\",\n                \"agents\": \"Agen\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"kapasitas\",\n                    \"capacity\": \"Kapasitas\",\n                    \"desc\": \"Jumlah maksimal jalur pipa paralel yang dijalankan oleh agen ini.\"\n                },\n                \"created\": \"Agen dibuat\",\n                \"delete_agent\": \"Hapus agen\",\n                \"delete_confirm\": \"Apakah Anda ingin menghapus agen ini. Itu tidak akan terhubung lagi ke server.\",\n                \"deleted\": \"Agen dihapus\",\n                \"desc\": \"Agen terdaftar untuk server\",\n                \"edit_agent\": \"Sunting agen\",\n                \"id\": \"ID\",\n                \"last_contact\": \"Hubungan terakhir\",\n                \"name\": {\n                    \"name\": \"Nama\",\n                    \"placeholder\": \"Nama agen\"\n                },\n                \"never\": \"Tidak pernah\",\n                \"no_schedule\": {\n                    \"name\": \"Nonaktifkan agen\",\n                    \"placeholder\": \"Hentikan agen untuk mengambil tugas baru\"\n                },\n                \"none\": \"Belum ada agen.\",\n                \"platform\": {\n                    \"badge\": \"platform\",\n                    \"platform\": \"Platform\"\n                },\n                \"save\": \"Simpan agen\",\n                \"saved\": \"Agen disimpan\",\n                \"show\": \"Tampilkan agen\",\n                \"token\": \"Token\",\n                \"version\": \"Versi\"\n            },\n            \"not_allowed\": \"Anda tidak diperbolehkan untuk mengakses pengaturan peladen\",\n            \"orgs\": {\n                \"delete_confirm\": \"Apakah Anda benar-benar ingin menghapus organisasi ini? Ini juga akan menghapus semua repositori yang dimiliki oleh organisasi ini.\",\n                \"delete_org\": \"Hapus organisasi\",\n                \"deleted\": \"Organisasi dihapus\",\n                \"desc\": \"Organisasi yang memiliki repositori di server ini\",\n                \"none\": \"Belum ada organisasi.\",\n                \"org_settings\": \"Pengaturan organisasi\",\n                \"orgs\": \"Organisasi\",\n                \"view\": \"Tampilkan organisasi\"\n            },\n            \"queue\": {\n                \"agent\": \"agen\",\n                \"desc\": \"Tugas tertunda yang akan dilakukan oleh agen\",\n                \"pause\": \"Jeda\",\n                \"paused\": \"Antrean dijeda\",\n                \"queue\": \"Antrean\",\n                \"resume\": \"Lanjutkan\",\n                \"resumed\": \"Antrean dilanjutkan\",\n                \"stats\": {\n                    \"completed_count\": \"Tugas yang Selesai\",\n                    \"pending_count\": \"Menunggu\",\n                    \"running_count\": \"Berjalan\",\n                    \"waiting_on_deps_count\": \"Menunggu ketergantungan\",\n                    \"worker_count\": \"Bebas\"\n                },\n                \"task_pending\": \"Tugas tertunda\",\n                \"task_running\": \"Tugas sedang berjalan\",\n                \"task_waiting_on_deps\": \"Tugas sedang menunggu ketergantungan\",\n                \"tasks\": \"Tugas\",\n                \"waiting_for\": \"menunggu\"\n            },\n            \"repos\": {\n                \"desc\": \"Repositori yang sudah atau sebelumnya diaktifkan di server ini\",\n                \"disabled\": \"Dinonaktifkan\",\n                \"none\": \"Belum ada repositori.\",\n                \"repair\": {\n                    \"repair\": \"Perbaiki semua\",\n                    \"success\": \"Repositori diperbaiki\"\n                },\n                \"repos\": \"Repositori\",\n                \"settings\": \"Pengaturan repositori\",\n                \"view\": \"Tampilkan repositori\"\n            },\n            \"secrets\": {\n                \"add\": \"Tambahkan rahasia\",\n                \"created\": \"Rahasia global dibuat\",\n                \"deleted\": \"Rahasia global dihapus\",\n                \"desc\": \"Rahasia global dapat diberikan untuk semua langkah jalur pipa repositori individu saat berjalan sebagai variabel lingkungan.\",\n                \"events\": {\n                    \"events\": \"Tersedia pada peristiwa berikut\",\n                    \"pr_warning\": \"Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda.\"\n                },\n                \"images\": {\n                    \"desc\": \"Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra\",\n                    \"images\": \"Tersedia untuk citra berikut\"\n                },\n                \"name\": \"Nama\",\n                \"none\": \"Belum ada rahasia global.\",\n                \"plugins_only\": \"Hanya tersedia untuk plugin\",\n                \"save\": \"Simpan rahasia\",\n                \"saved\": \"Rahasia global disimpan\",\n                \"secrets\": \"Rahasia\",\n                \"show\": \"Tampilkan rahasia\",\n                \"value\": \"Nilai\",\n                \"warning\": \"Rahasia ini akan tersedia untuk pengguna peladen.\"\n            },\n            \"settings\": \"Pengaturan\",\n            \"users\": {\n                \"add\": \"Tambahkan pengguna\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Pengguna adalah admin\"\n                },\n                \"avatar_url\": \"URL Avatar\",\n                \"cancel\": \"Batal\",\n                \"created\": \"Pengguna dibuat\",\n                \"delete_confirm\": \"Apakah Anda ingin menghapus pengguna ini? Ini juga akan menghapus semua repositori yang dimiliki oleh pengguna ini.\",\n                \"delete_user\": \"Hapus pengguna\",\n                \"deleted\": \"Pengguna dihapus\",\n                \"desc\": \"Pengguna terdaftar untuk server ini\",\n                \"edit_user\": \"Sunting pengguna\",\n                \"email\": \"Surel\",\n                \"login\": \"Masuk\",\n                \"none\": \"Belum ada pengguna.\",\n                \"save\": \"Simpan pengguna\",\n                \"saved\": \"Pengguna disimpan\",\n                \"show\": \"Tampilkan pengguna\",\n                \"users\": \"Pengguna\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Kembali\",\n    \"cancel\": \"Batal\",\n    \"default\": \"bawaan\",\n    \"docs\": \"Dokumentasi\",\n    \"documentation_for\": \"Dokumentasi untuk \\\"{topic}\\\"\",\n    \"empty_list\": \"{entity} tidak ditemukan!\",\n    \"errors\": {\n        \"not_found\": \"Server tidak dapat mencari objek yang diminta\"\n    },\n    \"global_level_secret\": \"rahasia global\",\n    \"info\": \"Info\",\n    \"login\": \"Masuk\",\n    \"logout\": \"Keluar\",\n    \"not_found\": {\n        \"back_home\": \"Kembali ke beranda\",\n        \"not_found\": \"Aduh 404, mungkin kami membuat sesuatu rusak atau Anda salah ketik :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Anda tidak diperbolehkan untuk mengakses pengaturan organisasi ini\",\n            \"secrets\": {\n                \"add\": \"Tambahkan rahasia\",\n                \"created\": \"Rahasia organisasi dibuat\",\n                \"deleted\": \"Rahasia organisasi dihapus\",\n                \"desc\": \"Rahasia organisasi dapat diberikan ke semua langkah jalur pipa repositori organisasi individu saat berjalan sebagai variabel lingkungan.\",\n                \"events\": {\n                    \"events\": \"Tersedia pada peristiwa berikut\",\n                    \"pr_warning\": \"Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda.\"\n                },\n                \"images\": {\n                    \"desc\": \"Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra\",\n                    \"images\": \"Tersedia untuk citra berikut\"\n                },\n                \"name\": \"Nama\",\n                \"none\": \"Belum ada rahasia organisasi.\",\n                \"plugins_only\": \"Hanya tersedia untuk plugin\",\n                \"save\": \"Simpan rahasia\",\n                \"saved\": \"Rahasia organisasi disimpan\",\n                \"secrets\": \"Rahasia\",\n                \"show\": \"Tampilkan rahasia\",\n                \"value\": \"Nilai\"\n            },\n            \"settings\": \"Pengaturan\"\n        }\n    },\n    \"org_level_secret\": \"rahasia organisasi\",\n    \"password\": \"Kata sandi\",\n    \"pipeline_feed\": \"Umpan jalur pipa\",\n    \"repo\": {\n        \"activity\": \"Aktivitas\",\n        \"add\": \"Tambahkan repositori\",\n        \"branches\": \"Cabang\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Lingkungan sasaran peluncuran\",\n            \"title\": \"Picu perisitiwa peluncuran untuk jalur pipa #{pipelineId}\",\n            \"trigger\": \"Luncurkan\",\n            \"variables\": {\n                \"add\": \"Tambahkan variabel\",\n                \"desc\": \"Tetapkan variabel tambahan untuk digunakan dalam jalur pipa Anda. Variabel dengan nama yang sama akan ditimpa.\",\n                \"name\": \"Nama variabel\",\n                \"title\": \"Variabel jalur pipa tambahan\",\n                \"value\": \"Nilai variabel\"\n            }\n        },\n        \"enable\": {\n            \"disabled\": \"Nonaktif\",\n            \"enable\": \"Aktifkan\",\n            \"enabled\": \"Sudah diaktifkan\",\n            \"list_reloaded\": \"Daftar repositori dimuat ulang\",\n            \"reload\": \"Muat ulang repositori\",\n            \"success\": \"Repositori diaktifkan\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Pilih cabang\",\n            \"title\": \"Picu sebuah jalur pipa\",\n            \"trigger\": \"Jalankan jalur pipa\",\n            \"variables\": {\n                \"add\": \"Tambahkan variabel\",\n                \"desc\": \"Tetapkan variabel tambahan untuk digunakan dalam jalur pipa Anda. Variabel dengan nama yang sama akan ditimpa.\",\n                \"name\": \"Nama variabel\",\n                \"title\": \"Variabel jalur pipa tambahan\",\n                \"value\": \"Nilai variabel\"\n            }\n        },\n        \"not_allowed\": \"Anda tidak diperbolehkan untuk mengakses repositori ini\",\n        \"open_in_forge\": \"Buka Repositori dalam Sistem Kendali Versi\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Batalkan\",\n                \"cancel_success\": \"Jalur pipa dibatalkan\",\n                \"canceled\": \"Langkah ini telah dibatalkan.\",\n                \"deploy\": \"Luncurkan\",\n                \"log_auto_scroll\": \"Gulir ke bawah secara otomatis\",\n                \"log_auto_scroll_off\": \"Matikan pengguliran otomatis\",\n                \"log_download\": \"Unduh\",\n                \"restart\": \"Mulai ulang\",\n                \"restart_success\": \"Jalur pipa dimulai ulang\"\n            },\n            \"config\": \"Konfigurasi\",\n            \"errors\": \"Kesalahan ({count})\",\n            \"event\": {\n                \"cron\": \"Kron\",\n                \"deploy\": \"Luncur\",\n                \"manual\": \"Manual\",\n                \"pr\": \"Permintaan Penarikan\",\n                \"push\": \"Dorongan\",\n                \"tag\": \"Tag\"\n            },\n            \"exit_code\": \"kode keluar {exitCode}\",\n            \"files\": \"Berkas yang diubah ({files})\",\n            \"loading\": \"Memuat…\",\n            \"log_download_error\": \"Terjadi sebuah kesalahan saat mengunduh berkas catatan\",\n            \"log_title\": \"Catatan Langkah\",\n            \"no_files\": \"Tidak ada berkas yang telah diubah.\",\n            \"no_pipeline_steps\": \"Tidak ada langkah jalur pipa yang tersedia!\",\n            \"no_pipelines\": \"Belum ada jalur pipa yang dimulai.\",\n            \"pipeline\": \"Jalur pipa #{pipelineId}\",\n            \"pipelines_for\": \"Jalur pipa untuk cabang \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Jalur pipa untuk permintaan penarikan #{index}\",\n            \"protected\": {\n                \"approve\": \"Setujui\",\n                \"approve_success\": \"Jalur pipa disetujui\",\n                \"awaits\": \"Jalur pipa ini menunggu untuk disetujui oleh seorang pemelihara!\",\n                \"decline\": \"Tolak\",\n                \"decline_success\": \"Jalur pipa ditolak\",\n                \"declined\": \"Jalur pipa ini telah ditolak!\",\n                \"review\": \"Tinjau perubahan\"\n            },\n            \"show_errors\": \"Tampilkan kesalahan\",\n            \"status\": {\n                \"blocked\": \"diblokir\",\n                \"declined\": \"ditolak\",\n                \"error\": \"terjadi kesalahan\",\n                \"failure\": \"gagal\",\n                \"killed\": \"dibatalkan\",\n                \"pending\": \"menunggu\",\n                \"running\": \"berjalan\",\n                \"skipped\": \"dilewati\",\n                \"started\": \"dimulai\",\n                \"status\": \"Status: {status}\",\n                \"success\": \"berhasil\"\n            },\n            \"step_not_started\": \"Langkah ini belum dijalankan.\",\n            \"tasks\": \"Tugas\",\n            \"warnings\": \"Peringatan ({count})\",\n            \"we_got_some_errors\": \"Aduh, kami mendapatkan beberapa kesalahan!\"\n        },\n        \"pull_requests\": \"Permintaan penarikan\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Tindakan\",\n                \"delete\": {\n                    \"confirm\": \"Semua data akan hilang setelah tindakan ini!!!\\n\\nApakah Anda benar-benar ingin melakukan ini?\",\n                    \"delete\": \"Hapus repositori\",\n                    \"success\": \"Repositori dihapus\"\n                },\n                \"disable\": {\n                    \"disable\": \"Nonaktifkan repositori\",\n                    \"success\": \"Repositori dinonaktifkan\"\n                },\n                \"enable\": {\n                    \"enable\": \"Aktifkan repositori\",\n                    \"success\": \"Repositori diaktifkan\"\n                },\n                \"repair\": {\n                    \"repair\": \"Perbaiki repositori\",\n                    \"success\": \"Repositori diperbaiki\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Lencana\",\n                \"branch\": \"Cabang\",\n                \"type\": \"Sintaks\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Tambahkan cron\",\n                \"branch\": {\n                    \"placeholder\": \"Cabang (menggunakan cabang bawaan jika kosong)\",\n                    \"title\": \"Cabang\"\n                },\n                \"created\": \"Kron dibuat\",\n                \"crons\": \"Cron\",\n                \"delete\": \"Hapus cron\",\n                \"deleted\": \"Kron dihapus\",\n                \"desc\": \"Pekerja cron dapat digunakan untuk memicu jalur pipa pada waktu yang ditentukan.\",\n                \"edit\": \"Sunting cron\",\n                \"name\": {\n                    \"name\": \"Nama\",\n                    \"placeholder\": \"Nama pekerja cron\"\n                },\n                \"next_exec\": \"Eksekusi berikutnya\",\n                \"none\": \"Belum ada cron.\",\n                \"not_executed_yet\": \"Belum dieksekusi\",\n                \"run\": \"Jalankan sekarang\",\n                \"save\": \"Simpan cron\",\n                \"saved\": \"Kron disimpan\",\n                \"schedule\": {\n                    \"placeholder\": \"Jadwal\",\n                    \"title\": \"Jadwal (berdasarkan UTC)\"\n                },\n                \"show\": \"Tampilkan cron\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Perbolehkan Permintaan Penarikan\",\n                    \"desc\": \"Jalur pipa dapat berjalan pada permintaan penarikan.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Batalkan jalur pipa sebelumnya\",\n                    \"desc\": \"Aktifkan untuk membatalkan jalur pipa yang menunggu dan yang berjalan dari peristiwa dan konteks yang sama sebelum memulai picuan yang baru.\"\n                },\n                \"general\": \"Umum\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Hanya masukkan kredensial netrc ke kontainer yang terpercaya (disarankan).\",\n                    \"netrc_only_trusted\": \"Hanya masukkan kredensial netrc ke kontainer yang terpercaya\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Secara bawaan: .woodpecker/*.{'{yaml,yml}'} → .woodpecker.yaml → .woodpecker.yml\",\n                    \"desc\": \"Jalur ke konfigurasi jalur pipa Anda (misalnya {0}). Folder seharusnya berakhir dengan sebuah {1}.\",\n                    \"desc_path_example\": \"jalur/saya/\",\n                    \"path\": \"Jalur pipa\"\n                },\n                \"project\": \"Pengaturan proyek\",\n                \"protected\": {\n                    \"desc\": \"Setiap jalur pipa harus disetujui sebelum dijalankan.\",\n                    \"protected\": \"Dilindungi\"\n                },\n                \"save\": \"Simpan pengaturan\",\n                \"success\": \"Pengaturan repositori diperbarui\",\n                \"timeout\": {\n                    \"minutes\": \"menit\",\n                    \"timeout\": \"Waktu habis\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Kontainer jalur pipa dasar mendapatkan akses ke kemampuan yang ditingkatkan seperti memasang volume.\",\n                    \"trusted\": \"Dipercayai\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Hanya pengguna yang terotentikasi dengan instansi Woodpecker dapat melihat proyek ini.\",\n                        \"internal\": \"Internal\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Hanya Anda dan pemilik repositori lainnya dapat melihat proyek ini.\",\n                        \"private\": \"Pribadi\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Setiap pengguna dapat melihat proyek Anda tanpa harus masuk.\",\n                        \"public\": \"Publik\"\n                    },\n                    \"visibility\": \"Keterlihatan proyek\"\n                }\n            },\n            \"not_allowed\": \"Anda tidak diperbolehkan untuk mengakses pengaturan repositori ini\",\n            \"registries\": {\n                \"add\": \"Tambahkan registri\",\n                \"address\": {\n                    \"address\": \"Alamat\",\n                    \"placeholder\": \"Alamat registri (mis. docker.io)\"\n                },\n                \"created\": \"Kredensial registri dibuat\",\n                \"credentials\": \"Kredensial registri\",\n                \"delete\": \"Hapus registri\",\n                \"deleted\": \"Kredensial registri dihapus\",\n                \"desc\": \"Kredensial registri dapat ditambahkan untuk menggunakan citra pribadi untuk jalur pipa Anda.\",\n                \"edit\": \"Sunting registri\",\n                \"none\": \"Belum ada kredensial registri.\",\n                \"registries\": \"Registri\",\n                \"save\": \"Simpan registri\",\n                \"saved\": \"Kredensial registri disimpan\",\n                \"show\": \"Tampilkan registri\"\n            },\n            \"secrets\": {\n                \"add\": \"Tambahkan rahasia\",\n                \"created\": \"Rahasia dibuat\",\n                \"delete\": \"Hapus rahasia\",\n                \"delete_confirm\": \"Apakah Anda ingin menghapus rahasia ini?\",\n                \"deleted\": \"Rahasia dihapus\",\n                \"desc\": \"Rahasia dapat diberikan ke langkah jalur pipa individu saat dijalankan sebagai variabel lingkungan.\",\n                \"edit\": \"Sunting rahasia\",\n                \"events\": {\n                    \"events\": \"Tersedia pada peristiwa berikut\",\n                    \"pr_warning\": \"Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda.\"\n                },\n                \"images\": {\n                    \"desc\": \"Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra\",\n                    \"images\": \"Tersedia untuk citra berikut\"\n                },\n                \"name\": \"Nama\",\n                \"none\": \"Belum ada rahasia.\",\n                \"plugins_only\": \"Hanya tersedia untuk plugin\",\n                \"save\": \"Simpan rahasia\",\n                \"saved\": \"Rahasia disimpan\",\n                \"secrets\": \"Rahasia\",\n                \"show\": \"Tampilkan rahasia\",\n                \"value\": \"Nilai\"\n            },\n            \"settings\": \"Pengaturan\"\n        },\n        \"user_none\": \"Organisasi/pengguna belum memiliki proyek apa pun.\"\n    },\n    \"repos\": \"Repo\",\n    \"repositories\": \"Repositori\",\n    \"running_version\": \"Anda sedang menjalankan Woodpecker {0}\",\n    \"search\": \"Cari…\",\n    \"time\": {\n        \"days_short\": \"h\",\n        \"hours_short\": \"j\",\n        \"min_short\": \"mnt\",\n        \"not_started\": \"belum dimulai\",\n        \"sec_short\": \"dtk\",\n        \"template\": \"BBB H, TTTT, JJ:mm z\",\n        \"weeks_short\": \"m\"\n    },\n    \"unknown_error\": \"Terjadi sebuah kesalahan yang tidak diketahui\",\n    \"update_woodpecker\": \"Mohon tingkatkan server Woodpecker Anda ke {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Anda tidak diperbolehkan untuk masuk\",\n        \"internal_error\": \"Terjadi beberapa kesalahan internal\",\n        \"oauth_error\": \"Terjadi kesalahan saat mengotentikasi dengan penyedia OAuth\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Contoh Penggunaan API\",\n                \"cli_usage\": \"Contoh Penggunaan CLI\",\n                \"desc\": \"Penggunaan Token Akses Pribadi dan API\",\n                \"dl_cli\": \"Unduh CLI\",\n                \"reset_token\": \"atur ulang token\",\n                \"shell_setup\": \"Penyiapan shell\",\n                \"shell_setup_before\": \"lakukan tahap penyiapan shell sebelumnya\",\n                \"swagger_ui\": \"UI Swagger\",\n                \"token\": \"Token Akses Pribadi\"\n            },\n            \"general\": {\n                \"general\": \"Umum\",\n                \"language\": \"Bahasa\",\n                \"theme\": {\n                    \"auto\": \"Otomatis\",\n                    \"dark\": \"Gelap\",\n                    \"light\": \"Terang\",\n                    \"theme\": \"Tema\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Tambahkan rahasia\",\n                \"created\": \"Rahasia pengguna dibuat\",\n                \"deleted\": \"Rahasia pengguna dihapus\",\n                \"desc\": \"Rahasia pengguna dapat diteruskan ke semua jalur pipa individu di semua repositori pengguna saat dijalankan sebagai variabel lingkungan.\",\n                \"events\": {\n                    \"events\": \"Tersedia di peristiwa berikut\",\n                    \"pr_warning\": \"Harap berhati-hati dengan opsi ini karena seseorang dapat mengirimkan permintaan penarikan berbahaya yang dapat mengekspos rahasia Anda.\"\n                },\n                \"images\": {\n                    \"desc\": \"Daftar citra di mana rahasia ini tersedia, tinggalkan kosong untuk memperbolehkan semua citra\",\n                    \"images\": \"Tersedia untuk citra berikut\"\n                },\n                \"name\": \"Nama\",\n                \"none\": \"Belum ada rahasia pengguna.\",\n                \"plugins_only\": \"Hanya tersedia untuk plugin\",\n                \"save\": \"Simpan rahasia\",\n                \"saved\": \"Rahasia pengguna disimpan\",\n                \"secrets\": \"Rahasia\",\n                \"show\": \"Tampilkan rahasia\",\n                \"value\": \"Nilai\"\n            },\n            \"settings\": \"Pengaturan Pengguna\"\n        }\n    },\n    \"username\": \"Nama pengguna\",\n    \"welcome\": \"Selamat datang di Woodpecker\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/it.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"queue\": {\n                \"agent\": \"agente\",\n                \"stats\": {\n                    \"completed_count\": \"Attività Completate\",\n                    \"worker_count\": \"Liberi\",\n                    \"running_count\": \"In esecuzione\",\n                    \"waiting_on_deps_count\": \"In attesa delle dipendenze\",\n                    \"pending_count\": \"In attesa\"\n                },\n                \"waiting_for\": \"in attesa di\",\n                \"pause\": \"Sospendi\",\n                \"resume\": \"Riprendi\",\n                \"resumed\": \"Coda ripresa\",\n                \"paused\": \"Coda sospesa\",\n                \"tasks\": \"Attività\",\n                \"task_running\": \"Attività in esecuzione\",\n                \"task_waiting_on_deps\": \"Attività in attesa delle dipendenze\",\n                \"desc\": \"Attività in attesa di essere eseguiti dagli agenti.\",\n                \"queue\": \"Coda\",\n                \"task_pending\": \"Attività in attesa\"\n            },\n            \"users\": {\n                \"add\": \"Aggiungi utente\",\n                \"admin\": {\n                    \"admin\": \"Amministatore\",\n                    \"placeholder\": \"L'utente è un amministratore\"\n                },\n                \"created\": \"Utente creato\",\n                \"delete_confirm\": \"Vuoi davvero eliminare questo utente? Ciò eliminerà anche tutti i repository di proprietà di questo utente.\",\n                \"delete_user\": \"Elimina utente\",\n                \"deleted\": \"Utente eliminato\",\n                \"edit_user\": \"Modifica utente\",\n                \"none\": \"Ancora nessun utente.\",\n                \"saved\": \"Utente salvato\",\n                \"show\": \"Mostra utenti\",\n                \"users\": \"Utenti\",\n                \"desc\": \"Utenti registrati su questo server.\",\n                \"login\": \"Accesso\",\n                \"email\": \"E-mail\",\n                \"avatar_url\": \"URL Avatar\",\n                \"cancel\": \"Annulla\",\n                \"save\": \"Salva utente\"\n            },\n            \"agents\": {\n                \"agents\": \"Agenti\",\n                \"desc\": \"Agenti registrati su questo server.\",\n                \"none\": \"Ancora nessun agente.\",\n                \"id\": \"ID\",\n                \"add\": \"Aggiungi agente\",\n                \"save\": \"Salva agente\",\n                \"show\": \"Mostra agenti\",\n                \"created\": \"Agente creato\",\n                \"saved\": \"Agente salvato\",\n                \"deleted\": \"Agente eliminato\",\n                \"name\": {\n                    \"name\": \"Nome\",\n                    \"placeholder\": \"Nome dell'agente\"\n                },\n                \"no_schedule\": {\n                    \"name\": \"Disabilita agente\",\n                    \"placeholder\": \"Impedisci all'agente di accettare nuove attività\"\n                },\n                \"token\": \"Token\",\n                \"platform\": {\n                    \"platform\": \"Piattaforma\",\n                    \"badge\": \"piattaforma\"\n                },\n                \"version\": \"Versione\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"Capacità\",\n                    \"desc\": \"Il numero massimo di pipeline parallele eseguite da questo agente.\",\n                    \"badge\": \"capacità\"\n                },\n                \"last_contact\": \"Ultimo contatto\",\n                \"never\": \"Mai\",\n                \"delete_confirm\": \"Vuoi davvero eliminare questo agente? Non sarà più in grado di connettersi al server.\",\n                \"edit_agent\": \"Modifica agente\",\n                \"delete_agent\": \"Elimina agente\",\n                \"org\": {\n                    \"badge\": \"org\"\n                },\n                \"custom_labels\": {\n                    \"custom_labels\": \"Etichette Personalizzate\",\n                    \"desc\": \"Le etichette personalizzate impostate dall'amministratore dell'agente all'avvio dell'agente.\"\n                }\n            },\n            \"orgs\": {\n                \"desc\": \"Organizzazioni che possiedono repository su questo server.\",\n                \"none\": \"Ancora nessuna organizzazione.\",\n                \"delete_org\": \"Elimina organizzazione\",\n                \"deleted\": \"Organizzazione eliminata\",\n                \"org_settings\": \"Impostazioni organizzazione\",\n                \"delete_confirm\": \"Vuoi davvero eliminare questa organizzazione? Ciò eliminerà anche tutti i repository di proprietà di questa organizzazione.\",\n                \"view\": \"Mostra organizzazione\",\n                \"orgs\": \"Organizzazioni\"\n            },\n            \"secrets\": {\n                \"warning\": \"Questi segreti sono disponibili per tutti gli utenti.\",\n                \"desc\": \"I segreti globali possono essere utilizzati nelle pipeline di tutti i repository.\"\n            },\n            \"repos\": {\n                \"settings\": \"Impostazioni repository\",\n                \"repos\": \"Repository\",\n                \"desc\": \"Repository che sono o sono stati attivati su questo server.\",\n                \"none\": \"Ancora nessun repository.\",\n                \"view\": \"Mostra repository\",\n                \"disabled\": \"Disabilitato\",\n                \"repair\": {\n                    \"repair\": \"Ripara tutti\",\n                    \"success\": \"Repository riparati\"\n                }\n            },\n            \"not_allowed\": \"Non hai il permesso di accedere alle impostazioni del server\",\n            \"registries\": {\n                \"desc\": \"È possibile aggiungere credenziali di registro globale per utilizzare immagini private per tutte le pipeline.\",\n                \"warning\": \"Queste credenziali di registro sono disponibili per tutti gli utenti.\"\n            }\n        }\n    },\n    \"back\": \"Indietro\",\n    \"cancel\": \"Annulla\",\n    \"docs\": \"Documentazione\",\n    \"documentation_for\": \"Documentazione per \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Il server non è riuscito a trovare l'oggetto richiesto\"\n    },\n    \"login\": \"Accedi\",\n    \"logout\": \"Esci\",\n    \"not_found\": {\n        \"back_home\": \"Torna alla pagina principale\",\n        \"not_found\": \"Diamine 404, o qualcosa si è rotto o hai scritto male qualcosa :-/\"\n    },\n    \"password\": \"Password\",\n    \"pipeline_feed\": \"Dettagli pipeline\",\n    \"repo\": {\n        \"activity\": \"Attività\",\n        \"add\": \"Aggiungi repository\",\n        \"branches\": \"Rami\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Ambiente di destinazione per il deployment\",\n            \"title\": \"Attiva un deployment per la pipeline corrente #{pipelineId}\",\n            \"trigger\": \"Distribuzione\",\n            \"variables\": {\n                \"add\": \"Aggiungi variabile\",\n                \"desc\": \"Specifica variabili addizionali da usare nella pipeline. Variabili con lo stesso nome saranno sovrascritte.\",\n                \"name\": \"Nome variabile\",\n                \"title\": \"Variabili addizionali della pipeline\",\n                \"value\": \"Valore variabile\",\n                \"delete\": \"Elimina variabile\"\n            },\n            \"enter_task\": \"Attività di distribuzione\"\n        },\n        \"enable\": {\n            \"enable\": \"Abilita\",\n            \"enabled\": \"Già abilitato\",\n            \"list_reloaded\": \"Elenco dei repository ricaricato\",\n            \"reload\": \"Ricarica i repository\",\n            \"success\": \"Repository abilitato\",\n            \"disabled\": \"Disabilitato\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Seleziona ramo\",\n            \"title\": \"Avvia un'esecuzione manuale della pipeline\",\n            \"trigger\": \"Esegui pipeline\",\n            \"variables\": {\n                \"add\": \"Aggiungi variabile\",\n                \"desc\": \"Specifica variabili addizionali da usare nella pipeline. Variabili con lo stesso nome saranno sovrascritte.\",\n                \"name\": \"Nome variabile\",\n                \"title\": \"Variabili aggiuntive per la pipeline\",\n                \"value\": \"Valore variabile\",\n                \"delete\": \"Elimina variabile\"\n            },\n            \"show_pipelines\": \"Mostra pipeline\"\n        },\n        \"not_allowed\": \"Non hai il permesso di accedere a questo repository\",\n        \"open_in_forge\": \"Apri repository sul forge\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Annulla\",\n                \"log_delete\": \"Elimina\",\n                \"log_auto_scroll\": \"Abilita scorrimento automatico\",\n                \"log_auto_scroll_off\": \"Disattiva scorrimento automatico\",\n                \"restart\": \"Riavvia\",\n                \"canceled\": \"Questo step è stato annullato.\",\n                \"cancel_success\": \"Pipeline annullata\",\n                \"restart_success\": \"Pipeline riavviata\",\n                \"log_download\": \"Scarica\",\n                \"deploy\": \"Distribuzione\"\n            },\n            \"no_pipelines\": \"Non è ancora stata attivata alcuna pipeline.\",\n            \"pipelines_for\": \"Pipeline per ramo \\\"{branch}\\\"\",\n            \"files\": \"File modificati\",\n            \"no_pipeline_steps\": \"Nessuno step disponibile nella pipeline!\",\n            \"step_not_started\": \"Questo step non è ancora iniziato.\",\n            \"config\": \"Configurazione\",\n            \"tasks\": \"Attività\",\n            \"log_delete_confirm\": \"Vuoi davvero eliminare i registri attività di step?\",\n            \"errors\": \"Errori\",\n            \"protected\": {\n                \"awaits\": \"Questa pipeline è in attesa di approvazione da parte di un manutentore!\",\n                \"approve\": \"Approva\",\n                \"decline\": \"Rifiuta\",\n                \"declined\": \"Questa pipeline è stata rifiutata!\",\n                \"approve_success\": \"Pipeline approvata\",\n                \"decline_success\": \"Pipeline rifiutata\"\n            },\n            \"log_delete_error\": \"Si è verificato un errore durante l'eliminazione dei registri attività di step\",\n            \"status\": {\n                \"failure\": \"fallita\",\n                \"killed\": \"terminata\",\n                \"status\": \"Stato: {status}\",\n                \"pending\": \"in attesa\",\n                \"running\": \"in esecuzione\",\n                \"started\": \"avviata\",\n                \"blocked\": \"bloccata\",\n                \"skipped\": \"saltata\",\n                \"success\": \"completata\",\n                \"declined\": \"rifiutata\",\n                \"error\": \"errore\"\n            },\n            \"warnings\": \"Avvertenze\",\n            \"show_errors\": \"Mostra errori\",\n            \"we_got_some_errors\": \"Oh no, si è verificato un errore!\",\n            \"event\": {\n                \"tag\": \"Tag\",\n                \"cron\": \"Attività pianificata\",\n                \"manual\": \"Manuale\",\n                \"release\": \"Rilascio\",\n                \"push\": \"Invia\",\n                \"pr\": \"Richiesta di Modifica\",\n                \"deploy\": \"Distribuzione\",\n                \"pr_closed\": \"Richiesta di Modifica unita/chiusa\"\n            },\n            \"exit_code\": \"Codice di Uscita {exitCode}\",\n            \"loading\": \"Caricamento…\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"log_download_error\": \"Si è verificato un errore durante il download del registro attività\",\n            \"log_title\": \"Registro Attività di Step\",\n            \"pipelines_for_pr\": \"Pipeline per richiesta di modifica #{index}\",\n            \"duration\": \"Durata pipeline\",\n            \"created\": \"Creata: {created}\",\n            \"no_logs\": \"Nessun registro\",\n            \"debug\": {\n                \"title\": \"Diagnostica\",\n                \"download_metadata\": \"Download metadati\",\n                \"metadata_download_error\": \"Errore durante il download dei metadati\",\n                \"metadata_download_successful\": \"Metadati scaricati correttamente\",\n                \"no_permission\": \"Non hai il permesso di accedere alle informazioni di diagnostica\",\n                \"metadata_exec_title\": \"Riesegui pipeline localmente\",\n                \"metadata_exec_desc\": \"Scarica i metadati di questa pipeline per eseguirla in locale. In questo modo è possibile risolvere i problemi e testare le modifiche prima di eseguirne il commit. 'woodpecker-cli' deve essere installato localmente, alla stessa versione del Server Woodpecker.\"\n            }\n        },\n        \"pull_requests\": \"Richieste di modifica\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Azioni\",\n                \"repair\": {\n                    \"success\": \"Repository riparato\",\n                    \"repair\": \"Ripara repository\"\n                },\n                \"delete\": {\n                    \"delete\": \"Elimina repository\",\n                    \"confirm\": \"Tutti i dati andranno persi dopo questa azione!\\n\\nVuoi davvero procedere?\",\n                    \"success\": \"Repository eliminato\"\n                },\n                \"disable\": {\n                    \"disable\": \"Disabilita repository\",\n                    \"success\": \"Repository disabilitato\"\n                },\n                \"enable\": {\n                    \"enable\": \"Abilita repository\",\n                    \"success\": \"Repository abiltato\"\n                }\n            },\n            \"crons\": {\n                \"name\": {\n                    \"name\": \"Nome\",\n                    \"placeholder\": \"Nome attività pianificata\"\n                },\n                \"next_exec\": \"Prossima esecuzione\",\n                \"not_executed_yet\": \"Non ancora eseguita\",\n                \"none\": \"Ancora nessuna attività pianificata.\",\n                \"branch\": {\n                    \"placeholder\": \"Ramo (utilizza ramo predefinito se vuoto)\",\n                    \"title\": \"Ramo\"\n                },\n                \"add\": \"Aggiungi attività pianificata\",\n                \"save\": \"Salva attività pianificata\",\n                \"created\": \"Attività pianificata creata\",\n                \"saved\": \"Attività pianificata salvata\",\n                \"deleted\": \"Attività pianificata eliminata\",\n                \"run\": \"Esegui ora\",\n                \"schedule\": {\n                    \"title\": \"Programma (basato su UTC)\",\n                    \"placeholder\": \"Programma\"\n                },\n                \"edit\": \"Modifica attività pianificata\",\n                \"delete\": \"Elimina attività pianificata\",\n                \"desc\": \"Le attività pianificate possono attivare pipeline a intervalli regolari.\",\n                \"show\": \"Mostra attività pianificate\",\n                \"crons\": \"Attività pianificate\"\n            },\n            \"general\": {\n                \"general\": \"Generale\",\n                \"pipeline_path\": {\n                    \"default\": \"Predefinito: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"path\": \"Percorso della pipeline\",\n                    \"desc\": \"Percorso per la configurazione della pipeline (ad esempio {0}). Le cartelle devono terminare con {1}.\",\n                    \"desc_path_example\": \"mio/percorso/\"\n                },\n                \"project\": \"Impostazioni progetto\",\n                \"protected\": {\n                    \"protected\": \"Protetto\",\n                    \"desc\": \"Ogni pipeline deve essere approvata prima di essere eseguita.\"\n                },\n                \"save\": \"Salva impostazioni\",\n                \"success\": \"Impostazioni del progetto aggiornate\",\n                \"timeout\": {\n                    \"minutes\": \"minuti\",\n                    \"timeout\": \"Scadenza\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Solo gli utenti autenticati dell'istanza Woodpecker possono vedere questo progetto.\",\n                        \"internal\": \"Interno\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Solo tu e gli altri proprietari della repository potete vedere questo progetto.\",\n                        \"private\": \"Privato\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Ogni utente può vedere il tuo progetto senza aver effettuato l'accesso.\",\n                        \"public\": \"Pubblico\"\n                    },\n                    \"visibility\": \"Visibilità progetto\"\n                },\n                \"cancel_prev\": {\n                    \"desc\": \"Gli eventi selezionati annullano le pipeline in attesa e quelle in esecuzione dello stesso evento prima di avviare la successiva.\",\n                    \"cancel\": \"Annulla pipeline precedenti\"\n                },\n                \"trusted\": {\n                    \"desc\": \"I container eseguiti dalle pipeline ottengono accesso a funzionalità avanzate (come il montaggio di volumi).\",\n                    \"trusted\": \"Attendibile\",\n                    \"network\": {\n                        \"network\": \"Rete\",\n                        \"desc\": \"I container di pipeline ottengono l'accesso ai privilegi di rete, come la modifica del DNS.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumi\",\n                        \"desc\": \"I container di pipeline possono montare volumi.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sicurezza\",\n                        \"desc\": \"I container di pipeline ottengono l'accesso ai privilegi di sicurezza.\"\n                    }\n                },\n                \"allow_pr\": {\n                    \"allow\": \"Consenti Richieste di Modifica\",\n                    \"desc\": \"Consenti l'esecuzione di pipeline sulle richieste di modifica.\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Consenti Deployment\",\n                    \"desc\": \"Consenti deployment per le pipeline riuscite. Tutti gli utenti con autorizzazioni push possono attivarli, da usare con cautela.\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Estensioni personalizzate affidabili per clone\",\n                    \"desc\": \"Estensioni che ottengono accesso alle credenziali netrc che possono essere utilizzate per clonare dal forge o per inviare modifiche.\"\n                }\n            },\n            \"not_allowed\": \"Non hai il permesso di accedere alle impostazioni di questo repository\",\n            \"secrets\": {\n                \"name\": \"Nome\",\n                \"value\": \"Valore\"\n            },\n            \"settings\": \"Impostazioni\",\n            \"registries\": {\n                \"registries\": \"Registri\",\n                \"desc\": \"È possibile aggiungere credenziali di registro per utilizzare immagini private nella pipeline.\",\n                \"address\": {\n                    \"placeholder\": \"Indirizzo del Registro (es. docker.io)\",\n                    \"address\": \"Indirizzo\"\n                },\n                \"credentials\": \"Credenziali di registro\",\n                \"show\": \"Mostra registri\",\n                \"add\": \"Aggiungi registro\",\n                \"none\": \"Ancora nessuna credenziale di registro.\",\n                \"save\": \"Salva registro\",\n                \"created\": \"Credenziali di registro create\",\n                \"saved\": \"Credenziali di registro salvate\",\n                \"deleted\": \"Credenziali di registro eliminate\",\n                \"edit\": \"Modifica registro\",\n                \"delete\": \"Elimina registro\"\n            },\n            \"badge\": {\n                \"type\": \"Sintassi\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"badge\": \"Badge\",\n                \"branch\": \"Ramo\"\n            }\n        },\n        \"user_none\": \"L'organizzazione/utente non ha ancora alcun progetto\",\n        \"visibility\": {\n            \"visibility\": \"Visibilità progetto\",\n            \"public\": {\n                \"public\": \"Pubblico\",\n                \"desc\": \"Chiunque può vedere il tuo progetto senza essere autenticato.\"\n            },\n            \"private\": {\n                \"private\": \"Privato\",\n                \"desc\": \"Solo tu e gli altri proprietari del repository possono visualizzare questo progetto.\"\n            },\n            \"internal\": {\n                \"internal\": \"Interno\",\n                \"desc\": \"Solo gli utenti autenticati dell'istanza di Woodpecker possono visualizzare questo progetto.\"\n            }\n        }\n    },\n    \"repos\": \"Repo\",\n    \"repositories\": \"Repository\",\n    \"search\": \"Cerca…\",\n    \"time\": {\n        \"not_started\": \"non ancora iniziato\",\n        \"template\": \"MMM D, YYYY, HH:mm z\",\n        \"just_now\": \"poco fa\"\n    },\n    \"unknown_error\": \"Si è verificato un errore sconosciuto\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Non hai il permesso di entrare\",\n        \"internal_error\": \"Errore interno\",\n        \"oauth_error\": \"Errore durante l'autenticazione con il provider Oauth\",\n        \"settings\": {\n            \"secrets\": {\n                \"desc\": \"I segreti utente possono essere utilizzati nelle pipeline di tutti i repository di proprietà dell'utente.\"\n            },\n            \"cli_and_api\": {\n                \"desc\": \"Utilizzo del Token di Accesso Personale, CLI e API\",\n                \"token\": \"Token di Accesso Personale\",\n                \"api_usage\": \"Esempio di utilizzo dell'API\",\n                \"cli_usage\": \"Esempio di utilizzo della CLI\",\n                \"download_cli\": \"Scarica CLI\",\n                \"reset_token\": \"Ripristina token\",\n                \"swagger_ui\": \"Interfaccia Swagger\",\n                \"cli_and_api\": \"CLI & API\"\n            },\n            \"settings\": \"Impostazioni Utente\",\n            \"general\": {\n                \"general\": \"Generale\",\n                \"language\": \"Lingua\",\n                \"theme\": {\n                    \"theme\": \"Tema\",\n                    \"light\": \"Chiaro\",\n                    \"dark\": \"Scuro\",\n                    \"auto\": \"Auto\"\n                }\n            },\n            \"registries\": {\n                \"desc\": \"È possibile aggiungere le credenziali di registro per utilizzare immagini private in tutte le pipeline personali.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agenti registrati nei repository dell'account.\"\n            }\n        }\n    },\n    \"username\": \"Nome utente\",\n    \"welcome\": \"Benvenuti in Woodpecker\",\n    \"secrets\": {\n        \"created\": \"Segreto creato\",\n        \"saved\": \"Segreto salvato\",\n        \"events\": {\n            \"events\": \"Disponibile ai seguenti eventi\",\n            \"pr_warning\": \"Si prega di fare attenzione con questa opzione: un malintenzionato potrebbe inviare una richiesta di modifica dannosa, che rivela i tuoi segreti.\",\n            \"warning\": \"Esporre segreti nelle richieste di modifica potrebbe consentire a malintenzionati di rubare i tuoi segreti tramite una pull request malevola.\"\n        },\n        \"value\": \"Valore\",\n        \"delete_confirm\": \"Vuoi davvero eliminare questo segreto?\",\n        \"none\": \"Ancora nessun segreto.\",\n        \"add\": \"Aggiungi segreto\",\n        \"save\": \"Salva segreto\",\n        \"show\": \"Mostra segreti\",\n        \"name\": \"Nome\",\n        \"delete\": \"Elimina segreto\",\n        \"edit\": \"Modifica segreto\",\n        \"deleted\": \"Segreto eliminato\",\n        \"images\": {\n            \"images\": \"Disponibile per le seguenti immagini\",\n            \"desc\": \"Elenco di immagini in cui questo segreto è disponibile, lascia vuoto per consentire a tutte le immagini.\"\n        },\n        \"secrets\": \"Segreti\",\n        \"desc\": \"I segreti possono essere utilizzati in tutte le pipeline di questo repository.\",\n        \"plugins\": {\n            \"images\": \"Disponibile solo per le seguenti estensioni\",\n            \"desc\": \"Elenco delle estensioni in cui questo segreto è disponibile. Vuoto, per consentire a ogni estensione e step normale.\"\n        }\n    },\n    \"default\": \"predefinito\",\n    \"info\": \"Dettagli\",\n    \"update_woodpecker\": \"Aggiorna la tua istanza Woodpecker a {0}\",\n    \"global_level_secret\": \"segreto globale\",\n    \"org_level_secret\": \"segreto organizzazione\",\n    \"login_to_cli\": \"Accedi alla CLI\",\n    \"abort\": \"Interrompi\",\n    \"cli_login_success\": \"Accesso alla CLI riuscito\",\n    \"oauth_error\": \"Errore durante l'autenticazione con il provider OAuth\",\n    \"internal_error\": \"Si è verificato un errore interno\",\n    \"registration_closed\": \"La registrazione è chiusa\",\n    \"api\": \"API\",\n    \"empty_list\": \"Nessuna (entità) trovata!\",\n    \"cli_login_denied\": \"Accesso alla CLI negato\",\n    \"return_to_cli\": \"Ora puoi chiudere questa scheda e tornare alla CLI.\",\n    \"settings\": \"Impostazioni\",\n    \"cli_login_failed\": \"Accesso alla CLI fallito\",\n    \"login_to_cli_description\": \"Se continui, verrai autenticato nella CLI.\",\n    \"access_denied\": \"Non hai il permesso di accedere a questa istanza\",\n    \"invalid_state\": \"Lo stato OAuth non è valido\",\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Non hai il permesso di accedere alle impostazioni di questa organizzazione\",\n            \"secrets\": {\n                \"desc\": \"I segreti dell'organizzazione possono essere utilizzati nelle pipeline di tutti i repository di proprietà dell'organizzazione.\"\n            },\n            \"registries\": {\n                \"desc\": \"È possibile aggiungere le credenziali di registro dell'organizzazione per utilizzare immagini private per tutte le pipeline di un'organizzazione.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agenti registrati per questa organizzazione.\"\n            }\n        }\n    },\n    \"running_version\": \"Stai eseguendo Woodpecker {0}\",\n    \"registries\": {\n        \"delete_confirm\": \"Vuoi davvero eliminare questo registro?\",\n        \"registries\": \"Registri\",\n        \"credentials\": \"Credenziali di registro\",\n        \"desc\": \"È possibile aggiungere le credenziali di registro per utilizzare immagini private nelle pipeline.\",\n        \"none\": \"Ancora nessune credenziali di registro.\",\n        \"address\": {\n            \"address\": \"Indirizzo\",\n            \"desc\": \"Indirizzo di Registro (es. docker.io)\"\n        },\n        \"show\": \"Mostra registri\",\n        \"save\": \"Salva registro\",\n        \"add\": \"Aggiungi registro\",\n        \"view\": \"Mostra registro\",\n        \"edit\": \"Modifica registro\",\n        \"delete\": \"Elimina registro\",\n        \"created\": \"Credenziali di registro create\",\n        \"saved\": \"Credenziali di registro salvate\",\n        \"deleted\": \"Credenziali di registro eliminate\"\n    },\n    \"login_with\": \"Accedi con {forge}\",\n    \"all_repositories\": \"Tutti i repository\",\n    \"no_search_results\": \"Nessun risultato trovato\",\n    \"require_approval\": {\n        \"forks\": \"Richieste di modifica dal fork\",\n        \"desc\": \"Impedisci alle pipeline dannose di esporre segreti o eseguire attività dannose approvandole prima dell'esecuzione.\",\n        \"require_approval_for\": \"Requisiti di approvazione\",\n        \"none\": \"Nessuno\",\n        \"pull_requests\": \"Tutte le richieste di modifica\",\n        \"all_events\": \"Tutti gli eventi del forge\",\n        \"none_desc\": \"Ogni evento attiva le pipeline, incluse le richieste di modifica. Questa impostazione può essere pericolosa ed è consigliata solo per le istanze private.\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Utenti autorizzati\",\n            \"desc\": \"Pipeline create dagli utenti elencati non richiedono mai approvazione.\"\n        }\n    },\n    \"org_access_denied\": \"Non sei autorizzato ad accedere a questa organizzazione\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/lv.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Pievienot aģentu\",\n                \"agents\": \"Aģenti\",\n                \"backend\": {\n                    \"backend\": \"Aizmugures sistēma\",\n                    \"badge\": \"aizmugures sistēma\"\n                },\n                \"capacity\": {\n                    \"badge\": \"paralēlie darbi\",\n                    \"capacity\": \"Paralēlie darbi\",\n                    \"desc\": \"Maksimālais aģenta paralēli izpildāmo konvejerdarbu skaits.\"\n                },\n                \"created\": \"Aģents izveidots\",\n                \"delete_agent\": \"Dzēst aģentu\",\n                \"delete_confirm\": \"Vai tiešām vēlaties dzēst šo aģentu? Tam vairs nebūs iespējas savienoties ar serveri.\",\n                \"deleted\": \"Aģents izdzēsts\",\n                \"desc\": \"Šajā serverī reģistrētie aģenti.\",\n                \"edit_agent\": \"Labot aģentu\",\n                \"id\": \"ID\",\n                \"last_contact\": \"Pēdējā sazināšanās\",\n                \"name\": {\n                    \"name\": \"Nosaukums\",\n                    \"placeholder\": \"Aģenta nosaukums\"\n                },\n                \"never\": \"nekad\",\n                \"no_schedule\": {\n                    \"name\": \"Atspējot aģentu\",\n                    \"placeholder\": \"Apturēt aģentu no jaunu darbu pieņemšanas\"\n                },\n                \"none\": \"Pagaidām nav neviena aģenta.\",\n                \"platform\": {\n                    \"badge\": \"platforma\",\n                    \"platform\": \"Platforma\"\n                },\n                \"save\": \"Saglabāt aģentu\",\n                \"saved\": \"Aģents saglabāts\",\n                \"show\": \"Parādīt aģentus\",\n                \"token\": \"Drošības talons\",\n                \"version\": \"Versija\"\n            },\n            \"not_allowed\": \"Nav piekļuves servera iestatījumiem.\",\n            \"orgs\": {\n                \"delete_confirm\": \"Vai patiešām vēlaties dzēst šo organizāciju? Tiks dzēsti arī visi organizācijai piederošie repozitoriji.\",\n                \"delete_org\": \"Dzēst organizāciju\",\n                \"deleted\": \"Organizācija tika izdzēsta\",\n                \"desc\": \"Organizācijas, kurām pieder repozitoriji šajā serverī.\",\n                \"none\": \"Pagaidām nav nevienas organizācijas.\",\n                \"org_settings\": \"Organizācijas iestatījumi\",\n                \"orgs\": \"Organizācijas\",\n                \"view\": \"Skatīt organizāciju\"\n            },\n            \"queue\": {\n                \"agent\": \"aģents\",\n                \"desc\": \"Uzdevumi, kuri gaida izpildi\",\n                \"pause\": \"Apturēt\",\n                \"paused\": \"Rinda ir apturēta\",\n                \"queue\": \"Rinda\",\n                \"resume\": \"Atsākt\",\n                \"resumed\": \"Rindas apstrāde atsākta\",\n                \"stats\": {\n                    \"completed_count\": \"Pabeigtie uzdevumi\",\n                    \"pending_count\": \"Gaida\",\n                    \"running_count\": \"Izpildās\",\n                    \"waiting_on_deps_count\": \"Gaida uz atkarībām\",\n                    \"worker_count\": \"Brīvi\"\n                },\n                \"task_pending\": \"Uzdevums gaida izpildi\",\n                \"task_running\": \"Uzdevums tiek izpildīts\",\n                \"task_waiting_on_deps\": \"Uzdevums gaida uz atkarībām\",\n                \"tasks\": \"Uzdevumi\",\n                \"waiting_for\": \"gaida uz\"\n            },\n            \"repos\": {\n                \"desc\": \"Repozitoriji, kas ir vai ir bijuši iespējoti šajā serverī.\",\n                \"disabled\": \"Atspējots\",\n                \"none\": \"Pagaidām nav neviena repozitorija.\",\n                \"repair\": {\n                    \"repair\": \"Salabot visus\",\n                    \"success\": \"Repozitoriji salaboti\"\n                },\n                \"repos\": \"Repozitoriji\",\n                \"settings\": \"Repozitorija iestatījumi\",\n                \"view\": \"Skatīt repozitoriju\"\n            },\n            \"secrets\": {\n                \"add\": \"Pievienot noslēpumu\",\n                \"created\": \"Globālais noslēpums izveidots\",\n                \"deleted\": \"Globālais noslēpums dzēsts\",\n                \"desc\": \"Noslēpumus var padot visu repozitoriju konvejerdarba soļiem izpildes laikā kā vides mainīgos.\",\n                \"events\": {\n                    \"events\": \"Pieejams šādiem notikumiem\",\n                    \"pr_warning\": \"Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu.\"\n                },\n                \"images\": {\n                    \"desc\": \"Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem\",\n                    \"images\": \"Pieejami šādiem attēliem\"\n                },\n                \"name\": \"Nosaukums\",\n                \"none\": \"Pagaidām nav neviena globālā noslēpuma.\",\n                \"plugins_only\": \"Pieejams tikai spraudņiem\",\n                \"save\": \"Saglabāt noslēpumu\",\n                \"saved\": \"Globālais noslēpums saglabāts\",\n                \"secrets\": \"Noslēpumi\",\n                \"show\": \"Noslēpumu saraksts\",\n                \"value\": \"Vērtība\",\n                \"warning\": \"Šie noslēpumi būs pieejami visiem lietotājiem.\"\n            },\n            \"settings\": \"Iestatījumi\",\n            \"users\": {\n                \"add\": \"Pievienot lietotāju\",\n                \"admin\": {\n                    \"admin\": \"Administrators\",\n                    \"placeholder\": \"Lietotājs ir administrators\"\n                },\n                \"avatar_url\": \"Avatāra URL\",\n                \"cancel\": \"Atcelt\",\n                \"created\": \"Lietotājs izveidots\",\n                \"delete_confirm\": \"Vai patiešām vēlaties dzēst šo lietotāju? Tiks dzēsti arī visi lietotājam piederošie repozitoriji.\",\n                \"delete_user\": \"Dzēst lietotāju\",\n                \"deleted\": \"Lietotājs izdzēsts\",\n                \"desc\": \"Lietotāji, kas ir reģistrēti šajā serverī\",\n                \"edit_user\": \"Labot lietotāju\",\n                \"email\": \"E-pasta adrese\",\n                \"login\": \"Lietotāja vārds\",\n                \"none\": \"Pašlaik vēl nav neviena lietotāja.\",\n                \"save\": \"Saglabāt lietotāju\",\n                \"saved\": \"Lietotāja dati saglabāti\",\n                \"show\": \"Parādīt lietotājus\",\n                \"users\": \"Lietotāji\"\n            },\n            \"registries\": {\n                \"warning\": \"Šie repozitorijas pilnvaras būs pieejamas visiem lietotājiem.\",\n                \"desc\": \"Globāli repozitoriju pilnvaras var tikt pievienoti pielietošanai privātos attēlos visiem konvejerdarbiem.\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Atpakaļ\",\n    \"cancel\": \"Atcelt\",\n    \"default\": \"noklusētais\",\n    \"docs\": \"Dokumentācija\",\n    \"documentation_for\": \"Dokumentācija par \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Nevarēja atrast pieprasīto objektu\"\n    },\n    \"info\": \"Informācija\",\n    \"login\": \"Autorizēties\",\n    \"logout\": \"Iziet\",\n    \"not_found\": {\n        \"back_home\": \"Uz sākumu\",\n        \"not_found\": \"Ak vai, 404, vai nu mēs salauzām kaut ko, vai arī tika atvērta lapa, kas neeksistē :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Nav piekļuves šīs organizācijas iestatījumiem\",\n            \"secrets\": {\n                \"add\": \"Pievienot noslēpumu\",\n                \"created\": \"Organizācijas noslēpums izveidots\",\n                \"deleted\": \"Organizācijas noslēpums dzēsts\",\n                \"desc\": \"Noslēpumus var padot visu organizācijas repozitoriju konvejerdarba soļiem kā vides mainīgos.\",\n                \"events\": {\n                    \"events\": \"Pieejams šādiem notikumiem\",\n                    \"pr_warning\": \"Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu.\"\n                },\n                \"images\": {\n                    \"desc\": \"Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem\",\n                    \"images\": \"Pieejami šādiem attēliem\"\n                },\n                \"name\": \"Nosaukums\",\n                \"none\": \"Pagaidām nav neviena organizācijas noslēpuma.\",\n                \"plugins_only\": \"Pieejams tikai spraudņiem\",\n                \"save\": \"Saglabāt noslēpumu\",\n                \"saved\": \"Organizācijas noslēpums saglabāts\",\n                \"secrets\": \"Noslēpumi\",\n                \"show\": \"Noslēpumu saraksts\",\n                \"value\": \"Vērtība\"\n            },\n            \"settings\": \"Iestatījumi\",\n            \"registries\": {\n                \"desc\": \"Organizācijas reģistrijas pilnvaras var tikt pievienoti, lai izmantotu privātas attēlos priekš visiem konvejerdarbiem organizācijā.\"\n            }\n        }\n    },\n    \"password\": \"Parole\",\n    \"pipeline_feed\": \"Konvejerdarba padeve\",\n    \"repo\": {\n        \"activity\": \"Aktivitāte\",\n        \"add\": \"Pievienot repozitoriju\",\n        \"branches\": \"Atzari\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Mērķa uzstādīšanas vide\",\n            \"title\": \"Iniciēt uzstādīšanu šim konvejerdarbam #{pipelineId}\",\n            \"trigger\": \"Uzstādīt\",\n            \"variables\": {\n                \"add\": \"Pievienot mainīgo\",\n                \"desc\": \"Norādiet papildus mainīgos, ko izmantot konvejerdarbā. Mainīgie ar šādu pašu nosaukumu tiks pārrakstīti.\",\n                \"name\": \"Mainīgā nosaukums\",\n                \"title\": \"Papildus konvejerdarba mainīgie\",\n                \"value\": \"Mainīgā vērtība\",\n                \"delete\": \"Dzēst mainīgo\"\n            },\n            \"enter_task\": \"Uzstādīšanas uzdevums\"\n        },\n        \"enable\": {\n            \"disabled\": \"Atspējots\",\n            \"enable\": \"Iespējot\",\n            \"enabled\": \"Jau ir iespējots\",\n            \"list_reloaded\": \"Repozitoriju sarakts tika pārlādēts\",\n            \"reload\": \"Pārlādēt repozitorijus\",\n            \"success\": \"Repozitorijs iespējots\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Norādiet atzaru\",\n            \"title\": \"Iniciēt manuālu konvejerdarba izpildi\",\n            \"trigger\": \"Izpildīt konvejerdarbu\",\n            \"variables\": {\n                \"add\": \"Pievienot\",\n                \"desc\": \"Norādiet papildus mainīgos, ko izmantot konvejerdarbā. Mainīgie ar tādu pašu nosaukumu tiks pārrakstīti.\",\n                \"name\": \"Mainīgā nosaukums\",\n                \"title\": \"Papildus konvejerdarba mainīgie\",\n                \"value\": \"Mainīgā vērtība\",\n                \"delete\": \"Dzēst mainīgo\"\n            },\n            \"show_pipelines\": \"Rādīt konvejerdarbus\"\n        },\n        \"not_allowed\": \"Nav piekļuves šim repozitorijam\",\n        \"open_in_forge\": \"Atvērt repozitoriju iekš forge\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Atcelt\",\n                \"cancel_success\": \"Konvejerdarbs atcelts\",\n                \"canceled\": \"Šis solis tika atcelts.\",\n                \"deploy\": \"Uzstādīt\",\n                \"log_auto_scroll\": \"Automātiski ritināt\",\n                \"log_auto_scroll_off\": \"Atslēgt automātisko ritināšanu\",\n                \"log_download\": \"Lejupielādēt\",\n                \"restart\": \"Pārstartēt\",\n                \"restart_success\": \"Konvejerdarbs pārstartēts\",\n                \"log_delete\": \"Dzēst\"\n            },\n            \"config\": \"Konfigurācija\",\n            \"errors\": \"Kļūdas ({count})\",\n            \"event\": {\n                \"cron\": \"Plānotais darbs\",\n                \"deploy\": \"Uzstādīšana\",\n                \"manual\": \"Manuāls\",\n                \"pr\": \"Izmaiņu pieprasījums\",\n                \"push\": \"Iesūtīšana\",\n                \"tag\": \"Tags\",\n                \"pr_closed\": \"Izmaiņu pieprasījums apvienots / aizvērts\",\n                \"release\": \"Relīze\"\n            },\n            \"exit_code\": \"Iziešanas kods {exitCode}\",\n            \"files\": \"Izmainītie faili ({files})\",\n            \"loading\": \"Notiek ielāde…\",\n            \"log_download_error\": \"Veicot žurnālfaila lejupielādi notika kļūda\",\n            \"log_title\": \"Soļa žurnāls\",\n            \"no_files\": \"Neviens fails nav mainīts.\",\n            \"no_pipeline_steps\": \"Konvejerdarbam nav neviena soļa!\",\n            \"no_pipelines\": \"Neviens konvejerdarbs vēl nav uzsākts.\",\n            \"pipeline\": \"Konvejerdarbs #{pipelineId}\",\n            \"pipelines_for\": \"Konvejerdarbi atzaram \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Konvejerdarbi izmaiņu pieprasījumam #{index}\",\n            \"protected\": {\n                \"approve\": \"Apstiprināt\",\n                \"approve_success\": \"Konvejerdarbs apstiprināts\",\n                \"awaits\": \"Šim konvejerdarbam ir nepieciešams apstiprinājums no atbildīgajām personām!\",\n                \"decline\": \"Noraidīt\",\n                \"decline_success\": \"Konvejerdarbs noraidīts\",\n                \"declined\": \"Šis konvejerdarbs tika noraidīts!\",\n                \"review\": \"Pārskatiet izmaiņas\"\n            },\n            \"show_errors\": \"Rādīt kļūdas\",\n            \"status\": {\n                \"blocked\": \"bloķēts\",\n                \"declined\": \"noraidīts\",\n                \"error\": \"kļūda\",\n                \"failure\": \"neveiksmīgs\",\n                \"killed\": \"apturēts\",\n                \"pending\": \"gaida izpildi\",\n                \"running\": \"izpildās\",\n                \"skipped\": \"izlaists\",\n                \"started\": \"uzsākts\",\n                \"status\": \"Statuss: {status}\",\n                \"success\": \"izpildīts\"\n            },\n            \"step_not_started\": \"Šis solis vēl nav uzsākts.\",\n            \"tasks\": \"Uzdevumi\",\n            \"warnings\": \"Brīdinājumi ({count})\",\n            \"we_got_some_errors\": \"Ak nē, notika kļūda!\",\n            \"no_logs\": \"Nav darbību žurnālu\",\n            \"duration\": \"Konvejerdarba ilgums\",\n            \"created\": \"Izveidots: {created}\",\n            \"log_delete_confirm\": \"Vai tiešām vēlaties dzēst šī soļa darbību žurnālus?\",\n            \"log_delete_error\": \"Notika kļūda dzēšot šī soļa darbību žurnālus\"\n        },\n        \"pull_requests\": \"Izmaiņu pieprasījumi\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Darbības\",\n                \"delete\": {\n                    \"confirm\": \"Visi repozitorija dati tiks neatgriezeniski dzēsti!\\n\\nVai vēlaties turpināt?\",\n                    \"delete\": \"Dzēst repozitoriju\",\n                    \"success\": \"Repozitorijs dzēsts\"\n                },\n                \"disable\": {\n                    \"disable\": \"Atspējot repozitoriju\",\n                    \"success\": \"Repozitorijs atspējots\"\n                },\n                \"enable\": {\n                    \"enable\": \"Iespējot repozitoriju\",\n                    \"success\": \"Repozitorijs iespējots\"\n                },\n                \"repair\": {\n                    \"repair\": \"Salabot repozitoriju\",\n                    \"success\": \"Repozitorijs salabots\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Nozīmīte\",\n                \"branch\": \"Atzars\",\n                \"type\": \"Pieraksta veids\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Pievienot plānoto darbu\",\n                \"branch\": {\n                    \"placeholder\": \"Atzars (atstājiet tukšu, lai izmantotu noklusēto atzaru)\",\n                    \"title\": \"Atzars\"\n                },\n                \"created\": \"Izveidots plānotais darbs\",\n                \"crons\": \"Darbu plānotājs\",\n                \"delete\": \"Dzēst darbu plānotāju\",\n                \"deleted\": \"Plānotais darbs izdzēsts\",\n                \"desc\": \"Darbu plānotājs var tikt izmantots, lai izpildītu konvejerdarbus pēc noteikta grafika.\",\n                \"edit\": \"Labot darbu plānotāju\",\n                \"name\": {\n                    \"name\": \"Nosaukums\",\n                    \"placeholder\": \"Plānotā darba nosaukums\"\n                },\n                \"next_exec\": \"Nākošā izpilde\",\n                \"none\": \"Nav pievienots neviens plānotais darbs.\",\n                \"not_executed_yet\": \"Vēl ne reizi nav izpildīts\",\n                \"run\": \"Izpildīt tagad\",\n                \"save\": \"Saglabāt plānoto darbu\",\n                \"saved\": \"Plānotā darba izmaiņas saglabātas\",\n                \"schedule\": {\n                    \"placeholder\": \"Grafiks\",\n                    \"title\": \"Grafiks (balstīts uz UTC laika joslu)\"\n                },\n                \"show\": \"Parādīt plānotos darbus\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Atļaut izmaiņu pieprasījumiem\",\n                    \"desc\": \"Ļaut izpildīt konvejerdarbus izmaiņu pieprasījumiem.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Atcelt iepriekšējos konvejerdarbus\",\n                    \"desc\": \"Iespējojot šo pazīmi, tiks atcelti visi iepriekšējie konvejerdarbi, kuriem sakrīt notikums un konteksts.\"\n                },\n                \"general\": \"Projekts\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Atļaut izmantot Git autorizāciju tikai uzticamiem konteineriem (ieteicams).\",\n                    \"netrc_only_trusted\": \"Papildus uzticamie klonēšanas spraudņiem\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Pēc noklusējuma: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Ceļš uz konvejerdarba konfigurāciju, piemēram, {0}. Mapēm jābeidzas ar {0}.\",\n                    \"desc_path_example\": \"mans/ceļš/\",\n                    \"path\": \"Konvejerdarba ceļš\"\n                },\n                \"project\": \"Projekta iestatījumi\",\n                \"protected\": {\n                    \"desc\": \"Nepieciešams apstiprināt visus konvejerdarbus pirms tie tiek izpildīti.\",\n                    \"protected\": \"Aizsargāts\"\n                },\n                \"save\": \"Saglabāt iestatījumus\",\n                \"success\": \"Projekta iestatījumi tika saglabāti\",\n                \"timeout\": {\n                    \"minutes\": \"minūtes\",\n                    \"timeout\": \"Noildze\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Konvejerdarba konteineri tiks izpildīti ar paaugstinātām tiesībām (piemēram, piesaistīt servera direktorijas).\",\n                    \"trusted\": \"Uzticams\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Tikai autorizēti lietotāji var piekļūt šim projektam.\",\n                        \"internal\": \"Iekšējs\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Tikai lietotāji, kam ir tiesības uz repozitoriju, var piekļūt šim projektam.\",\n                        \"private\": \"Privāts\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Ikviens var piekļūt projektam, arī neautorizētie lietotāji.\",\n                        \"public\": \"Publisks\"\n                    },\n                    \"visibility\": \"Projekta redzamība\"\n                },\n                \"allow_deploy\": {\n                    \"desc\": \"Atļaut publicēšanu no veiksmīgiem konvejerdarbiem. Lietojiet tikai, ja uzticaties visiem lietotājiem ar iesūtīšanas tiesībām.\",\n                    \"allow\": \"Atļaut publicēšanu\"\n                }\n            },\n            \"not_allowed\": \"Nav piekļuves šī repozitorija iestatījumiem\",\n            \"registries\": {\n                \"add\": \"Pievienot reģistru\",\n                \"address\": {\n                    \"address\": \"Adrese\",\n                    \"placeholder\": \"Reģistra adrese, piemēram, docker.io\"\n                },\n                \"created\": \"Reģistra autorizācijas dati pievienoti\",\n                \"credentials\": \"Reģistru autorizācijas dati\",\n                \"delete\": \"Dzēst reģistra autorizācijas datus\",\n                \"deleted\": \"Reģistra autorizācijas dati dzēsti\",\n                \"desc\": \"Reģistru autorizācijas dati var tikt izmantoti, lai izmantotu attēlos no privātiem reģistriem, konvjerdarbu soļos.\",\n                \"edit\": \"Labot reģistra autorizācijas datus\",\n                \"none\": \"Pašlaik nav pievienots neviens reģistrs.\",\n                \"registries\": \"Reģistri\",\n                \"save\": \"Saglabāt reģistru\",\n                \"saved\": \"Reģistra autorizācijas dati saglabāti\",\n                \"show\": \"Reģistru saraksts\"\n            },\n            \"secrets\": {\n                \"add\": \"Pievienot noslēpumu\",\n                \"created\": \"Noslēpums izveidots\",\n                \"delete\": \"Dzēst noslēpumu\",\n                \"delete_confirm\": \"Vai patiešām vēlaties dzēst šo noslēpumu?\",\n                \"deleted\": \"Noslēpums dzēsts\",\n                \"desc\": \"Noslēpumus var padot individuāliem konvejerdarba soļiem izpildes laikā kā vides mainīgos.\",\n                \"edit\": \"Labot noslēpumu\",\n                \"events\": {\n                    \"events\": \"Pieejams šādiem notikumiem\",\n                    \"pr_warning\": \"Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu.\"\n                },\n                \"images\": {\n                    \"desc\": \"Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem\",\n                    \"images\": \"Pieejams šādiem attēliem\"\n                },\n                \"name\": \"Nosaukums\",\n                \"none\": \"Pagaidām nav neviena noslēpuma.\",\n                \"plugins_only\": \"Pieejams tikai spraudņiem\",\n                \"save\": \"Saglabāt noslēpumu\",\n                \"saved\": \"Noslēpums saglabāts\",\n                \"secrets\": \"Noslēpumi\",\n                \"show\": \"Noslēpumu saraksts\",\n                \"value\": \"Vērtība\"\n            },\n            \"settings\": \"Iestatījumi\"\n        },\n        \"user_none\": \"Šai organizācijai/lietotājam pagaidām nav neviena projekta.\",\n        \"visibility\": {\n            \"visibility\": \"Projekta redzamība\",\n            \"public\": {\n                \"public\": \"Publisks\",\n                \"desc\": \"Jebkurš var redzēt šo projektu bez autentifikācijas.\"\n            },\n            \"private\": {\n                \"private\": \"Privāts\",\n                \"desc\": \"Tikai repozitorija dalībnieki var redzēt šo projektu.\"\n            },\n            \"internal\": {\n                \"internal\": \"Iekšējs\",\n                \"desc\": \"Visi autentificētie lietotāji šajā serverī var redzēt šo projektu.\"\n            }\n        }\n    },\n    \"repos\": \"Repozitorijas\",\n    \"repositories\": {\n        \"all\": {\n            \"desc\": \"Repozitoriji sakārtoti pēc pēdējā konvejerdarba izveides laika\",\n            \"title\": \"Visi repozitoriji\"\n        },\n        \"title\": \"Repozitoriji\",\n        \"last\": {\n            \"title\": \"Pēdējo reizi aplūkots\",\n            \"desc\": \"Visbiežāk skatītie repozitoriji sakārtoti pēc pēdējā aplūkošanas laika\"\n        }\n    },\n    \"running_version\": \"Tiek izmantota Woodpecker {0}\",\n    \"search\": \"Meklēt…\",\n    \"time\": {\n        \"days_short\": \"dien.\",\n        \"hours_short\": \"st.\",\n        \"min_short\": \"min.\",\n        \"not_started\": \"nav uzsākts\",\n        \"sec_short\": \"sek.\",\n        \"template\": \"YYYY. [gada] D. MMMM, HH:mm z\",\n        \"weeks_short\": \"ned.\",\n        \"just_now\": \"tikko\"\n    },\n    \"unknown_error\": \"Notika neparedzēta kļūda\",\n    \"update_woodpecker\": \"Lūdzu atjauniniet Woodpecker instanci uz {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Jums nav tiesību autorizēties\",\n        \"internal_error\": \"Notikusi sistēmas iekšējā kļūda\",\n        \"oauth_error\": \"Neizdevās autorizēties, izmantojot, OAuth piegādātāju\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Piemērs API lietošanai\",\n                \"cli_usage\": \"Piemērs komandrindas lietošanai\",\n                \"desc\": \"Personīgā piekļuves pilnvara un API lietošana\",\n                \"dl_cli\": \"Lejupielādēt komandrindas rīku\",\n                \"reset_token\": \"Atiestatīt pilnvaru\",\n                \"shell_setup\": \"Komandrindas iestatīšana\",\n                \"shell_setup_before\": \"nepieciešamie komandrindas iestatīšanas soļi\",\n                \"swagger_ui\": \"API dokumentācija\",\n                \"token\": \"Personīgā piekļuves pilnvara\"\n            },\n            \"general\": {\n                \"general\": \"Vispārīgi\",\n                \"language\": \"Valoda\",\n                \"theme\": {\n                    \"auto\": \"Noteikt automātiski\",\n                    \"dark\": \"Tumšā\",\n                    \"light\": \"Gaišā\",\n                    \"theme\": \"Tēma\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Pievienot noslēpumu\",\n                \"created\": \"Lietotāja noslēpums tika izveidots\",\n                \"deleted\": \"Lietotāja noslēpums tika izdzēsts\",\n                \"desc\": \"Lietotāja noslēpumi var tikt padoti personīgajiem konvejerdarbu soļiem kā vides mainīgie.\",\n                \"events\": {\n                    \"events\": \"Pieejams notikumiem\",\n                    \"pr_warning\": \"Esiet uzmanīgi atzīmējot šo pazīmi, jo tas var tikt izmantots, lai izmaiņu pieprasījumā atklātu šī noslēpuma vērtību, nepiederošām personām.\"\n                },\n                \"images\": {\n                    \"desc\": \"Ar komatu attdalīts saraksts ar attēlu nosaukumiem, kam šis noslēpums būs pieejams, atstājiet tukšu, lai tas būtu pieejams visiem attēliem\",\n                    \"images\": \"Pieejams šādiem soļu attēliem\"\n                },\n                \"name\": \"Nosaukums\",\n                \"none\": \"Pagaidām nav neviena lietotāja noslēpuma.\",\n                \"plugins_only\": \"Pieejams tikai spraudņiem\",\n                \"save\": \"Saglabāt noslēpumu\",\n                \"saved\": \"Lietotāja noslēpums tika saglabāts\",\n                \"secrets\": \"Noslēpumi\",\n                \"show\": \"Parādīt noslēpumus\",\n                \"value\": \"Vērtība\"\n            },\n            \"settings\": \"Lietotāja iestatījumi\",\n            \"registries\": {\n                \"desc\": \"Lietotāju reģistra pilnvaras var tikt pielietotas privātos attēlos priekš personīgiem konvejerdarbiem.\"\n            },\n            \"cli_and_api\": {\n                \"token\": \"Personīgās Piekļuves Žetons\",\n                \"api_usage\": \"Piemēra API pielietošana\",\n                \"cli_usage\": \"Piemēra CLI lietošana\",\n                \"download_cli\": \"Lejupielādēt CLI\",\n                \"reset_token\": \"Atiestatīt žetonu\",\n                \"swagger_ui\": \"Swagger UI\",\n                \"cli_and_api\": \"CLI & API\",\n                \"desc\": \"Personīgās Piekļuves Žetons, CLI un API pielietošna\"\n            }\n        }\n    },\n    \"username\": \"Lietotājvārds\",\n    \"welcome\": \"Woodpecker\",\n    \"secrets\": {\n        \"secrets\": \"Noslēpumi\",\n        \"none\": \"Pašlaik nav vides noslēpumu.\",\n        \"add\": \"Pievienot noslēpumu\",\n        \"save\": \"Saglabāt noslēpumu\",\n        \"show\": \"Rādīt noslēpumus\",\n        \"name\": \"Nosaukums\",\n        \"value\": \"Vērtība\",\n        \"delete_confirm\": \"Vai tiešām vēlaties dzēst šo noslēpumu?\",\n        \"saved\": \"Noslēpums saglabāts\",\n        \"events\": {\n            \"events\": \"Pieejams sekojošajos notikumos\",\n            \"pr_warning\": \"Lūdzu esiet uzmanīgi ar šo iestatījumu: ļaunprātīgs aktieris var iesniegt ļaunprātīgu pull request, kas atklās noslēpumus.\"\n        },\n        \"images\": {\n            \"desc\": \"Saraksts ar attēliem, kuriem šis noslēpums ir pieejams. Atstāt tukšu, lai atļautu visos attēlos.\",\n            \"images\": \"Pieejams sekojošiem attēliem\"\n        },\n        \"edit\": \"Rediģēt noslēpumu\",\n        \"desc\": \"Noslēpumi var tikt padoti individuāliem konvejerdarbu soļiem kā vides mainīgie.\",\n        \"deleted\": \"Noslēpums dzēsts\",\n        \"created\": \"Noslēpums izveidots\",\n        \"delete\": \"Dzēst noslēpumu\"\n    },\n    \"registries\": {\n        \"delete_confirm\": \"Vai tiešām vēlaties dzēst šo reģistru?\",\n        \"created\": \"Reģistra pilnvaras izveidotas\",\n        \"view\": \"Skatīt reģistru\",\n        \"edit\": \"Rediģēt reģistru\",\n        \"delete\": \"Dzēst reģistru\",\n        \"saved\": \"Reģistra pilnvaras saglabātas\",\n        \"deleted\": \"Reģistra pilnvaras dzēstas\",\n        \"save\": \"Saglabāt reģistru\",\n        \"add\": \"Pievienot reģistru\",\n        \"registries\": \"Reģistrijas\",\n        \"credentials\": \"Reģistriju pilnvaras\",\n        \"desc\": \"Reģistriju pilnvaras var tikt pievienotas pielietošanai privātos attēlos priekš konvejerdarbiem.\",\n        \"none\": \"Pašlaik nav rēģistrijas pilnvaru.\",\n        \"address\": {\n            \"address\": \"Adreses\",\n            \"desc\": \"Reģistrijas adreses (piem. docker.io)\"\n        },\n        \"show\": \"Rādīt reģistrijas\"\n    },\n    \"oauth_error\": \"Kļūda autentificējoties pret OAuth nodrošinātāju\",\n    \"internal_error\": \"Notikušas dažas iekšējās kļūdas\",\n    \"registration_closed\": \"Reģistrācijas process ir aizvērts\",\n    \"access_denied\": \"Jums nav atļaujas piekļūt šai instancei\",\n    \"invalid_state\": \"OAuth stāvoklis nav valīds\",\n    \"org_level_secret\": \"organizācijas noslēpums\",\n    \"cli_login_success\": \"Autorizēšanās CLI veiksmīga\",\n    \"login_to_cli\": \"Autorizēties CLI\",\n    \"login_to_cli_description\": \"Turpinot, Jūs autorizēs iekš CLI.\",\n    \"abort\": \"Aborts\",\n    \"cli_login_failed\": \"Autorizešanās pie CLI neizdevās\",\n    \"return_to_cli\": \"Jūs varat aizvērt šo cilni un atgriezties pie CLI.\",\n    \"settings\": \"Iestatījumi\",\n    \"login_with\": \"Autorizēties ar {forge}\",\n    \"empty_list\": \"Nav {entity}!\",\n    \"global_level_secret\": \"globāls noslēpums\",\n    \"cli_login_denied\": \"Autorizešanās pie CLI liegta\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/nb-NO.json",
    "content": "{\n    \"user\": {\n        \"settings\": {\n            \"cli_and_api\": {\n                \"api_usage\": \"Eksempel på API-bruk\",\n                \"cli_usage\": \"Eksempel på CLI-bruk\",\n                \"cli_and_api\": \"CLI & API\",\n                \"desc\": \"Personlig Tilgangs-Token, CLI og API bruk\",\n                \"token\": \"Personlig Tilgangs-Token\",\n                \"download_cli\": \"Last ned CLI\",\n                \"reset_token\": \"Nullstill token\",\n                \"swagger_ui\": \"Swagger UI\"\n            },\n            \"general\": {\n                \"theme\": {\n                    \"dark\": \"Mørkt\",\n                    \"theme\": \"Tema\",\n                    \"light\": \"Lyst\",\n                    \"auto\": \"Automatisk\"\n                },\n                \"general\": \"Konto\",\n                \"language\": \"Språk\"\n            },\n            \"settings\": \"Bruker-instillinger\",\n            \"secrets\": {\n                \"desc\": \"Bruker-hemmeligheter kan brukes i alle arbeidsflyter eid av brukeren.\"\n            },\n            \"registries\": {\n                \"desc\": \"Brukerens register-hemmeligheter kan brukes for å hente private imager for alle personlige arbeidsflyter.\"\n            },\n            \"agents\": {\n                \"desc\": \"Agenter som er registrert til dine personlige repoer.\"\n            }\n        }\n    },\n    \"registries\": {\n        \"desc\": \"Register-hemmeligheter kan bli lagt til for å bruke private imager i arbeidsflyter.\",\n        \"created\": \"Register-hemmeligheter opprettet\",\n        \"registries\": \"Registere\",\n        \"credentials\": \"Register-hemmeligheter\",\n        \"none\": \"Det finnes ingen register-hemmeligheter enda.\",\n        \"address\": {\n            \"address\": \"Adresse\",\n            \"desc\": \"Register Adresse (e.g. docker.io)\"\n        },\n        \"show\": \"Vis registere\",\n        \"save\": \"Lagre register\",\n        \"add\": \"Legg til register\",\n        \"view\": \"Vis register\",\n        \"edit\": \"Rediger register\",\n        \"delete\": \"Slett register\",\n        \"delete_confirm\": \"Ønsker du virkelig å slette dette registeret?\",\n        \"saved\": \"Register-hemmeligheter lagret\",\n        \"deleted\": \"Register-hemmeligheter slettet\"\n    },\n    \"secrets\": {\n        \"desc\": \"Hemmeligheter kan bli brukt i alle arbeidsflyter for dette arkivet.\",\n        \"plugins\": {\n            \"desc\": \"Liste over utvidelses-imager hvor denne hemmeligheten er tilgengelig. La være tom for å tillate alle utvidelser og normale steg.\",\n            \"images\": \"Kun tilgjengelig for følgende utvidelser\"\n        },\n        \"secrets\": \"Hemmeligheter\",\n        \"none\": \"Det finnes ingen hemmeligheter enda.\",\n        \"add\": \"Legg til hemmelighet\",\n        \"save\": \"Lagre hemmelighet\",\n        \"show\": \"Vis hemmeligheter\",\n        \"name\": \"Navn\",\n        \"value\": \"Verdi\",\n        \"delete_confirm\": \"Ønsker du virkelig å slette denne hemmeligheten?\",\n        \"deleted\": \"Hemmeligheten ble slettet\",\n        \"created\": \"Hemmeligheten ble opprettet\",\n        \"saved\": \"Hemmeligheten ble lagret\",\n        \"events\": {\n            \"events\": \"Tilgjengelig for følgende hendelser\",\n            \"warning\": \"Å tilgjengeliggjøre hemmeligheter for pull-forespørsler kan la skurker stjele hemmelighetene dine med en ondsinnet pull-forespørsel.\"\n        },\n        \"edit\": \"Rediger hemmelighet\",\n        \"delete\": \"Slett hemmelighet\"\n    },\n    \"require_approval\": {\n        \"none\": \"Ingen\",\n        \"none_desc\": \"Alle hendelser kjører arbeidsflyter, inkludert pull-forespørsler. Denne instillingen kan være skadelig og er kun anbefalt for private tjenere.\",\n        \"desc\": \"Sikre deg mot at ondsinnede arbeidsflyter eksponerer hemmeligheter eller kjører skadelige oppgaver ved å godkjenne dem før kjøring.\",\n        \"require_approval_for\": \"Godkjenningskrav\",\n        \"forks\": \"Pull-forespørsler fra gafflede arkiver\",\n        \"pull_requests\": \"Alle pull-forespørsler\",\n        \"all_events\": \"Alle hendelser fra tilbyder\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Tillatte brukere\",\n            \"desc\": \"Arbeidsflyter fra disse brukerne trenger aldri godkjenning.\"\n        }\n    },\n    \"org_level_secret\": \"organisasjons-hemmelighet\",\n    \"oauth_error\": \"Feil under autentisering mot OAuth leverandør\",\n    \"forges_desc\": \"Konfigurer tilbydere av arkiver Woodpecker skal kjøre for.\",\n    \"login\": \"Logg inn\",\n    \"repos\": \"Arkiver\",\n    \"repositories\": {\n        \"title\": \"Arkiver\",\n        \"all\": {\n            \"title\": \"Alle arkiver\",\n            \"desc\": \"Arkiver sortert etter siste arbeidsflyt\"\n        },\n        \"last\": {\n            \"title\": \"Sist besøkt\",\n            \"desc\": \"Siste besøkte arkiver sortert etter besøkstid\"\n        }\n    },\n    \"docs\": \"Dokumentasjon\",\n    \"api\": \"API\",\n    \"logout\": \"Logg ut\",\n    \"search\": \"Søk…\",\n    \"username\": \"Brukernavn\",\n    \"password\": \"Passord\",\n    \"back\": \"Tilbake\",\n    \"unknown_error\": \"En ukjent feil oppsto\",\n    \"pipeline_feed\": \"Arbeidsflyts-historikk\",\n    \"empty_list\": \"{entity} finnes ikke!\",\n    \"not_found\": {\n        \"back_home\": \"Tilbake hjem\",\n        \"not_found\": \"Oi en 404! Enten har noe gått galt, eller du skrev inn feil adresse :-/\"\n    },\n    \"errors\": {\n        \"not_found\": \"Tjeneren kunne ikke finne det forespurte objektet\"\n    },\n    \"time\": {\n        \"not_started\": \"ikke påbegynt\",\n        \"just_now\": \"akkurat nå\"\n    },\n    \"repo\": {\n        \"manual_pipeline\": {\n            \"title\": \"Start en arbeidsflyt manuelt\",\n            \"trigger\": \"Kjør arbeidsflyt\",\n            \"select_branch\": \"Velg gren\",\n            \"variables\": {\n                \"delete\": \"Slett variabel\",\n                \"title\": \"Ytterligere arbeidsflyts-variabler\",\n                \"desc\": \"Oppgi ytterligere variabler for din arbeidsflyt. Variabler med samme navn blir overstyrt.\",\n                \"name\": \"Variabel-navn\",\n                \"value\": \"Verdi for variabel\"\n            },\n            \"show_pipelines\": \"Vis arbeidsflyter\",\n            \"no_manual_workflows\": \"Det var ingen manuelt utførbare jobflyter, eller filteret eksluderte alle\"\n        },\n        \"deploy_pipeline\": {\n            \"title\": \"Start en utrulling for nåværende arbeidsflyt #{pipelineid}\",\n            \"enter_target\": \"Ønsket miljø for utrulling\",\n            \"enter_task\": \"Utrullings-oppgave\",\n            \"trigger\": \"Rull ut\",\n            \"variables\": {\n                \"delete\": \"Slett variabel\",\n                \"title\": \"Ytterligere variabler for arbeidsflyt\",\n                \"name\": \"Variabel-navn\",\n                \"value\": \"Verdi for variabel\",\n                \"desc\": \"Oppgi ytterligere variabler for bruk i din arbeidsflyt. Variabler med samme navn blir overskrevet.\"\n            }\n        },\n        \"activity\": \"Aktivitet\",\n        \"branches\": \"Grener\",\n        \"pull_requests\": \"Pull-forespørsler\",\n        \"add\": \"Legg til arkiv\",\n        \"user_none\": \"Denne organisasjonen/brukeren har ingen prosjekter enda\",\n        \"not_allowed\": \"Du har ikke tilgang til dette arkivet\",\n        \"enable\": {\n            \"success\": \"Arkiv aktivert\",\n            \"enable\": \"Aktiver\",\n            \"enabled\": \"Allerede aktivert\",\n            \"disabled\": \"Deaktivert\"\n        },\n        \"visibility\": {\n            \"visibility\": \"Synlighet for prosjekt\",\n            \"public\": {\n                \"public\": \"Offentlig\",\n                \"desc\": \"Alle kan se prosjektet ditt uten å være logget inn.\"\n            },\n            \"private\": {\n                \"private\": \"Privat\",\n                \"desc\": \"Bare du og andre eiere av arkivet kan se dette prosjektet.\"\n            },\n            \"internal\": {\n                \"internal\": \"Intern\",\n                \"desc\": \"Bare brukere som er logget inn til denne Woodpecker-installasjonen kan se prosjektet.\"\n            }\n        },\n        \"settings\": {\n            \"not_allowed\": \"Du har ikke tilgang til å se instillingene for dette arkivet\",\n            \"general\": {\n                \"general\": \"Prosjekt\",\n                \"project\": \"Prosjekt-instillinger\",\n                \"save\": \"Lagre instillingene\",\n                \"success\": \"Prosjekt-instillingene er oppdatert\",\n                \"pipeline_path\": {\n                    \"path\": \"Arbeidsflyts-sti\",\n                    \"default\": \"Som standard: .woodpecker/.{'{yaml,yml'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc_path_example\": \"min/sti/\",\n                    \"desc\": \"Stil til konfigurasjon for arbeidsflyt (for eksempel {0}. Mapper skal slutte med en {1}.\"\n                },\n                \"allow_pr\": {\n                    \"allow\": \"Tillat Pull-Forespørsler\",\n                    \"desc\": \"Tillat kjøring av arbeidsflyter for pull-forespørsler.\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Tillat Utrulling\",\n                    \"desc\": \"Tillat utrulling for vellykkede arbeidsflyter. All brukere med skrive-tilgang kan starte disse, så bruk med varsomhet.\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Egenvalgte tillatte utvidelser for kloning\",\n                    \"desc\": \"Utvidelser som får tilgang til netrc-akkreditering som kan brukes for å klone arkiver fra tilbyderen eller oppdatere dem hos tilbyderen.\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Betrodd\",\n                    \"network\": {\n                        \"network\": \"Nettverk\",\n                        \"desc\": \"Arbeidsflyt-kontainere får tilgang til nettverks-rettigheter som å endre DNS.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Volumer\",\n                        \"desc\": \"Arbeidsflyts-kontainere får tilgang til å montere volumer.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Sikkerhet\",\n                        \"desc\": \"Arbeidsflyts-kontainere får tilgang til sikkerhets-privilegier.\"\n                    }\n                },\n                \"timeout\": {\n                    \"timeout\": \"Tidsavbrudd\",\n                    \"minutes\": \"Minutter\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Avbryt tidligere arbeidsflyter\",\n                    \"desc\": \"Valgte hendelser avbryter ventende og kjørende arbeidsflyter for den samme hendelsen før de starter nye.\"\n                }\n            },\n            \"crons\": {\n                \"crons\": \"Periodiske oppgaver\",\n                \"desc\": \"Periodiske oppgaver (cron) kan brukes for å kjøre gjentakende arbeidsflyter.\",\n                \"show\": \"Vis periodiske oppgaver\",\n                \"add\": \"Legg til periodisk oppgave\",\n                \"none\": \"Det er ingen periodiske oppgaver enda.\",\n                \"save\": \"Lagre periodisk oppgave\",\n                \"created\": \"Periodisk oppgave opprettet\",\n                \"saved\": \"Perioidsk oppgave lagret\",\n                \"deleted\": \"Periodisk oppgave slettet\",\n                \"not_executed_yet\": \"Ikke kjørt enda\",\n                \"run\": \"Kjør nå\",\n                \"branch\": {\n                    \"title\": \"Gren\",\n                    \"placeholder\": \"Gren (Bruker standard gren om ikke valgt)\"\n                },\n                \"name\": {\n                    \"name\": \"Navn\",\n                    \"placeholder\": \"Navn på periodisk oppgave\"\n                },\n                \"schedule\": {\n                    \"title\": \"Kjøringsplan (basert på UTC)\",\n                    \"placeholder\": \"Kjøringsplan\"\n                },\n                \"edit\": \"Rediger periodisk oppgave\",\n                \"delete\": \"Slett periodisk oppgave\",\n                \"next_exec\": \"Neste kjøring\",\n                \"enabled\": \"Aktivert\"\n            },\n            \"badge\": {\n                \"badge\": \"Merke\",\n                \"type\": \"Syntaks\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"branch\": \"Gren\",\n                \"workflow\": \"Jobbflyt\",\n                \"step\": \"Steg\"\n            },\n            \"actions\": {\n                \"actions\": \"Handlinger\",\n                \"repair\": {\n                    \"repair\": \"Reparer arkiv\",\n                    \"success\": \"Arkivet er reparert\"\n                },\n                \"disable\": {\n                    \"disable\": \"Deaktiver arkiv\",\n                    \"success\": \"Arkivet er deaktivert\"\n                },\n                \"enable\": {\n                    \"enable\": \"Aktiver arkiv\",\n                    \"success\": \"Arkivet er aktivert\"\n                },\n                \"delete\": {\n                    \"delete\": \"Slett arkiv\",\n                    \"confirm\": \"Alle data vil bli tap etter denne handlingen!\\n\\nVil du fortsette?\",\n                    \"success\": \"Arkivet er slettet\"\n                }\n            }\n        },\n        \"pipeline\": {\n            \"tasks\": \"Oppgaver\",\n            \"config\": \"Konfigurasjon\",\n            \"files\": \"Endrede filer\",\n            \"no_pipeline_steps\": \"Ingen arbeidsflyts-steg er tilgjengelige!\",\n            \"step_not_started\": \"Dette steget har ikke startet enda.\",\n            \"pipelines_for\": \"Arbeidsflyter for grenen \\\"{branch}\\\"\",\n            \"exit_code\": \"Avslutnings-kode {exitCode}\",\n            \"loading\": \"Laster.…\",\n            \"no_logs\": \"Ingen logger\",\n            \"pipeline\": \"Arbeidsflyt #{pipelineId}\",\n            \"log_title\": \"Logger for Steg\",\n            \"log_delete_confirm\": \"Ønsker du virkelig å slette steg-loggene?\",\n            \"log_delete_error\": \"En feil oppsto under sletting av steg-loggene\",\n            \"actions\": {\n                \"cancel\": \"Avbryt\",\n                \"restart\": \"Start igjen\",\n                \"canceled\": \"Dette steget har blitt avbrutt.\",\n                \"cancel_success\": \"Arbeidsflyt avbrutt\",\n                \"restart_success\": \"Arbeidsflyten er startet på nytt\",\n                \"log_download\": \"Last ned\",\n                \"log_delete\": \"Slett\",\n                \"log_auto_scroll_off\": \"Deaktiver automatisk scrolling\",\n                \"deploy\": \"Rull ut\",\n                \"log_auto_scroll\": \"Aktiver automatisk scrolling\"\n            },\n            \"protected\": {\n                \"awaits\": \"Arbeidsflyten venter på godkjenning fra en forvalter!\",\n                \"approve\": \"Godkjenn\",\n                \"decline\": \"Avvis\",\n                \"declined\": \"Denne arbeidsflyten er avvist!\",\n                \"approve_success\": \"Arbeidsflyten er godkjent\",\n                \"decline_success\": \"Arbeidsflyten er avvist\"\n            },\n            \"event\": {\n                \"push\": \"Push\",\n                \"tag\": \"Tagg\",\n                \"pr\": \"Pull-forespørsel\",\n                \"pr_metadata\": \"Metadata for Pull-forespørsel endret\",\n                \"deploy\": \"Rull ut\",\n                \"cron\": \"Gjentagende Oppgave\",\n                \"release\": \"Utgivelse\",\n                \"pr_closed\": \"Pull-forespørslen er merget/lukket\",\n                \"manual\": \"Manuell\"\n            },\n            \"status\": {\n                \"blocked\": \"blokkert\",\n                \"pending\": \"ventende\",\n                \"running\": \"kjørende\",\n                \"started\": \"startet\",\n                \"skipped\": \"hoppet over\",\n                \"success\": \"suksess\",\n                \"declined\": \"avvist\",\n                \"error\": \"feil\",\n                \"failure\": \"feilet\",\n                \"killed\": \"drept\",\n                \"status\": \"Status: {status}\"\n            },\n            \"errors\": \"Feil\",\n            \"warnings\": \"Advarsler\",\n            \"show_errors\": \"Vis feil\",\n            \"created\": \"Opprettet: {created}\",\n            \"debug\": {\n                \"title\": \"Feilsøk\",\n                \"download_metadata\": \"Last ned metadata\",\n                \"metadata_download_error\": \"En feil oppsto under nedlastning av metadata\",\n                \"metadata_download_successful\": \"Metadata ble lastet ned\",\n                \"no_permission\": \"Du har ikke tilgang til feilsøkings-informasjon\",\n                \"metadata_exec_title\": \"Kjør arbeidsflyten igjen lokalt\",\n                \"metadata_exec_desc\": \"Last ned metadata for denne arbeidsflyten for å kjøre den lokalt. Dette lar deg fikse feilene og teste endringer før du lagrer dem til arkivet. Du må bruke samme versjon av Woodpecker-cliet som server-versjonen.\"\n            },\n            \"view\": \"Vis arbeidsflyt\",\n            \"log_download_error\": \"En feil oppsto under nedlastning av logg-filen\",\n            \"no_pipelines\": \"Ingen arbeidsflyter har startet enda.\",\n            \"we_got_some_errors\": \"Å nei, en feil oppsto!\",\n            \"duration\": \"Varighet for arbeidsflyt: {duration}\",\n            \"pipelines_for_pr\": \"Arbeidsflyter for pull-forespørsel #{index}\"\n        },\n        \"open_in_forge\": \"Åpne arkiv hos leverandør\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"registries\": {\n                \"desc\": \"Organisasjonens register-hemmeligheter kan brukes for private imager i alle arbeidsflyter for organisasjonen.\"\n            },\n            \"agents\": {\n                \"desc\": \"Registrerte Agenter for denne organisasjonen.\"\n            },\n            \"secrets\": {\n                \"desc\": \"Organisasjons-hemmeligheter kan brukes i arbeidsflytene til alle arkivene denne organisasjonen eier.\"\n            },\n            \"not_allowed\": \"Du har ikke tilgang til instillingene for denne organisasjonen\"\n        }\n    },\n    \"admin\": {\n        \"settings\": {\n            \"settings\": \"Admin-Instillinger\",\n            \"secrets\": {\n                \"desc\": \"Globale hemmeligheter kan brukes arbeidsflyter for alle arkiver.\",\n                \"warning\": \"Disse hemmelighetene er tilgjengelige for alle brukere.\"\n            },\n            \"registries\": {\n                \"desc\": \"Globale register-hemmeligheter kan bli lagt til for å bruke private imager for alle arbeidsflyter.\",\n                \"warning\": \"Disse register-hemmelighetene er tilgjengelige for alle brukere.\"\n            },\n            \"agents\": {\n                \"agents\": \"Agenter\",\n                \"desc\": \"Agenter registert på denne tjeneren.\",\n                \"none\": \"Ingen agenter er registert enda.\",\n                \"id\": \"ID\",\n                \"add\": \"Legg til agent\",\n                \"save\": \"Lagre agent\",\n                \"show\": \"Vis agenter\",\n                \"created\": \"Agent er opprettet\",\n                \"deleted\": \"Agenten er slettet\",\n                \"name\": {\n                    \"name\": \"Navn\",\n                    \"placeholder\": \"Navn på agenten\"\n                },\n                \"no_schedule\": {\n                    \"name\": \"Deaktivert agent\",\n                    \"placeholder\": \"Stopp agenten fra å ta nye oppgaver\"\n                },\n                \"token\": \"Token\",\n                \"platform\": {\n                    \"platform\": \"Platform\",\n                    \"badge\": \"platform\"\n                },\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"Kapasitet\",\n                    \"desc\": \"Maksimum antall samtidige arbeidsflyter denne agenten kan kjøre.\",\n                    \"badge\": \"kapasitet\"\n                },\n                \"custom_labels\": {\n                    \"desc\": \"Egendefinerte merkelapper satt av agent-administrator ved oppstart.\",\n                    \"custom_labels\": \"Egendefinerte Merkelapper\"\n                },\n                \"org\": {\n                    \"badge\": \"org\"\n                },\n                \"version\": \"Versjon\",\n                \"last_contact\": {\n                    \"last_contact\": \"Siste kontakt\",\n                    \"badge\": \"siste kontakt\"\n                },\n                \"never\": \"Aldri\",\n                \"edit_agent\": \"Rediger agent\",\n                \"delete_agent\": \"Slett agent\",\n                \"saved\": \"Agenten er lagret\",\n                \"delete_confirm\": \"Ønsker du virkelig å slette denne agenten? Den vil ikke lenger kunne kontakte tjeneren.\"\n            },\n            \"queue\": {\n                \"queue\": \"Kø\",\n                \"pause\": \"Pause\",\n                \"resume\": \"Gjenoppta\",\n                \"paused\": \"Køen er pauset\",\n                \"resumed\": \"Køen er gjenopptatt\",\n                \"tasks\": \"Oppgaver\",\n                \"task_running\": \"Oppgaven kjører\",\n                \"task_pending\": \"Oppgaven venter\",\n                \"task_waiting_on_deps\": \"Oppgaven venter på avhengigheter\",\n                \"agent\": \"agent\",\n                \"waiting_for\": \"venter på\",\n                \"stats\": {\n                    \"completed_count\": \"Fullførte Oppgaver\",\n                    \"worker_count\": \"Ledig\",\n                    \"running_count\": \"Kjører\",\n                    \"pending_count\": \"Venter\",\n                    \"waiting_on_deps_count\": \"Venter på avhengigheter\"\n                },\n                \"desc\": \"Oppgaver som venter på å bli kjørt av agenter.\"\n            },\n            \"users\": {\n                \"users\": \"Brukere\",\n                \"desc\": \"Registrerte brukere på denne tjeneren.\",\n                \"login\": \"Brukernavn\",\n                \"email\": \"Epost\",\n                \"avatar_url\": \"URL til Avatar\",\n                \"save\": \"Lagre bruker\",\n                \"cancel\": \"Avbryt\",\n                \"show\": \"Vis brukere\",\n                \"add\": \"Legg til bruker\",\n                \"none\": \"Det finnes ingen brukere enda.\",\n                \"deleted\": \"Bruker slettet\",\n                \"created\": \"Bruker opprettet\",\n                \"saved\": \"Bruker lagret\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"Brukeren er en admin\"\n                },\n                \"delete_user\": \"Slett bruker\",\n                \"edit_user\": \"Rediger bruker\",\n                \"delete_confirm\": \"Ønsker du virkelig å slette denne brukeren? Dette vil også slette arkivene brukeren eier.\"\n            },\n            \"orgs\": {\n                \"orgs\": \"Organisasjoner\",\n                \"desc\": \"Organisasjoner som eier arkiver på denne tjeneren.\",\n                \"none\": \"Det finnes ingen organisasjoner enda.\",\n                \"org_settings\": \"Organisasjons-instillinger\",\n                \"delete_org\": \"Slett organisasjon\",\n                \"deleted\": \"Organisasjonen er slettet\",\n                \"delete_confirm\": \"Ønsker du virkelig å slette denne organisasjonen. Dette vil også slette alle arkiver eid av denne organisasjonen.\",\n                \"view\": \"Vis organisasjon\"\n            },\n            \"repos\": {\n                \"repos\": \"Arkiver\",\n                \"desc\": \"Arkiver som er eller ble aktivert på denne tjeneren.\",\n                \"none\": \"Det finnes ingen arkiver enda.\",\n                \"view\": \"Vis arkiv\",\n                \"settings\": \"Arkiv-instillinger\",\n                \"disabled\": \"Deaktivert\",\n                \"repair\": {\n                    \"repair\": \"Reparer alle\",\n                    \"success\": \"Arkiver er reparert\"\n                }\n            },\n            \"not_allowed\": \"Du har ikke tilgang til å se server-instillingene\"\n        }\n    },\n    \"default\": \"standard\",\n    \"info\": \"Info\",\n    \"running_version\": \"Du kjører Woodpecker {0}\",\n    \"update_woodpecker\": \"Vennligst oppdater din Woodpecker-tjener til {0}\",\n    \"global_level_secret\": \"global hemmelighet\",\n    \"login_to_cli\": \"Logg inn til CLI\",\n    \"login_to_cli_description\": \"Hvis du fortsetter blir du logget inn til CLIet.\",\n    \"abort\": \"Avbryt\",\n    \"cli_login_success\": \"Innlogging til CLIet var vellykket\",\n    \"cli_login_failed\": \"Innlogging til CLIet feilet\",\n    \"cli_login_denied\": \"Innlogging til CLIet ble avvist\",\n    \"return_to_cli\": \"Du kan nå lukke denne fanen og returnere til CLIet.\",\n    \"settings\": \"Instillinger\",\n    \"internal_error\": \"En intern feil oppsto\",\n    \"registration_closed\": \"Registrering er stengt\",\n    \"access_denied\": \"Du har ikke tilgang til denne tjeneren\",\n    \"org_access_denied\": \"Du har ikke tilgang til denne organisasjonen\",\n    \"invalid_state\": \"OAuth statusen er ugyldig\",\n    \"no_search_results\": \"Ingen resultater ble funnet\",\n    \"forges\": \"Tilbydere\",\n    \"add_forge\": \"Legg til tilbyder\",\n    \"show_forges\": \"Vis tilbydere\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Tillegg\",\n    \"forge_type\": \"Tilbyder-type\",\n    \"oauth_client_id\": \"OAuth Klient-ID\",\n    \"oauth_client_secret\": \"OAuth Klient-Hemmelighet\",\n    \"oauth_host\": \"OAuth adresse\",\n    \"merge_ref\": \"Merge ref\",\n    \"merge_ref_desc\": \"Referanse for merge basis. Dette blir brukt for å finne endringer for Pull-forespørsler.\",\n    \"public_only\": \"Kun offentlig\",\n    \"public_only_desc\": \"Bare vis offentlige arkiver.\",\n    \"git_username\": \"Git brukernavn\",\n    \"git_username_desc\": \"Brukernavn for Git brukeren.\",\n    \"git_password\": \"Git passord\",\n    \"git_password_desc\": \"Passord eller personlig tilgangs-token for Git brukeren.\",\n    \"executable\": \"Kjørbar fil\",\n    \"executable_desc\": \"Sti til kjørbar fil for tillegget.\",\n    \"save\": \"Lagre\",\n    \"add\": \"Legg til\",\n    \"skip_verify\": \"Hopp over verifisering av SSL\",\n    \"skip_verify_desc\": \"Hopp over verifisering av SSL for API-forbindelsen. Dette er ikke anbefalt i produksjon.\",\n    \"url\": \"URL\",\n    \"forge_managed_by_env\": \"Hoved-tilbyderen er administrert gjennom miljøvariabler. Alle endringer til denne tilbyderen vil bli tilbakesatt ved omstart.\",\n    \"oauth_redirect_url\": \"URL for OAuth videresending\",\n    \"forge_created\": \"Tilbyderen er opprettet\",\n    \"advanced_options\": \"Avanserte valg\",\n    \"leave_empty_to_keep_current_value\": \"La stå tom for å beholde nåværende verdi\",\n    \"forge_deleted\": \"Tilbyderen er slettet\",\n    \"forge_delete_confirm\": \"Ønsker du virkelig å slette denn tilbyderen? Dette vil også slette alle arkiver, brukere og arbeidsflyter tilknyttet denne tilbyderen.\",\n    \"edit_forge\": \"Rediger tilbyder\",\n    \"delete_forge\": \"Slett tilbyder\",\n    \"no_forges\": \"Det finnes ingen tilbydere enda.\",\n    \"use_this_redirect_url_to_create\": \"Bruk denne videresendings-URLen for å opprette eller oppdatere OAuth-applikasjonen.\",\n    \"developer_settings_to_create\": \"Gå til {0} og sett opp OAuth applikasjonen.\",\n    \"developer_settings\": \"utvikler-instillinger\",\n    \"public_url_for_oauth_if\": \"Offentlig URL for OAuth om forskjellig fra URLen ({0})\",\n    \"forge_saved\": \"Tilbyderen er lagret\",\n    \"fullscreen\": \"Fullskjerm\",\n    \"exit_fullscreen\": \"Avslutt fullskjerm\",\n    \"help_translating\": \"Du kan bidra til å oversette Woodpecker til ditt språk på {0}.\",\n    \"weblate\": \"vår Weblate\",\n    \"cancel\": \"Avbryt\",\n    \"documentation_for\": \"Dokumentasjon for \\\"{topic}\\\"\",\n    \"login_to_woodpecker_with\": \"Log inn til Woodpecker med\",\n    \"extensions\": \"Utvidelser\",\n    \"extensions_description\": \"Utvidelser er HTTP-tjenester som kan bli kalt av Woodpecker istedenfor å bruke de innebygde tjenesten.\",\n    \"extension_endpoint_placeholder\": \"f.eks. https://eksempel.no/api\",\n    \"config_extension_endpoint\": \"Konfigurer endepunkt for utvidelse\",\n    \"extensions_signatures_public_key\": \"Offentlig nøkkel for signaturer\",\n    \"extensions_signatures_public_key_description\": \"Denne offentlige nøkkelen skal brukes av dine utvidelser for å verifisere webhook-kall fra Woodpecker.\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/nl.json",
    "content": "{\n    \"api\": \"API\",\n    \"back\": \"Terug\",\n    \"cancel\": \"Annuleren\",\n    \"docs\": \"Documentatie\",\n    \"documentation_for\": \"Documentatie voor \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Object kon niet op de server gevonden worden\"\n    },\n    \"login\": \"Inloggen\",\n    \"logout\": \"Uitloggen\",\n    \"not_found\": {\n        \"back_home\": \"Terug naar startpagina\",\n        \"not_found\": \"Whoa 404, of wij hebben iets gesloopt of je hebt een typfoutje gemaakt :-/\"\n    },\n    \"password\": \"Wachtwoord\",\n    \"pipeline_feed\": \"Pipeline feed\",\n    \"repo\": {\n        \"activity\": \"Activiteit\",\n        \"add\": \"Repository toevoegen\",\n        \"branches\": \"Branches\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Doelomgeving deployment\",\n            \"title\": \"Voer deployment event uit voor de huidige pipeline #{pipelineId}\",\n            \"trigger\": \"Rol uit\",\n            \"variables\": {\n                \"add\": \"Voeg variabele toe\",\n                \"desc\": \"Specificeer additionele variabelen voor gebruik in je pipeline. Variabelen met een bestaande naam worden overschreven.\",\n                \"name\": \"Variabele naam\",\n                \"title\": \"Additionele pipeline variabelen\",\n                \"value\": \"Variabele waarde\"\n            }\n        },\n        \"enable\": {\n            \"enable\": \"Activeer\",\n            \"enabled\": \"Al ingeschakeld\",\n            \"list_reloaded\": \"Repository lijst herladen\",\n            \"reload\": \"Opslagplaatsen herladen\",\n            \"success\": \"Repository ingeschakeld\",\n            \"disabled\": \"Uitgeschakeld\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Selecteer branch\",\n            \"title\": \"Activeer een handmatige pipeline run\",\n            \"trigger\": \"Pipeline uitvoeren\",\n            \"variables\": {\n                \"add\": \"Voeg variabele toe\",\n                \"desc\": \"Specificeer aanvullende variabelen om in je pipeline te gebruiken. Variabelen met dezelfde naam worden overschreven.\",\n                \"name\": \"Variabele naam\",\n                \"title\": \"Aanvullende variabelen\",\n                \"value\": \"Variabele waarde\"\n            }\n        },\n        \"not_allowed\": \"Je hebt geen toegang tot dit archief\",\n        \"pull_requests\": \"Pull verzoeken\",\n        \"settings\": {\n            \"general\": {\n                \"general\": \"Algemeen\",\n                \"project\": \"Project instellingen\",\n                \"save\": \"Instellingen opslaan\",\n                \"success\": \"Repository instellingen geüpdatet\",\n                \"timeout\": {\n                    \"minutes\": \"minuten\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Vertrouwd\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Alleen geauthenticeerde gebruikers van deze Woodpecker instantie kunnen dit project zien.\"\n                    },\n                    \"private\": {\n                        \"private\": \"Privé\"\n                    },\n                    \"visibility\": \"Project zichtbaarheid\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Geheim toevoegen\",\n                \"created\": \"Geheim aangemaakt\",\n                \"delete\": \"Geheim verwijderen\",\n                \"deleted\": \"Geheim verwijderd\",\n                \"edit\": \"Geheim wijzigen\",\n                \"images\": {\n                    \"images\": \"Beschikbaar voor de volgende afbeeldingen\"\n                },\n                \"name\": \"Naam\",\n                \"save\": \"Geheim opslaan\",\n                \"saved\": \"Geheim opgeslagen\",\n                \"secrets\": \"Geheimen\",\n                \"show\": \"Geheimen weergeven\",\n                \"value\": \"Waarde\"\n            },\n            \"settings\": \"Instellingen\"\n        },\n        \"user_none\": \"Deze organisatie / gebruiker heeft nog geen projecten.\"\n    },\n    \"repos\": \"Repos\",\n    \"repositories\": {\n        \"last\": {\n            \"title\": \"Laatst Bezocht\"\n        }\n    },\n    \"search\": \"Zoeken…\",\n    \"time\": {\n        \"days_short\": \"d\",\n        \"hours_short\": \"u\",\n        \"min_short\": \"min\",\n        \"not_started\": \"nog niet gestart\",\n        \"sec_short\": \"sec\",\n        \"template\": \"DD.MM.YYYY, HH:mm z\",\n        \"weeks_short\": \"w\"\n    },\n    \"unknown_error\": \"Er is een onbekende fout opgetreden\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"U heeft niet de rechten om in te loggen\",\n        \"internal_error\": \"Er is een interne fout opgetreden\"\n    },\n    \"username\": \"Gebruikersnaam\",\n    \"welcome\": \"Welkom bij Woodpecker\",\n    \"login_to_woodpecker_with\": \"Inloggen bij Woodpecker met\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/pl.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Dodaj agenta\",\n                \"agents\": \"Agenty\",\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"badge\": \"pojemność\",\n                    \"capacity\": \"Pojemność\",\n                    \"desc\": \"Maksymalna liczba równoległych potoków wykonywanych przez tego agenta.\"\n                },\n                \"created\": \"Utworzono agenta\",\n                \"delete_agent\": \"Usuń agenta\",\n                \"delete_confirm\": \"Czy na pewno chcesz usunąć tego agenta? Nie będzie można go więcej połączyć z serwerem.\",\n                \"deleted\": \"Usunięto agenta\",\n                \"desc\": \"Agenty zarejestrowane na tym serwerze\",\n                \"edit_agent\": \"Edytuj agenta\",\n                \"last_contact\": \"Ostatni kontakt\",\n                \"name\": {\n                    \"name\": \"Nazwa\",\n                    \"placeholder\": \"Nazwa agenta\"\n                },\n                \"never\": \"Nigdy\",\n                \"no_schedule\": {\n                    \"name\": \"Dezaktywuj agenta\",\n                    \"placeholder\": \"Nie pozwalaj agentowi pobierać nowych zadań\"\n                },\n                \"none\": \"Nie ma jeszcze żadnego agenta.\",\n                \"platform\": {\n                    \"badge\": \"platforma\",\n                    \"platform\": \"Platforma\"\n                },\n                \"save\": \"Zapisz agenta\",\n                \"saved\": \"Zapisano agenta\",\n                \"show\": \"Pokaż agenty\",\n                \"token\": \"Token\",\n                \"version\": \"Wersja\",\n                \"id\": \"ID\"\n            },\n            \"not_allowed\": \"Nie masz pozwolenia na dostęp do ustawień serwera\",\n            \"queue\": {\n                \"agent\": \"agent\",\n                \"desc\": \"Zadania oczekujące na wykonanie przez agentów\",\n                \"pause\": \"Wstrzymaj\",\n                \"paused\": \"Kolejka jest wstrzymana\",\n                \"queue\": \"Kolejka\",\n                \"resume\": \"Wznów\",\n                \"resumed\": \"Kolejka jest wznowiona\",\n                \"stats\": {\n                    \"completed_count\": \"Zadania zakończone\",\n                    \"pending_count\": \"Oczekujące\",\n                    \"running_count\": \"Uruchomione\",\n                    \"waiting_on_deps_count\": \"Oczekiwanie na zależności\",\n                    \"worker_count\": \"Wolne\"\n                },\n                \"task_pending\": \"Zadanie jest oczekujące\",\n                \"task_running\": \"Zadanie jest w toku\",\n                \"task_waiting_on_deps\": \"Zadanie oczekuje na zależności\",\n                \"tasks\": \"Zadania\",\n                \"waiting_for\": \"oczekuje na\"\n            },\n            \"secrets\": {\n                \"add\": \"Dodaj sekret\",\n                \"created\": \"Dodano sekret globalny\",\n                \"deleted\": \"Usunięto sekret globalny\",\n                \"desc\": \"Globalne sekrety mogą być przekazane jako zmienne środowiskowe do poszczególnych kroków potoku wszystkich repozytoriów.\",\n                \"events\": {\n                    \"events\": \"Dostępny dla wybranych zdarzeń\",\n                    \"pr_warning\": \"Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów\",\n                    \"images\": \"Dostępny dla wybranych obrazów\"\n                },\n                \"name\": \"Nazwa\",\n                \"none\": \"Nie ma jeszcze żadnych globalnych sekretów.\",\n                \"plugins_only\": \"Dostępny tylko dla pluginów\",\n                \"save\": \"Zapisz sekret\",\n                \"saved\": \"Zapisano sekret globalny\",\n                \"secrets\": \"Sekrety\",\n                \"show\": \"Pokaż sekrety\",\n                \"value\": \"Wartość\",\n                \"warning\": \"Te sekrety będą dostępne dla wszystkich użytkowników serwera.\"\n            },\n            \"settings\": \"Ustawienia\",\n            \"users\": {\n                \"add\": \"Dodaj użytkownika\",\n                \"admin\": {\n                    \"admin\": \"Administrator\",\n                    \"placeholder\": \"Użytkownik jest administratorem\"\n                },\n                \"avatar_url\": \"Adres URL awatara\",\n                \"cancel\": \"Anuluj\",\n                \"created\": \"Dodano użytkownika\",\n                \"delete_confirm\": \"Czy naprawdę chcesz usunąć tego użytkownika?\",\n                \"delete_user\": \"Usuń użytkownika\",\n                \"deleted\": \"Usunięto użytkownika\",\n                \"desc\": \"Użytkownicy zarejestrowani na tym serwerze\",\n                \"edit_user\": \"Edytuj użytkownika\",\n                \"email\": \"Email\",\n                \"login\": \"Logowanie\",\n                \"none\": \"Nie ma jeszcze użytkowników.\",\n                \"save\": \"Zapisz użytkownika\",\n                \"saved\": \"Zapisano użytkownika\",\n                \"show\": \"Pokaż użytkowników\",\n                \"users\": \"Użytkownicy\"\n            },\n            \"orgs\": {\n                \"orgs\": \"Organizacje\"\n            },\n            \"repos\": {\n                \"repos\": \"Repozytoria\"\n            }\n        }\n    },\n    \"back\": \"Powrót\",\n    \"cancel\": \"Anuluj\",\n    \"docs\": \"Dokumentacja\",\n    \"documentation_for\": \"Dokumentacja do tematu \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Serwer nie mógł znaleźć żądanego obiektu\"\n    },\n    \"login\": \"Zaloguj\",\n    \"logout\": \"Wyloguj\",\n    \"not_found\": {\n        \"back_home\": \"Powrót do strony głównej\",\n        \"not_found\": \"Ojoj, 404, albo coś zepsuliśmy, albo pomyliłeś się przy wpisywaniu :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Nie masz pozwolenia na dostęp do ustawień tej organizacji\",\n            \"secrets\": {\n                \"add\": \"Dodaj sekret\",\n                \"created\": \"Dodano sekret organizacji\",\n                \"deleted\": \"Usunięto sekret organizacji\",\n                \"desc\": \"Sekrety organizacji mogą być przekazane jako zmienne środowiskowe do poszczególnych kroków potoku wszystkich repozytoriów tej organizacji.\",\n                \"events\": {\n                    \"events\": \"Dostępny dla wybranych zdarzeń\",\n                    \"pr_warning\": \"Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów\",\n                    \"images\": \"Dostępny dla wybranych obrazów\"\n                },\n                \"name\": \"Nazwa\",\n                \"none\": \"Nie ma jeszcze żadnych sekretów organizacji.\",\n                \"plugins_only\": \"Dostępny tylko dla pluginów\",\n                \"save\": \"Zapisz sekret\",\n                \"saved\": \"Zapisano sekret organizacji\",\n                \"secrets\": \"Sekrety\",\n                \"show\": \"Pokaż sekrety\",\n                \"value\": \"Wartość\"\n            },\n            \"settings\": \"Ustawienia\",\n            \"agents\": {\n                \"desc\": \"Agenty zarejestrowane dla tej organizacji.\"\n            },\n            \"registries\": {\n                \"desc\": \"Można dodać dane rejestrów organizacji, aby używać prywatnych obrazów dla wszystkich potoków organizacji.\"\n            }\n        }\n    },\n    \"password\": \"Hasło\",\n    \"pipeline_feed\": \"Tablica potoków\",\n    \"repo\": {\n        \"activity\": \"Aktywności\",\n        \"add\": \"Dodaj repozytorium\",\n        \"branches\": \"Gałęzie\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Docelowe środowisko wdrażania\",\n            \"title\": \"Wyzwalanie zdarzenia deploymentu dla bieżącego potoku #{pipelineId}\",\n            \"trigger\": \"Wdrażanie\",\n            \"variables\": {\n                \"add\": \"Dodaj zmienną\",\n                \"desc\": \"Zdefiniuj dodatkowe zmienne użyte w twoim potoku. Zmienne o tej samej nazwie zostaną nadpisane.\",\n                \"name\": \"Nazwa zmiennej\",\n                \"title\": \"Dodatkowe zmienne potoku\",\n                \"value\": \"Wartość zmiennej\",\n                \"delete\": \"Usuń zmienną\"\n            },\n            \"enter_task\": \"Zadanie wdrażania\"\n        },\n        \"enable\": {\n            \"enable\": \"Aktywuj\",\n            \"enabled\": \"Już aktywowano\",\n            \"list_reloaded\": \"Wczytano listę repozytoriów ponownie\",\n            \"reload\": \"Wczytaj repozytoria ponownie\",\n            \"success\": \"Zaktywowano\",\n            \"disabled\": \"Dezaktywowany\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Wybierz gałąź\",\n            \"title\": \"Wyzwól ręcznie uruchomienie potoku\",\n            \"trigger\": \"Uruchom potok\",\n            \"variables\": {\n                \"add\": \"Dodaj zmienną\",\n                \"desc\": \"Zdefiniuj dodatkowe zmienne użyte w twoim potoku. Zmienne o tej samej nazwie zostaną nadpisane.\",\n                \"name\": \"Nazwa zmiennej\",\n                \"title\": \"Dodatkowe zmienne potoku\",\n                \"value\": \"Wartość zmiennej\",\n                \"delete\": \"Delete variable\"\n            },\n            \"show_pipelines\": \"Pokaż potoki\"\n        },\n        \"not_allowed\": \"Nie masz pozwolenia na dostęp do tego repozytorium\",\n        \"open_in_forge\": \"Otwórz repozytorium w systemie kontroli wersji\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Anuluj\",\n                \"cancel_success\": \"Anulowano potok\",\n                \"canceled\": \"Ten krok został anulowany.\",\n                \"deploy\": \"Wdrażanie\",\n                \"log_auto_scroll\": \"Przewijaj automatycznie\",\n                \"log_auto_scroll_off\": \"Wyłącz automatyczne przewijanie\",\n                \"log_download\": \"Pobierz\",\n                \"restart\": \"Uruchom ponownie\",\n                \"restart_success\": \"Uruchomiono potok ponownie\",\n                \"log_delete\": \"Usuń\"\n            },\n            \"config\": \"Konfiguracja\",\n            \"event\": {\n                \"cron\": \"Cron\",\n                \"deploy\": \"Wdrażanie\",\n                \"manual\": \"Ręcznie\",\n                \"pr\": \"Pull Request\",\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"release\": \"Wydanie\",\n                \"pr_closed\": \"Pull Request scalony/zamknięty\",\n                \"pr_metadata\": \"Zmieniono metadane Pull Requesta\"\n            },\n            \"exit_code\": \"Kod wyjścia {exitCode}\",\n            \"files\": \"Zmodyfikowane pliki ({files})\",\n            \"loading\": \"Ładowanie…\",\n            \"log_download_error\": \"Wystąpił błąd podczas pobierania pliku z logiem\",\n            \"no_files\": \"Nie zmodyfikowano żadnych plików.\",\n            \"no_pipeline_steps\": \"Brak dostępnych kroków potoku!\",\n            \"no_pipelines\": \"Nie uruchomiono jeszcze żadnego potoku.\",\n            \"pipeline\": \"Potok #{pipelineId}\",\n            \"pipelines_for\": \"Potoki dla gałęzi \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Potoki dla pull requesta #{index}\",\n            \"protected\": {\n                \"approve\": \"Zatwierdź\",\n                \"approve_success\": \"Zatwierdzono potok\",\n                \"awaits\": \"Ten potok oczekuje na zatwierdzenie przez maintainera!\",\n                \"decline\": \"Odrzuć\",\n                \"decline_success\": \"Odrzucono potok\",\n                \"declined\": \"Ten potok został odrzucony!\"\n            },\n            \"status\": {\n                \"blocked\": \"zablokowany\",\n                \"declined\": \"odrzucony\",\n                \"error\": \"błąd\",\n                \"failure\": \"zakończony niepowodzeniem\",\n                \"killed\": \"zabity\",\n                \"pending\": \"oczekujący na wykonanie\",\n                \"running\": \"w trakcie uruchomienia\",\n                \"skipped\": \"pominięty\",\n                \"started\": \"rozpoczęty\",\n                \"status\": \"Status: {status}\",\n                \"success\": \"zakończony powodzeniem\"\n            },\n            \"step_not_started\": \"Ten krok jeszcze się nie rozpoczął.\",\n            \"tasks\": \"Zadania\",\n            \"log_title\": \"Logi\",\n            \"we_got_some_errors\": \"Ojej, coś poszło nie tak!\",\n            \"no_logs\": \"Brak logów\",\n            \"log_delete_confirm\": \"Czy na pewno chcesz usunąć logi kroku?\",\n            \"errors\": \"Błędy\",\n            \"duration\": \"Czas trwania potoku: {duration}\",\n            \"show_errors\": \"Pokaż błędy\",\n            \"created\": \"Utworzono: {created}\",\n            \"debug\": {\n                \"metadata_exec_desc\": \"Pobierz metadane tego potoku, aby uruchomić go lokalnie. Pozwala to na naprawę problemów i testowanie zmian przed ich zatwierdzeniem. Woodpecker CLI musi być zainstalowane lokalnie w tej samej wersji co serwer.\",\n                \"download_metadata\": \"Pobierz metadane\",\n                \"metadata_download_error\": \"Błąd podczas pobierania metadanych\",\n                \"no_permission\": \"Nie masz pozwolenia na dostęp do informacji debugowania\",\n                \"metadata_exec_title\": \"Uruchom potok ponownie lokalnie\",\n                \"title\": \"Debugowanie\",\n                \"metadata_download_successful\": \"Pomyślnie pobrano metadane\"\n            },\n            \"view\": \"Zobacz potok\",\n            \"log_delete_error\": \"Błąd podczas usuwania logów kroku\",\n            \"warnings\": \"Ostrzeżenia\"\n        },\n        \"pull_requests\": \"Pull requesty\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Operacje\",\n                \"delete\": {\n                    \"confirm\": \"Wszystkie dane zostaną utracone po wykonaniu tej operacji!!!\\n\\nCzy na pewno chcesz kontynuować?\",\n                    \"delete\": \"Usuń repozytorium\",\n                    \"success\": \"Usunięto repozytorium\"\n                },\n                \"disable\": {\n                    \"disable\": \"Dezaktywuj repozytorium\",\n                    \"success\": \"Dezaktywowano repozytorium\"\n                },\n                \"enable\": {\n                    \"enable\": \"Włącz repozytorium\",\n                    \"success\": \"Włączono repozytorium\"\n                },\n                \"repair\": {\n                    \"repair\": \"Napraw repozytorium\",\n                    \"success\": \"Naprawiono repozytorium\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Plakietka\",\n                \"branch\": \"Gałąź\",\n                \"type\": \"Składnia\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Dodaj cron\",\n                \"branch\": {\n                    \"placeholder\": \"Gałąź (używa domyślnej gałęzi jeśli puste)\",\n                    \"title\": \"Gałąź\"\n                },\n                \"created\": \"Dodano cron\",\n                \"crons\": \"Crony\",\n                \"delete\": \"Usuń cron\",\n                \"deleted\": \"Usunięto cron\",\n                \"desc\": \"Zadania cron mogą być używane do wyzwalania potoków w regularnym odstępie.\",\n                \"edit\": \"Edytuj cron\",\n                \"name\": {\n                    \"name\": \"Nazwa\",\n                    \"placeholder\": \"Nazwa zadania cron\"\n                },\n                \"next_exec\": \"Następne uruchomienie\",\n                \"none\": \"Nie dodano jeszcze żadnego crona.\",\n                \"not_executed_yet\": \"Jeszcze nigdy nie uruchomiono\",\n                \"run\": \"Uruchom teraz\",\n                \"save\": \"Zapisz cron\",\n                \"saved\": \"Zapisano cron\",\n                \"schedule\": {\n                    \"placeholder\": \"Harmonogram\",\n                    \"title\": \"Harmonogram (wyrażony w UTC)\"\n                },\n                \"show\": \"Pokaż crony\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Zezwól na pull requesty\",\n                    \"desc\": \"Potoki mogą być uruchamiane przy pull requestach.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Anuluj wcześniejsze potoki\",\n                    \"desc\": \"Zaznacz aby anulować oczekujące i uruchomione potoki dla tego samego zdarzenia i kontekstu przed rozpoczęciem nowego potoku.\"\n                },\n                \"general\": \"Ogólne\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Wstrzykuj poświadczenia netrc tylko do zaufanych kontenerów (zalecane).\",\n                    \"netrc_only_trusted\": \"Wstrzykuj poświadczenia netrc tylko do zaufanych kontenerów\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"Domyślnie: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Ścieżka do konfiguracji Twojego potoku (na przykład {0}). Foldery powinny kończyć się znakiem {1}.\",\n                    \"desc_path_example\": \"moja/ścieżka/\",\n                    \"path\": \"Ścieżka do pliku potoku\"\n                },\n                \"project\": \"Ustawienia projektu\",\n                \"protected\": {\n                    \"desc\": \"Każdy potok musi zostać zatwierdzony przed uruchomieniem.\",\n                    \"protected\": \"Chroniony\"\n                },\n                \"save\": \"Zapisz ustawienia\",\n                \"success\": \"Zaktualizowano ustawienia projektu\",\n                \"timeout\": {\n                    \"minutes\": \"minut\",\n                    \"timeout\": \"Limit czasu wykonania\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Kontenery potoku otrzymają dostęp do podwyższonych operacji, takich jak montowanie woluminów.\",\n                    \"trusted\": \"Zaufany\",\n                    \"network\": {\n                        \"network\": \"Sieć\",\n                        \"desc\": \"Kontenery potoku otrzymują dostęp do uprawnień sieciowych, takich jak zmiana DNS.\"\n                    },\n                    \"volumes\": {\n                        \"desc\": \"Kontenery potoku mogą montować woluminy.\",\n                        \"volumes\": \"Woluminy\"\n                    },\n                    \"security\": {\n                        \"security\": \"Bezpieczeństwo\",\n                        \"desc\": \"Kontenery potoku otrzymują dostęp do uprawnień bezpieczeństwa.\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Tylko zalogowani użytkownicy instancji Woodpecker mogą zobaczyć ten projekt.\",\n                        \"internal\": \"Wewnętrzny\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Tylko ty i inni właściciele repozytorium mogą zobaczyć ten projekt.\",\n                        \"private\": \"Prywatny\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Każdy użytkownik może zobaczyć twój projekt bez bycia zalogowanym.\",\n                        \"public\": \"Publiczny\"\n                    },\n                    \"visibility\": \"Widoczność projektu\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Zezwól na wdrożenia\",\n                    \"desc\": \"Zezwól na wdrożenia dla udanych potoków. Wszyscy użytkownicy z uprawnieniami do push mogą je wyzwalać, więc używaj ostrożnie.\"\n                }\n            },\n            \"not_allowed\": \"Nie masz pozwolenia na dostęp do ustawień tego repozytorium\",\n            \"registries\": {\n                \"add\": \"Dodaj rejestr\",\n                \"address\": {\n                    \"address\": \"Adres\",\n                    \"placeholder\": \"Adres rejestru (np. docker.io)\"\n                },\n                \"created\": \"Utworzono dane rejestru\",\n                \"credentials\": \"Dane rejestrów\",\n                \"delete\": \"Usuń rejestr\",\n                \"deleted\": \"Usunięto dane rejestru\",\n                \"desc\": \"Możesz dodać dane rejestrów aby używać prywatnych obrazów w twoim potoku.\",\n                \"edit\": \"Edytuj rejestr\",\n                \"none\": \"Nie dodano jeszcze żadnego rejestru.\",\n                \"registries\": \"Rejestry\",\n                \"save\": \"Zapisz rejestr\",\n                \"saved\": \"Zapisano dane rejestru\",\n                \"show\": \"Pokaż rejestry\"\n            },\n            \"secrets\": {\n                \"add\": \"Dodaj sekret\",\n                \"created\": \"Dodano sekret\",\n                \"delete\": \"Usuń sekret\",\n                \"delete_confirm\": \"Czy naprawdę chcesz usunąć ten sekret?\",\n                \"deleted\": \"Usunięto sekret\",\n                \"desc\": \"Sekrety są przekazywane do poszczególnych kroków potoku jako zmienne środowiskowe.\",\n                \"edit\": \"Edytuj sekret\",\n                \"events\": {\n                    \"events\": \"Dostępny dla wybranych zdarzeń\",\n                    \"pr_warning\": \"Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety.\"\n                },\n                \"images\": {\n                    \"desc\": \"Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów\",\n                    \"images\": \"Dostępny dla wybranych obrazów\"\n                },\n                \"name\": \"Nazwa\",\n                \"none\": \"Nie ma jeszcze żadnych sekretów.\",\n                \"plugins_only\": \"Dostępny tylko dla pluginów\",\n                \"save\": \"Zapisz sekret\",\n                \"saved\": \"Zapisano sekret\",\n                \"secrets\": \"Sekrety\",\n                \"show\": \"Pokaż sekrety\",\n                \"value\": \"Wartość\"\n            },\n            \"settings\": \"Ustawienia\"\n        },\n        \"user_none\": \"Ta organizacja / użytkownik nie ma jeszcze żadnych projektów.\",\n        \"visibility\": {\n            \"private\": {\n                \"desc\": \"Tylko ty i inni właściciele repozytorium mogą zobaczyć ten projekt.\",\n                \"private\": \"Prywatny\"\n            },\n            \"visibility\": \"Widoczność projektu\",\n            \"public\": {\n                \"public\": \"Publiczny\",\n                \"desc\": \"Każdy może zobaczyć twój projekt bez logowania.\"\n            },\n            \"internal\": {\n                \"internal\": \"Wewnętrzny\",\n                \"desc\": \"Tylko zalogowani użytkownicy instancji Woodpecker mogą zobaczyć ten projekt.\"\n            }\n        }\n    },\n    \"repos\": \"Repozytoria\",\n    \"repositories\": {\n        \"title\": \"Repozytoria\",\n        \"all\": {\n            \"title\": \"Wszystkie repozytoria\",\n            \"desc\": \"Repozytoria posortowane według ostatniego utworzenia potoku\"\n        },\n        \"last\": {\n            \"title\": \"Ostatnio odwiedzone\",\n            \"desc\": \"Ostatnio odwiedzone repozytoria posortowane według czasu dostępu\"\n        }\n    },\n    \"search\": \"Szukaj…\",\n    \"time\": {\n        \"days_short\": \"d\",\n        \"hours_short\": \"godz\",\n        \"min_short\": \"min\",\n        \"not_started\": \"jeszcze nie rozpoczęto\",\n        \"sec_short\": \"sek\",\n        \"template\": \"DD.MM.YYYY, HH:mm z\",\n        \"weeks_short\": \"tyg\",\n        \"just_now\": \"przed chwilą\"\n    },\n    \"unknown_error\": \"Wystąpił nieznany błąd\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Nie masz pozwolenia na zalogowanie\",\n        \"internal_error\": \"Wystąpił błąd wewnętrzny\",\n        \"oauth_error\": \"Błąd podczas uwierzytelnienia u dostawcy OAuth\",\n        \"settings\": {\n            \"api\": {\n                \"dl_cli\": \"Pobierz CLI\",\n                \"cli_usage\": \"Przykład użycia CLI\",\n                \"shell_setup\": \"Przygotowanie powłoki\",\n                \"api_usage\": \"Przykład użycia API\",\n                \"api\": \"API\",\n                \"reset_token\": \"Zresetuj token\"\n            },\n            \"settings\": \"Ustawienia użytkownika\",\n            \"secrets\": {\n                \"deleted\": \"Usunięto sekret użytkownika\",\n                \"name\": \"Nazwa\",\n                \"value\": \"Wartość\",\n                \"secrets\": \"Sekrety\",\n                \"none\": \"Nie ma jeszcze żadnych sekretów użytkownika.\",\n                \"add\": \"Dodaj sekret\",\n                \"save\": \"Zapisz sekret\",\n                \"show\": \"Pokaż sekrety\",\n                \"created\": \"Dodano sekret użytkownika\",\n                \"saved\": \"Zapisano sekret użytkownika\"\n            },\n            \"general\": {\n                \"theme\": {\n                    \"auto\": \"Automatyczny\",\n                    \"dark\": \"Ciemny\",\n                    \"light\": \"Jasny\",\n                    \"theme\": \"Motyw\"\n                },\n                \"language\": \"Język\",\n                \"general\": \"Ogólne\"\n            }\n        }\n    },\n    \"username\": \"Nazwa użytkownika\",\n    \"welcome\": \"Witamy w Woodpecker\",\n    \"empty_list\": \"Nie znaleziono {entity}!\",\n    \"api\": \"API\",\n    \"login_to_woodpecker_with\": \"Zaloguj się do Woodpecker za pomocą\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/pt.json",
    "content": "{\n    \"back\": \"Voltar\",\n    \"cancel\": \"Cancelar\",\n    \"docs\": \"Documentação\",\n    \"documentation_for\": \"Documentação para \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Servidor não pode encontrar o objeto requisitado\"\n    },\n    \"login\": \"Entrar\",\n    \"logout\": \"Sair\",\n    \"not_found\": {\n        \"back_home\": \"Voltar para o início\",\n        \"not_found\": \"Opa 404, quebramos alguma coisa, ou você teve um erro de digitação :-/\"\n    },\n    \"password\": \"Senha\",\n    \"repo\": {\n        \"activity\": \"Atividade\",\n        \"add\": \"Adicionar repositório\",\n        \"deploy_pipeline\": {\n            \"variables\": {\n                \"add\": \"Adicionar variável\",\n                \"name\": \"Nome da variável\",\n                \"value\": \"Valor da variável\",\n                \"title\": \"Variáveis adicionais do pipeline\",\n                \"desc\": \"Especifique variáveis adicionais a serem usadas em seu pipeline. As variáveis com o mesmo nome são substituídas.\",\n                \"delete\": \"Excluir variável\"\n            },\n            \"title\": \"Acionar a implantação para o pipeline atual #{pipelineId}\",\n            \"enter_target\": \"Ambiente de destino para implantação\",\n            \"trigger\": \"Implantação\",\n            \"enter_task\": \"Tarefa de implantação\"\n        },\n        \"enable\": {\n            \"enable\": \"Habilitar\",\n            \"enabled\": \"Já habilitado\",\n            \"list_reloaded\": \"Lista de repositório recarregada\",\n            \"reload\": \"Recarregar repositórios\",\n            \"success\": \"Repositório habilitado\",\n            \"disabled\": \"Desativado\",\n            \"new_forge_repo\": \"novo repositório na forja\",\n            \"stale_wp_repo\": \"repositório Woodpecker desatualizado\",\n            \"conflict\": \"Conflito\",\n            \"conflict_desc\": \"Este repositório foi recriado na forja com um novo ID, mas uma entrada desatualizada com o mesmo nome ainda existe no Woodpecker. Exclua o desatualizado para habilitar o novo, ou corrija o antigo.\",\n            \"forge_repo_missing\": \"O repositório da forja está faltando!\"\n        },\n        \"manual_pipeline\": {\n            \"variables\": {\n                \"add\": \"Adicionar variável\",\n                \"desc\": \"Especifique variáveis adicionais a serem usadas em seu pipeline. As variáveis com o mesmo nome são substituídas.\",\n                \"name\": \"Nome da variável\",\n                \"value\": \"Valor da variável\",\n                \"delete\": \"Excluir variável\",\n                \"title\": \"Variáveis adicionais do pipeline\"\n            },\n            \"title\": \"Executar um pipeline manual\",\n            \"trigger\": \"Executar pipeline\",\n            \"select_branch\": \"Selecionar branch\",\n            \"show_pipelines\": \"Mostrar pipelines\",\n            \"no_manual_workflows\": \"Nenhum fluxo de trabalho correspondente foi encontrado. Certifique-se de que pelo menos um fluxo de trabalho seja executado no evento manual.\"\n        },\n        \"not_allowed\": \"Você não está autorizado a acessar este repositório\",\n        \"open_in_forge\": \"Abrir repositório na forja\",\n        \"settings\": {\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Permitir pull requests\",\n                    \"desc\": \"Permite a execução de pipelines em pull requests.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Cancelar pipelines anteriores\",\n                    \"desc\": \"Permite cancelar pipelines pendentes e em execução do mesmo evento e contexto antes de iniciar o próximo pipeline.\"\n                },\n                \"general\": \"Projeto\",\n                \"project\": \"Configurações de Projeto\",\n                \"protected\": {\n                    \"desc\": \"Todas as pipeline necessitam de aprovação antes de serem executadas.\",\n                    \"protected\": \"Protegido\"\n                },\n                \"save\": \"Salvar configurações\",\n                \"success\": \"Configurações do projeto atualizadas\",\n                \"timeout\": {\n                    \"minutes\": \"minutos\",\n                    \"timeout\": \"Tempo limite\"\n                },\n                \"trusted\": {\n                    \"trusted\": \"Confiável\",\n                    \"desc\": \"Os contêineres de pipeline subjacente obtêm acesso a recursos escalonados, como a montagem de volumes.\",\n                    \"volumes\": {\n                        \"desc\": \"Os contêineres de pipeline podem montar volumes.\",\n                        \"volumes\": \"Volumes\"\n                    },\n                    \"security\": {\n                        \"security\": \"Segurança\",\n                        \"desc\": \"Os contêineres de pipeline têm acesso a privilégios de segurança.\"\n                    },\n                    \"network\": {\n                        \"network\": \"Rede\",\n                        \"desc\": \"Os contêineres de pipeline têm acesso a privilégios de rede, como alteração de DNS.\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Somente usuários autenticados na instância Woodpecker podem ver este projeto.\",\n                        \"internal\": \"Interno\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Somente você e outros donos do repositório podem ver este projeto.\",\n                        \"private\": \"Privado\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Qualquer usuário pode ver seu projeto sem estar logado.\",\n                        \"public\": \"Público\"\n                    },\n                    \"visibility\": \"Visibilidade do projeto\"\n                },\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Plugins que obtêm acesso para credenciais netrc que podem ser usadas para clonar repositórios da forja ou fazer push para a forja.\",\n                    \"netrc_only_trusted\": \"Contêineres confiáveis personalizados\"\n                },\n                \"pipeline_path\": {\n                    \"path\": \"Caminho do pipeline\",\n                    \"default\": \"Por padrão: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Caminho para a configuração do pipeline (por exemplo, {0}). As pastas devem terminar com {1}.\",\n                    \"desc_path_example\": \"meu/caminho/\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Permitir implantações\",\n                    \"desc\": \"Permite implantações para pipelines bem-sucedidos. Todos os usuários com permissões de fazer push podem acioná-las, então use com cautela.\"\n                }\n            },\n            \"not_allowed\": \"Você não está autorizado a acessar as configurações deste repositório\",\n            \"secrets\": {\n                \"add\": \"Adicionar secret\",\n                \"created\": \"Secret criado\",\n                \"delete\": \"Excluir secret\",\n                \"delete_confirm\": \"Você realmente deseja excluir este secret?\",\n                \"deleted\": \"Secret excluído\",\n                \"edit\": \"Editar secret\",\n                \"images\": {\n                    \"images\": \"Disponíveis para as seguintes imagens\",\n                    \"desc\": \"Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens\"\n                },\n                \"name\": \"Nome\",\n                \"save\": \"Salvar secret\",\n                \"saved\": \"Secret salvo\",\n                \"show\": \"Exibir secrets\",\n                \"value\": \"Valor\",\n                \"desc\": \"Os segredos podem ser passados em tempo de execução como variáveis de ambiente para etapas individuais no pipeline.\",\n                \"secrets\": \"Segredos\",\n                \"none\": \"Não há segredos ainda.\",\n                \"events\": {\n                    \"events\": \"Disponível nos seguintes eventos\",\n                    \"pr_warning\": \"Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos.\"\n                },\n                \"plugins_only\": \"Disponível apenas para plugins\"\n            },\n            \"settings\": \"Configurações\",\n            \"crons\": {\n                \"desc\": \"As tarefas Cron podem ser usados para acionar pipelines regularmente.\",\n                \"delete\": \"Excluir cron\",\n                \"crons\": \"Crons\",\n                \"show\": \"Mostrar crons\",\n                \"add\": \"Adicionar cron\",\n                \"none\": \"Ainda não existem crons.\",\n                \"save\": \"Salvar cron\",\n                \"created\": \"Cron criado\",\n                \"saved\": \"Cron salvo\",\n                \"deleted\": \"Cron excluído\",\n                \"next_exec\": \"Próxima execução\",\n                \"not_executed_yet\": \"Ainda não foi executado\",\n                \"run\": \"Executar agora\",\n                \"branch\": {\n                    \"title\": \"Branch\",\n                    \"placeholder\": \"Branch (usa o branch padrão se estiver vazio)\"\n                },\n                \"name\": {\n                    \"name\": \"Nome\",\n                    \"placeholder\": \"Nome do trabalho cron\"\n                },\n                \"schedule\": {\n                    \"title\": \"Programação (com base no UTC)\",\n                    \"placeholder\": \"Programação\"\n                },\n                \"edit\": \"Editar cron\",\n                \"enabled\": \"Habilitado\"\n            },\n            \"actions\": {\n                \"repair\": {\n                    \"repair\": \"Reparar repositório\",\n                    \"success\": \"Repositório reparado\"\n                },\n                \"delete\": {\n                    \"confirm\": \"Todos os dados serão perdidos após essa ação!\\n\\nVocê realmente deseja continuar?\",\n                    \"delete\": \"Excluir repositório\",\n                    \"success\": \"Repositório excluído\"\n                },\n                \"actions\": \"Ações\",\n                \"disable\": {\n                    \"disable\": \"Desativar repositório\",\n                    \"success\": \"Repositório desativado\"\n                },\n                \"enable\": {\n                    \"enable\": \"Ativar repositório\",\n                    \"success\": \"Repositório ativado\"\n                }\n            },\n            \"registries\": {\n                \"desc\": \"As credenciais de registries podem ser adicionadas para usar imagens privadas para o seu pipeline.\",\n                \"created\": \"Credenciais de registry criadas\",\n                \"saved\": \"Credenciais de registry salvas\",\n                \"registries\": \"Registries\",\n                \"credentials\": \"Credenciais de registry\",\n                \"show\": \"Mostrar registries\",\n                \"add\": \"Adicionar registry\",\n                \"none\": \"Ainda não há credenciais de registry.\",\n                \"save\": \"Salvar registry\",\n                \"deleted\": \"Credenciais de registry excluídas\",\n                \"address\": {\n                    \"address\": \"Endereço\",\n                    \"placeholder\": \"Endereço de registry (por exemplo, docker.io)\"\n                },\n                \"edit\": \"Editar registry\",\n                \"delete\": \"Excluir registry\"\n            },\n            \"badge\": {\n                \"badge\": \"Emblema\",\n                \"type\": \"Sintaxe\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"branch\": \"Branch\",\n                \"events\": \"Eventos\",\n                \"workflow\": \"Fluxo de trabalho\",\n                \"step\": \"Etapa\"\n            }\n        },\n        \"user_none\": \"Esta organização/usuário ainda não tem projeto\",\n        \"pipeline\": {\n            \"protected\": {\n                \"awaits\": \"Esse pipeline está aguardando a aprovação de um mantenedor!\",\n                \"review\": \"Revisar alterações\",\n                \"approve\": \"Aprovar\",\n                \"decline\": \"Recusar\",\n                \"declined\": \"Esse pipeline foi recusado!\",\n                \"approve_success\": \"Pipeline aprovado\",\n                \"decline_success\": \"Pipeline recusado\"\n            },\n            \"status\": {\n                \"started\": \"iniciado\",\n                \"status\": \"Status: {status}\",\n                \"blocked\": \"bloqueado\",\n                \"pending\": \"pendente\",\n                \"running\": \"executando\",\n                \"skipped\": \"omitido\",\n                \"success\": \"sucesso\",\n                \"declined\": \"recusado\",\n                \"error\": \"erro\",\n                \"failure\": \"falha\",\n                \"killed\": \"finalizado\",\n                \"canceled\": \"cancelada\"\n            },\n            \"we_got_some_errors\": \"Oh não, ocorreu um erro!\",\n            \"step_not_started\": \"Essa etapa ainda não foi iniciada.\",\n            \"log_download_error\": \"Ocorreu um erro ao fazer o download do arquivo de registro\",\n            \"actions\": {\n                \"log_auto_scroll_off\": \"Desabilitar rolagem automática\",\n                \"cancel\": \"Cancelar\",\n                \"restart\": \"Reiniciar\",\n                \"canceled\": \"Essa etapa foi cancelada.\",\n                \"cancel_success\": \"Pipeline cancelado\",\n                \"deploy\": \"Implantação\",\n                \"restart_success\": \"Pipeline reiniciado\",\n                \"log_download\": \"Baixar\",\n                \"log_auto_scroll\": \"Habilitar rolagem automática\",\n                \"log_delete\": \"Excluir\",\n                \"skipped\": \"Essa etapa foi ignorada.\"\n            },\n            \"tasks\": \"Tarefas\",\n            \"config\": \"Configuração\",\n            \"files\": \"Arquivos alterados\",\n            \"no_files\": \"Nenhum arquivo foi alterado.\",\n            \"no_pipelines\": \"Nenhum pipelines foi iniciado ainda.\",\n            \"event\": {\n                \"push\": \"Push\",\n                \"tag\": \"Tag\",\n                \"pr\": \"Pull Request\",\n                \"deploy\": \"Implantação\",\n                \"cron\": \"Cron\",\n                \"manual\": \"Manual\",\n                \"release\": \"Release\",\n                \"pr_closed\": \"Pull request mesclada/fechada\",\n                \"pr_metadata\": \"Metadados da pull request alterados\"\n            },\n            \"errors\": \"Erros\",\n            \"warnings\": \"Avisos\",\n            \"show_errors\": \"Mostrar erros\",\n            \"no_pipeline_steps\": \"Não há etapas de pipeline disponíveis!\",\n            \"pipelines_for\": \"Pipelines para branch \\\"{branch}\\\"\",\n            \"pipelines_for_pr\": \"Pipelines para o pull request #{index}\",\n            \"exit_code\": \"Código de saída {exitCode}\",\n            \"loading\": \"Carregando…\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"log_title\": \"Registros de etapas\",\n            \"no_logs\": \"Nenhum registro\",\n            \"created\": \"Criado: {created}\",\n            \"duration\": \"Duração do pipeline: {duration}\",\n            \"debug\": {\n                \"download_metadata\": \"Download dos metadados\",\n                \"metadata_exec_title\": \"Executar novamente o pipeline localmente\",\n                \"metadata_exec_desc\": \"Faz download dos metadados deste pipeline para executá-lo localmente. Isso permite corrigir problemas e testar alterações antes de enviá-las. O Woodpecker CLI deve ser instalado localmente na mesma versão do servidor.\",\n                \"metadata_download_error\": \"Erro ao fazer download dos metadados\",\n                \"metadata_download_successful\": \"Download dos metadados bem-sucedido\",\n                \"no_permission\": \"Você não tem permissão para acessar as informações de depuração\",\n                \"title\": \"Depuração\"\n            },\n            \"log_delete_confirm\": \"Você realmente deseja excluir os registros de etapas?\",\n            \"log_delete_error\": \"Ocorreu um erro ao excluir os registros de etapas\",\n            \"view\": \"Ver pipeline\",\n            \"cancel_info\": {\n                \"superseded_by\": \"Substituído por #{pipelineId}\",\n                \"canceled_by_user\": \"Cancelado por {user}\",\n                \"canceled_by_step\": \"Cancelada por causa de {step}\"\n            },\n            \"load_more\": \"Carregar mais\",\n            \"version\": \"A versão do Woodpecker na qual este pipeline foi executado.\",\n            \"version_header\": \"Versão do Woodpecker\"\n        },\n        \"branches\": \"Branches\",\n        \"pull_requests\": \"Pull requests\",\n        \"visibility\": {\n            \"private\": {\n                \"private\": \"Privado\",\n                \"desc\": \"Apenas você e outros proprietários do repositório podem ver este projeto.\"\n            },\n            \"visibility\": \"Visibilidade do projeto\",\n            \"public\": {\n                \"public\": \"Público\",\n                \"desc\": \"Qualquer pessoa pode ver seu projeto sem estar autenticado.\"\n            },\n            \"internal\": {\n                \"desc\": \"Apenas usuários autenticados da instância Woodpecker podem ver este projeto.\",\n                \"internal\": \"Interno\"\n            }\n        }\n    },\n    \"repos\": \"Repos\",\n    \"repositories\": {\n        \"title\": \"Repositórios\",\n        \"all\": {\n            \"title\": \"Todos os repositórios\",\n            \"desc\": \"Repositórios ordenados pela última criação de pipeline\"\n        },\n        \"last\": {\n            \"title\": \"Última visita\",\n            \"desc\": \"Repositórios visitados mais recentemente classificados por tempo de acesso\"\n        }\n    },\n    \"search\": \"Buscar…\",\n    \"time\": {\n        \"not_started\": \"não iniciado ainda\",\n        \"template\": \"DD.MM.YYYY, HH:mm z\",\n        \"weeks_short\": \"sem\",\n        \"days_short\": \"d\",\n        \"hours_short\": \"h\",\n        \"min_short\": \"min\",\n        \"sec_short\": \"seg\",\n        \"just_now\": \"agora mesmo\"\n    },\n    \"unknown_error\": \"Ocorreu um erro desconhecido\",\n    \"url\": \"URL\",\n    \"username\": \"Nome de usuário\",\n    \"welcome\": \"Bem-vindo ao Woodpecker\",\n    \"admin\": {\n        \"settings\": {\n            \"queue\": {\n                \"stats\": {\n                    \"completed_count\": \"Tarefas concluídas\",\n                    \"worker_count\": \"Livre\",\n                    \"running_count\": \"Executando\",\n                    \"pending_count\": \"Pendente\",\n                    \"waiting_on_deps_count\": \"Aguardando dependências\"\n                },\n                \"queue\": \"Em fila\",\n                \"desc\": \"Tarefas aguardando para serem executadas pelos agentes.\",\n                \"pause\": \"Pausa\",\n                \"resume\": \"Retomar\",\n                \"paused\": \"A fila está em pausa\",\n                \"resumed\": \"A fila foi retomada\",\n                \"tasks\": \"Tarefas\",\n                \"task_running\": \"A tarefa está em execução\",\n                \"task_pending\": \"Tarefa pendente\",\n                \"task_waiting_on_deps\": \"A tarefa está aguardando as dependências\",\n                \"agent\": \"agente\",\n                \"waiting_for\": \"esperando por\"\n            },\n            \"users\": {\n                \"show\": \"Mostrar usuários\",\n                \"delete_confirm\": \"Você realmente deseja excluir esse usuário? Isso também excluirá todos os repositórios pertencentes a esse usuário.\",\n                \"created\": \"Usuário criado\",\n                \"edit_user\": \"Editar usuário\",\n                \"users\": \"Usuários\",\n                \"desc\": \"Usuários registrados para este servidor.\",\n                \"login\": \"Login\",\n                \"email\": \"Email\",\n                \"avatar_url\": \"URL do avatar\",\n                \"save\": \"Salvar usuário\",\n                \"cancel\": \"Cancelar\",\n                \"add\": \"Adicionar usuário\",\n                \"none\": \"Ainda não há usuários.\",\n                \"deleted\": \"Usuário excluído\",\n                \"saved\": \"Usuário salvo\",\n                \"admin\": {\n                    \"admin\": \"Admin\",\n                    \"placeholder\": \"O usuário é um administrador\"\n                },\n                \"delete_user\": \"Excluir usuário\"\n            },\n            \"orgs\": {\n                \"delete_org\": \"Excluir organização\",\n                \"delete_confirm\": \"Você deseja realmente excluir essa organização? Isso também excluirá todos os repositórios pertencentes a essa organização.\",\n                \"orgs\": \"Organizações\",\n                \"desc\": \"Organizações proprietárias de repositórios neste servidor.\",\n                \"none\": \"Ainda não existem organizações.\",\n                \"org_settings\": \"Configurações da organização\",\n                \"deleted\": \"Organização excluída\",\n                \"view\": \"Ver organização\"\n            },\n            \"secrets\": {\n                \"deleted\": \"Segredo global excluído\",\n                \"images\": {\n                    \"desc\": \"Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens\",\n                    \"images\": \"Disponível para as seguintes imagens\"\n                },\n                \"secrets\": \"Segredos\",\n                \"desc\": \"Os segredos globais podem ser usados nos pipelines de todos os repositórios.\",\n                \"warning\": \"Esses segredos estão disponíveis para todos os usuários.\",\n                \"none\": \"Ainda não há segredos globais.\",\n                \"add\": \"Adicionar segredo\",\n                \"save\": \"Salvar segredo\",\n                \"show\": \"Mostrar segredos\",\n                \"name\": \"Nome\",\n                \"value\": \"Valor\",\n                \"created\": \"Segredo global criado\",\n                \"saved\": \"Segredo global salvo\",\n                \"plugins_only\": \"Disponível apenas para plugins\",\n                \"events\": {\n                    \"events\": \"Disponível nos seguintes eventos\",\n                    \"pr_warning\": \"Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos.\"\n                }\n            },\n            \"agents\": {\n                \"show\": \"Mostrar agentes\",\n                \"name\": {\n                    \"name\": \"Nome\",\n                    \"placeholder\": \"Nome do agente\"\n                },\n                \"no_schedule\": {\n                    \"placeholder\": \"Impedir agente de assumir novas tarefas\",\n                    \"name\": \"Desativar agente\"\n                },\n                \"delete_confirm\": \"Você realmente deseja excluir esse agente? Ele não conseguirá mais se conectar ao servidor.\",\n                \"agents\": \"Agentes\",\n                \"desc\": \"Agentes registrados neste servidor.\",\n                \"none\": \"Ainda não há agentes.\",\n                \"id\": \"ID\",\n                \"add\": \"Adicionar agente\",\n                \"save\": \"Salvar agente\",\n                \"created\": \"Agente criado\",\n                \"saved\": \"Agente salvo\",\n                \"deleted\": \"Agente excluído\",\n                \"token\": \"Token\",\n                \"platform\": {\n                    \"platform\": \"Plataforma\",\n                    \"badge\": \"plataforma\"\n                },\n                \"backend\": {\n                    \"backend\": \"Backend\",\n                    \"badge\": \"backend\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"Capacidade\",\n                    \"desc\": \"A quantidade máxima de pipelines paralelos executados por esse agente.\",\n                    \"badge\": \"capacidade\"\n                },\n                \"version\": \"Versão\",\n                \"last_contact\": {\n                    \"last_contact\": \"Último contato\",\n                    \"badge\": \"último contato\"\n                },\n                \"never\": \"Nunca\",\n                \"edit_agent\": \"Editar agente\",\n                \"delete_agent\": \"Excluir agente\",\n                \"org\": {\n                    \"badge\": \"org\"\n                },\n                \"custom_labels\": {\n                    \"custom_labels\": \"Etiquetas personalizadas\",\n                    \"desc\": \"As etiquetas personalizadas definidas pelo administrador do agente na inicialização do agente.\"\n                }\n            },\n            \"settings\": \"Configurações administrativas\",\n            \"not_allowed\": \"Você não tem permissão para acessar as configurações do servidor\",\n            \"repos\": {\n                \"repos\": \"Repositórios\",\n                \"desc\": \"Repositórios que estão ou estavam ativados neste servidor.\",\n                \"none\": \"Ainda não há repositórios.\",\n                \"view\": \"Ver repositório\",\n                \"settings\": \"Configurações do repositório\",\n                \"disabled\": \"Desativado\",\n                \"repair\": {\n                    \"repair\": \"Reparar todos\",\n                    \"success\": \"Repositórios reparados\"\n                }\n            },\n            \"registries\": {\n                \"desc\": \"Credenciais de registry global podem ser adicionadas para usar imagens privadas para todos os pipelines.\",\n                \"warning\": \"Essas credenciais de registry estão disponíveis para todos os usuários.\"\n            }\n        }\n    },\n    \"org\": {\n        \"settings\": {\n            \"secrets\": {\n                \"secrets\": \"Segredos\",\n                \"desc\": \"Os segredos da organização podem ser usados no pipeline de todos os repositórios desta organização.\",\n                \"images\": {\n                    \"desc\": \"Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens\",\n                    \"images\": \"Disponível para as seguintes imagens\"\n                },\n                \"events\": {\n                    \"pr_warning\": \"Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos.\",\n                    \"events\": \"Disponível nos seguintes eventos\"\n                },\n                \"none\": \"Ainda não há segredos de organização.\",\n                \"add\": \"Adicionar segredo\",\n                \"save\": \"Salvar segredo\",\n                \"show\": \"Mostrar segredos\",\n                \"name\": \"Nome\",\n                \"value\": \"Valor\",\n                \"deleted\": \"Segredo da organização excluído\",\n                \"created\": \"Segredo da organização criado\",\n                \"saved\": \"Segredo da organização salvo\",\n                \"plugins_only\": \"Disponível apenas para plugins\"\n            },\n            \"settings\": \"Configurações\",\n            \"not_allowed\": \"Você não tem permissão para acessar as configurações desta organização\",\n            \"agents\": {\n                \"desc\": \"Agentes registrados para esta organização.\"\n            },\n            \"registries\": {\n                \"desc\": \"As credenciais de registry da organização podem ser adicionados para usar imagens privadas para todos os pipelines de uma organização.\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"empty_list\": \"Nenhuma {entity} encontrada!\",\n    \"pipeline_feed\": \"Relatório de atividades de pipeline\",\n    \"user\": {\n        \"settings\": {\n            \"secrets\": {\n                \"desc\": \"Os segredos do usuário podem ser usados nos pipeline de todos os repositórios do usuário.\",\n                \"images\": {\n                    \"desc\": \"Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens\",\n                    \"images\": \"Disponível para as seguintes imagens\"\n                },\n                \"secrets\": \"Segredos\",\n                \"none\": \"Ainda não há segredos de usuário.\",\n                \"add\": \"Adicionar segredo\",\n                \"save\": \"Salvar segredo\",\n                \"show\": \"Mostrar segredos\",\n                \"name\": \"Nome\",\n                \"value\": \"Valor\",\n                \"deleted\": \"Segredo do usuário excluído\",\n                \"created\": \"Segredo de usuário criado\",\n                \"saved\": \"Segredo do usuário salvo\",\n                \"plugins_only\": \"Disponível apenas para plugins\",\n                \"events\": {\n                    \"events\": \"Disponível nos seguintes eventos\",\n                    \"pr_warning\": \"Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos.\"\n                }\n            },\n            \"api\": {\n                \"desc\": \"Token de acesso pessoal e uso da API\",\n                \"token\": \"Token de acesso pessoal\",\n                \"shell_setup_before\": \"Execute as etapas de configuração do shell antes\",\n                \"api\": \"API\",\n                \"shell_setup\": \"Configuração do shell\",\n                \"api_usage\": \"Exemplo de uso da API\",\n                \"cli_usage\": \"Exemplo de uso da CLI\",\n                \"dl_cli\": \"Download da CLI\",\n                \"reset_token\": \"Redefinir token\",\n                \"swagger_ui\": \"Swagger UI\"\n            },\n            \"settings\": \"Configurações do usuário\",\n            \"general\": {\n                \"general\": \"Conta\",\n                \"language\": \"Idioma\",\n                \"theme\": {\n                    \"theme\": \"Tema\",\n                    \"light\": \"Claro\",\n                    \"dark\": \"Escuro\",\n                    \"auto\": \"Auto\"\n                }\n            },\n            \"registries\": {\n                \"desc\": \"Credenciais de registry de usuário podem ser adicionadas para usar imagens privadas para todos os pipelines pessoais.\"\n            },\n            \"cli_and_api\": {\n                \"cli_usage\": \"Exemplo de uso da CLI\",\n                \"download_cli\": \"Baixar CLI\",\n                \"reset_token\": \"Redefinir token\",\n                \"cli_and_api\": \"CLI & API\",\n                \"desc\": \"Uso de token de acesso pessoal, CLI e API\",\n                \"token\": \"Token de acesso pessoal\",\n                \"api_usage\": \"Exemplo de uso da API\",\n                \"swagger_ui\": \"Swagger UI\"\n            },\n            \"agents\": {\n                \"desc\": \"Os agentes registrados para os repositórios da sua conta.\"\n            }\n        },\n        \"oauth_error\": \"Erro ao autenticar no provedor OAuth\",\n        \"internal_error\": \"Ocorreu um erro interno\",\n        \"access_denied\": \"Você não tem permissão para fazer login\"\n    },\n    \"update_woodpecker\": \"Atualize sua instância do Woodpecker para {0}\",\n    \"default\": \"padrão\",\n    \"info\": \"Info\",\n    \"running_version\": \"Você está executando Woodpecker {0}\",\n    \"global_level_secret\": \"segredo global\",\n    \"org_level_secret\": \"segredo da organização\",\n    \"registries\": {\n        \"add\": \"Adicionar registry\",\n        \"save\": \"Salvar registry\",\n        \"registries\": \"Registries\",\n        \"saved\": \"Credenciais de registry salvas\",\n        \"deleted\": \"Credenciais de registry excluídas\",\n        \"view\": \"Ver registry\",\n        \"edit\": \"Editar registry\",\n        \"delete\": \"Excluir registry\",\n        \"delete_confirm\": \"Você realmente deseja excluir esse registry?\",\n        \"created\": \"Credenciais de registry criadas\",\n        \"desc\": \"Credenciais de registry podem ser adicionadas para usar imagens privadas para pipelines.\",\n        \"credentials\": \"Credenciais de registry\",\n        \"none\": \"Ainda não há credenciais de registry.\",\n        \"address\": {\n            \"address\": \"Endereço\",\n            \"desc\": \"Endereço de registry (p.ex., docker.io)\"\n        },\n        \"show\": \"Mostrar registries\"\n    },\n    \"login_to_cli_description\": \"Se continuar, você será conectado à CLI.\",\n    \"cli_login_failed\": \"Login na CLI falhou\",\n    \"secrets\": {\n        \"add\": \"Adicionar segredo\",\n        \"plugins\": {\n            \"images\": \"Disponível apenas para os seguintes plugins\",\n            \"desc\": \"Lista de imagens de plugins onde este segredo está disponível. Deixe em branco para permitir todos os plugins e etapas normais.\"\n        },\n        \"events\": {\n            \"events\": \"Disponível apenas nos eventos a seguir\",\n            \"warning\": \"Expor segredos para pull requests pode permitir que pessoas mal-intencionadas roubem seus segredos com um pull request malicioso.\"\n        },\n        \"secrets\": \"Segredos\",\n        \"none\": \"Ainda não há segredos.\",\n        \"value\": \"Valor\",\n        \"edit\": \"Editar segredo\",\n        \"save\": \"Salvar segredo\",\n        \"show\": \"Mostrar segredos\",\n        \"name\": \"Nome\",\n        \"deleted\": \"Segredo excluído\",\n        \"delete_confirm\": \"Você realmente deseja excluir este segredo?\",\n        \"created\": \"Segredo criado\",\n        \"saved\": \"Segredo salvo\",\n        \"desc\": \"Segredos podem ser usados em todos os pipelines deste repositório.\",\n        \"delete\": \"Excluir segredo\",\n        \"note\": \"Nota\"\n    },\n    \"require_approval\": {\n        \"allowed_users\": {\n            \"allowed_users\": \"Usuários permitidos\",\n            \"desc\": \"Os pipelines criados pelos usuários listados nunca exigem aprovação.\"\n        },\n        \"pull_requests\": \"Todas pull requests\",\n        \"none\": \"Nenhum\",\n        \"require_approval_for\": \"Requisitos para aprovação\",\n        \"none_desc\": \"Todo evento aciona pipelines, incluindo pull requests. Essa configuração pode ser perigosa e é recomendada apenas para instâncias privadas.\",\n        \"forks\": \"Pull request de forks\",\n        \"desc\": \"Evite que pipelines maliciosos exponham segredos ou executem tarefas prejudiciais aprovando-as antes da execução.\",\n        \"all_events\": \"Todos eventos da forja\"\n    },\n    \"access_denied\": \"Você não tem permissão para acessar esta instância\",\n    \"no_search_results\": \"Nenhum resultado encontrado\",\n    \"invalid_state\": \"O estado de OAuth é inválido\",\n    \"org_access_denied\": \"Você não tem permissão para acessar esta organização\",\n    \"login_to_cli\": \"Acessar com CLI\",\n    \"abort\": \"Abortar\",\n    \"cli_login_success\": \"Login na CLI bem-sucedido\",\n    \"settings\": \"Configurações\",\n    \"oauth_error\": \"Erro ao autenticar no provedor de OAuth\",\n    \"return_to_cli\": \"Agora você pode fechar esta aba e retornar à CLI.\",\n    \"internal_error\": \"Ocorreu um erro interno\",\n    \"registration_closed\": \"O registro está fechado\",\n    \"login_with\": \"Acessar com {forge}\",\n    \"cli_login_denied\": \"Login na CLI negado\",\n    \"forges\": \"Forjas\",\n    \"add_forge\": \"Adicionar forja\",\n    \"show_forges\": \"Mostrar forjas\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Complemento\",\n    \"forge_type\": \"Tipo de forja\",\n    \"oauth_host\": \"Host de OAuth\",\n    \"public_only_desc\": \"Mostra apenas repositórios públicos.\",\n    \"public_only\": \"Públicos apenas\",\n    \"git_username\": \"Nome de usuário Git\",\n    \"git_username_desc\": \"Nome de usuário do usuário Git.\",\n    \"git_password\": \"Senha Git\",\n    \"git_password_desc\": \"Senha ou token de acesso pessoal do usuário Git.\",\n    \"executable\": \"Executável\",\n    \"save\": \"Salvar\",\n    \"add\": \"Adicionar\",\n    \"skip_verify\": \"Pular verificação SSL\",\n    \"forge_managed_by_env\": \"A forja principal é gerenciada por variáveis de ambiente. Quaisquer alterações nesta forja serão revertidas na reinicialização.\",\n    \"oauth_redirect_uri\": \"URI de redirecionamento de OAuth\",\n    \"forge_created\": \"Forja criada\",\n    \"advanced_options\": \"Opções avançadas\",\n    \"forge_deleted\": \"Forja excluída\",\n    \"edit_forge\": \"Editar forja\",\n    \"delete_forge\": \"Excluir forja\",\n    \"no_forges\": \"Não há forja ainda.\",\n    \"use_this_redirect_uri_to_create\": \"Use este URI de redirecionamento para criar ou atualizar o aplicativo OAuth. Acesse {0} e configure o aplicativo OAuth.\",\n    \"developer_settings\": \"configurações do desenvolvedor\",\n    \"public_url_for_oauth_if\": \"URL pública para OAuth se diferente da URL ({0})\",\n    \"forge_saved\": \"Forja salva\",\n    \"forges_desc\": \"Configura forjas hospedando repositórios para os quais Woodpecker deve ser executado.\",\n    \"executable_desc\": \"Caminho do executável do complemento.\",\n    \"forge_delete_confirm\": \"Você realmente deseja excluir esta forja? Isso também excluirá todos os repositórios, usuários e pipelines relacionados a esta forja.\",\n    \"oauth_client_id\": \"ID de cliente OAuth\",\n    \"oauth_client_secret\": \"Segredo de cliente OAuth\",\n    \"merge_ref\": \"Ref da mesclagem\",\n    \"leave_empty_to_keep_current_value\": \"Deixe em branco para manter o valor\",\n    \"merge_ref_desc\": \"Referência a ser usada para mesclar a base. Isso é usado para determinar a diferença entre pull requests.\",\n    \"skip_verify_desc\": \"Pula verificação SSL para a conexão API. Isso não é recomendado para uso em produção.\",\n    \"login_to_woodpecker_with\": \"Entrar no Woodpecker com\",\n    \"fullscreen\": \"Tela cheia\",\n    \"exit_fullscreen\": \"Sair de tela cheia\",\n    \"oauth_redirect_url\": \"URL de redirecionamento de OAuth\",\n    \"use_this_redirect_url_to_create\": \"Use esta URL de redirecionamento para criar ou atualizar o aplicativo OAuth.\",\n    \"developer_settings_to_create\": \"Vá para {0} e configure o aplicativo OAuth.\",\n    \"weblate\": \"nosso Weblate\",\n    \"help_translating\": \"Você pode ajudar a traduzir Woodpecker para o seu idioma em {0}.\",\n    \"extensions\": \"Extensões\",\n    \"extensions_description\": \"Extensões são serviços HTTP que podem ser chamados pelo Woodpecker em vez de usar os nativos.\",\n    \"extension_endpoint_placeholder\": \"p.ex., https://example.com/api\",\n    \"config_extension_endpoint\": \"Endpoint da extensão de configuração\",\n    \"extensions_signatures_public_key\": \"Chave pública para assinaturas\",\n    \"extensions_signatures_public_key_description\": \"Esta chave pública deve ser usada por suas extensões para verificar se o chamadas de webhook do Woodpecker.\",\n    \"extensions_configuration_saved\": \"Configuração de extensões salva\",\n    \"disabled\": \"Desativado\",\n    \"config_extension_exclusive\": \"Exclusiva\",\n    \"config_extension_exclusive_desc\": \"Se habilitada, essa opção ignorará todas as outras formas de obter configurações, incluindo a forja.\",\n    \"global_level_registry\": \"registry global\",\n    \"org_level_registry\": \"registry de organização\",\n    \"registry_extension_endpoint\": \"Endpoint da extensão de registry\",\n    \"secret_extension_endpoint\": \"Endpoint da extensão de segredos\",\n    \"secret_extension_netrc\": \"Incluir cerdenciais de netrc\",\n    \"secret_extension_netrc_desc\": \"Envia as credenciais de netrc da forja para a extensão de segredos.\",\n    \"extension_netrc\": \"Incluir credenciais netrc\",\n    \"extension_netrc_desc\": \"Envia credenciais netrc da forja para a extensão.\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/ru.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"Добавить обработчик\",\n                \"agents\": \"Обработчики\",\n                \"backend\": {\n                    \"backend\": \"Бэкенд\",\n                    \"badge\": \"бэкенд\"\n                },\n                \"capacity\": {\n                    \"badge\": \"мощность\",\n                    \"capacity\": \"Мощность\",\n                    \"desc\": \"Максимальное количество конвейеров, выполняемых параллельно этим обработчиком.\"\n                },\n                \"created\": \"Обработчик успешно добавлен\",\n                \"delete_agent\": \"Удалить обработчик\",\n                \"delete_confirm\": \"Вы действительно хотите удалить этот обработчик? Он больше не сможет подключаться к серверу.\",\n                \"deleted\": \"Обработчик успешно удалён\",\n                \"desc\": \"Обработчики, зарегистрированные на этом сервере.\",\n                \"edit_agent\": \"Редактировать обработчик\",\n                \"id\": \"ID\",\n                \"last_contact\": {\n                    \"badge\": \"доступен\",\n                    \"last_contact\": \"Последняя связь\"\n                },\n                \"name\": {\n                    \"name\": \"Название\",\n                    \"placeholder\": \"Название обработчика\"\n                },\n                \"never\": \"Никогда\",\n                \"no_schedule\": {\n                    \"name\": \"Отключить обработчик\",\n                    \"placeholder\": \"Запретить обработчику получать новые задачи\"\n                },\n                \"none\": \"Обработчиков пока нет.\",\n                \"platform\": {\n                    \"badge\": \"платформа\",\n                    \"platform\": \"Платформа\"\n                },\n                \"save\": \"Сохранить обработчик\",\n                \"saved\": \"Обработчик сохранён\",\n                \"show\": \"Показать обработчики\",\n                \"token\": \"Токен\",\n                \"version\": \"Версия\",\n                \"org\": {\n                    \"badge\": \"орг\"\n                },\n                \"custom_labels\": {\n                    \"custom_labels\": \"Пользовательские Метки\",\n                    \"desc\": \"Пользовательские метки, установленные администратором обработчика при его запуске.\"\n                }\n            },\n            \"not_allowed\": \"У вас нет прав доступа к настройкам сервера\",\n            \"orgs\": {\n                \"delete_confirm\": \"Вы действительно хотите удалить эту организацию? При этом также будут удалены все репозитории, принадлежащие этой организации.\",\n                \"delete_org\": \"Удалить организацию\",\n                \"deleted\": \"Организация удалена\",\n                \"desc\": \"Организации, владеющие репозиториями на этом сервере.\",\n                \"none\": \"Организаций пока нет.\",\n                \"org_settings\": \"Настройки организации\",\n                \"orgs\": \"Организации\",\n                \"view\": \"Просмотр организации\"\n            },\n            \"queue\": {\n                \"agent\": \"обработчик\",\n                \"desc\": \"Задачи, ожидающие выполнения обработчиками.\",\n                \"pause\": \"Приостановить\",\n                \"paused\": \"Очередь приостановлена\",\n                \"queue\": \"Очередь\",\n                \"resume\": \"Продолжить\",\n                \"resumed\": \"Очередь возобновлена\",\n                \"stats\": {\n                    \"completed_count\": \"Завершённые задачи\",\n                    \"pending_count\": \"Ожидает\",\n                    \"running_count\": \"Выполняется\",\n                    \"waiting_on_deps_count\": \"Ожидает зависимостей\",\n                    \"worker_count\": \"Свободно\"\n                },\n                \"task_pending\": \"Задача ожидает\",\n                \"task_running\": \"Задача выполняется\",\n                \"task_waiting_on_deps\": \"Задача ожидает завершения выполнения зависимостей\",\n                \"tasks\": \"Задачи\",\n                \"waiting_for\": \"в ожидании\"\n            },\n            \"repos\": {\n                \"desc\": \"Репозитории, когда-либо включавшиеся на этом сервере.\",\n                \"disabled\": \"Отключено\",\n                \"none\": \"Репозиториев пока нет.\",\n                \"repair\": {\n                    \"repair\": \"Исправить все\",\n                    \"success\": \"Репозитории исправлены\"\n                },\n                \"repos\": \"Репозитории\",\n                \"settings\": \"Настройки репозитория\",\n                \"view\": \"Просмотр репозитория\"\n            },\n            \"secrets\": {\n                \"add\": \"Создать секрет\",\n                \"created\": \"Глобальный секрет создан\",\n                \"deleted\": \"Глобальный секрет удалён\",\n                \"desc\": \"Глобальные секреты могут использоваться во всех конвейерах всех репозиториев.\",\n                \"events\": {\n                    \"events\": \"Доступен для следующих событий\",\n                    \"pr_warning\": \"Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты.\"\n                },\n                \"images\": {\n                    \"desc\": \"Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы\",\n                    \"images\": \"Доступен только для этих образов\"\n                },\n                \"name\": \"Название\",\n                \"none\": \"Тут пока нет глобальных секретов.\",\n                \"plugins_only\": \"Доступен только для расширений\",\n                \"save\": \"Сохранить секрет\",\n                \"saved\": \"Глобальный секрет сохранён\",\n                \"secrets\": \"Секреты\",\n                \"show\": \"Показать секрет\",\n                \"value\": \"Значение\",\n                \"warning\": \"Эти секреты доступны всем пользователям.\"\n            },\n            \"settings\": \"Настройки\",\n            \"users\": {\n                \"add\": \"Добавить пользователя\",\n                \"admin\": {\n                    \"admin\": \"Администратор\",\n                    \"placeholder\": \"Пользователь является администратором\"\n                },\n                \"avatar_url\": \"URL аватара\",\n                \"cancel\": \"Отмена\",\n                \"created\": \"Пользователь успешно создан\",\n                \"delete_confirm\": \"Вы действительно хотите удалить этого пользователя? При этом также будут удалены все репозитории, принадлежащие этому пользователю.\",\n                \"delete_user\": \"Удалить пользователя\",\n                \"deleted\": \"Пользователь успешно удалён\",\n                \"desc\": \"Пользователи, зарегистрированные на этом сервере.\",\n                \"edit_user\": \"Изменить пользователя\",\n                \"email\": \"Почта\",\n                \"login\": \"Вход в систему\",\n                \"none\": \"Пользователей пока нет.\",\n                \"save\": \"Сохранить пользователя\",\n                \"saved\": \"Пользователь сохранён\",\n                \"show\": \"Показать пользователей\",\n                \"users\": \"Пользователи\"\n            },\n            \"registries\": {\n                \"desc\": \"Можно добавить глобальные учётные данные реестра, чтобы иметь возможность использовать частные образы во всех конвейерах.\",\n                \"warning\": \"Эти учётные данные реестра доступны всем пользователям.\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"Назад\",\n    \"cancel\": \"Отменить\",\n    \"default\": \"по умолчанию\",\n    \"docs\": \"Документация\",\n    \"documentation_for\": \"Документация о «{topic}»\",\n    \"errors\": {\n        \"not_found\": \"Серверу не удалось найти запрошенный объект\"\n    },\n    \"global_level_secret\": \"глобальный секрет\",\n    \"info\": \"Информация\",\n    \"login\": \"Вход\",\n    \"logout\": \"Выйти\",\n    \"not_found\": {\n        \"back_home\": \"Вернуться на главную\",\n        \"not_found\": \"Ошибка 404. Проверьте, что ввели адрес правильно :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"У вас нет доступа к настройкам этой организации\",\n            \"secrets\": {\n                \"add\": \"Создать секрет\",\n                \"created\": \"Секрет организации успешно добавлен\",\n                \"deleted\": \"Секрет организации был удалён\",\n                \"desc\": \"Секреты организации могут использоваться в конвейерах всех репозиториев, принадлежащих организации.\",\n                \"events\": {\n                    \"events\": \"Доступен для следующих событий\",\n                    \"pr_warning\": \"Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты.\"\n                },\n                \"images\": {\n                    \"desc\": \"Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы\",\n                    \"images\": \"Доступен только для этих образов\"\n                },\n                \"name\": \"Название\",\n                \"none\": \"Тут пока нет секретов организации.\",\n                \"plugins_only\": \"Доступен только для расширений\",\n                \"save\": \"Сохранить секрет\",\n                \"saved\": \"Секрет организации успешно обновлён\",\n                \"secrets\": \"Секреты\",\n                \"show\": \"Показать секрет\",\n                \"value\": \"Значение\"\n            },\n            \"settings\": \"Настройки\",\n            \"registries\": {\n                \"desc\": \"Можно добавить учётные данные реестра для организации, чтобы иметь возможность использовать частные образы во всех конвейерах этой организации.\"\n            },\n            \"agents\": {\n                \"desc\": \"Обработчики, зарегистрированные для этой организации.\"\n            }\n        }\n    },\n    \"org_level_secret\": \"секрет организации\",\n    \"password\": \"Пароль\",\n    \"pipeline_feed\": \"Состояние конвейеров\",\n    \"repo\": {\n        \"activity\": \"Активность\",\n        \"add\": \"Подключить репозиторий\",\n        \"branches\": \"Ветви\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Целевое окружение для развёртывания\",\n            \"title\": \"Вызвать развёртывание для текущего конвейера #{pipelineId}\",\n            \"trigger\": \"Развернуть\",\n            \"variables\": {\n                \"add\": \"Добавить переменную\",\n                \"desc\": \"Укажите дополнительные переменные для использования в конвейере. Переменные с совпадающими именами будут перезаписаны.\",\n                \"name\": \"Имя переменной\",\n                \"title\": \"Дополнительные переменные для конвейера\",\n                \"value\": \"Значение переменной\",\n                \"delete\": \"Удалить переменную\"\n            },\n            \"enter_task\": \"Задача на развёртывание\"\n        },\n        \"enable\": {\n            \"disabled\": \"Отключено\",\n            \"enable\": \"Подключить\",\n            \"enabled\": \"Уже подключен\",\n            \"list_reloaded\": \"Обновить список репозиториев\",\n            \"reload\": \"Обновить репозитории\",\n            \"success\": \"Репозиторий подключен\",\n            \"new_forge_repo\": \"новый репозиторий на платформе\",\n            \"stale_wp_repo\": \"устаревший репозиторий Woodpecker\",\n            \"conflict\": \"Конфликт\",\n            \"forge_repo_missing\": \"Репозиторий не найден в платформе!\",\n            \"conflict_desc\": \"Этот репозиторий был пересоздан в платформе с новым ID, но в Woodpecker всё ещё существует устаревшая запись с тем же именем. Удалите устаревшую запись, чтобы активировать новую, либо восстановите старую.\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Выберите ветвь\",\n            \"title\": \"Запустить конвейер вручную\",\n            \"trigger\": \"Запустить конвейер\",\n            \"variables\": {\n                \"add\": \"Добавить переменную\",\n                \"desc\": \"Укажите дополнительные переменные для использования в конвейере. Переменные с совпадающими именами будут перезаписаны.\",\n                \"name\": \"Имя переменной\",\n                \"title\": \"Дополнительные переменные для конвейера\",\n                \"value\": \"Значение переменной\",\n                \"delete\": \"Удалить переменную\"\n            },\n            \"show_pipelines\": \"Показать конвейеры\",\n            \"no_manual_workflows\": \"Не найдено подходящих рабочих процессов. Убедитесь, что хотя бы один процесс можно запустить вручную событием manual.\"\n        },\n        \"not_allowed\": \"У вас нет прав для доступа к этому репозиторию\",\n        \"open_in_forge\": \"Открыть репозиторий в платформе разработки\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Отменить\",\n                \"cancel_success\": \"Конвейер отменён\",\n                \"canceled\": \"Этот шаг был отменён.\",\n                \"deploy\": \"Развёртывание\",\n                \"log_auto_scroll\": \"Включить автоматическую прокрутку\",\n                \"log_auto_scroll_off\": \"Отключить автоматическую прокрутку\",\n                \"log_download\": \"Скачать\",\n                \"restart\": \"Перезапустить\",\n                \"restart_success\": \"Конвейер перезапущен\",\n                \"log_delete\": \"Удалить\",\n                \"skipped\": \"Этот шаг был пропущен.\"\n            },\n            \"config\": \"Конфигурация\",\n            \"errors\": \"Ошибки\",\n            \"event\": {\n                \"cron\": \"Задание cron\",\n                \"deploy\": \"Развёртывание (деплой)\",\n                \"manual\": \"Ручной запуск\",\n                \"pr\": \"Запросы на слияние\",\n                \"push\": \"Новый коммит\",\n                \"tag\": \"Тег\",\n                \"pr_closed\": \"Запрос на слияние удовлетворён/закрыт\",\n                \"release\": \"Релиз\",\n                \"pr_metadata\": \"Изменены метаданные запроса на слияние\"\n            },\n            \"exit_code\": \"Код завершения {exitCode}\",\n            \"files\": \"Изменённые файлы\",\n            \"loading\": \"Загрузка…\",\n            \"log_download_error\": \"Произошла ошибка при скачивании файла журнала\",\n            \"log_title\": \"Журнал шага\",\n            \"no_files\": \"Никакие файлы не были изменены.\",\n            \"no_pipeline_steps\": \"Нет доступных шагов конвейера!\",\n            \"no_pipelines\": \"Ни один конвейер ещё не запущен.\",\n            \"pipeline\": \"Конвейер №{pipelineId}\",\n            \"pipelines_for\": \"Конвейеры для ветви «{branch}»\",\n            \"pipelines_for_pr\": \"Конвейер для запроса на слияние №{index}\",\n            \"protected\": {\n                \"approve\": \"Подтвердить\",\n                \"approve_success\": \"Конвейер подтверждён\",\n                \"awaits\": \"Конвейер ожидает подтверждения разработчиком!\",\n                \"decline\": \"Отклонить\",\n                \"decline_success\": \"Конвейер отклонён\",\n                \"declined\": \"Этот конвейер был отклонён!\",\n                \"review\": \"Обзор изменений\"\n            },\n            \"show_errors\": \"Показать ошибки\",\n            \"status\": {\n                \"blocked\": \"заблокирован\",\n                \"declined\": \"отклонён\",\n                \"error\": \"ошибка\",\n                \"failure\": \"провален\",\n                \"killed\": \"принудительно завершён\",\n                \"pending\": \"ожидает\",\n                \"running\": \"выполняется\",\n                \"skipped\": \"пропущен\",\n                \"started\": \"запущен\",\n                \"status\": \"Состояние: {status}\",\n                \"success\": \"успешно выполнен\",\n                \"canceled\": \"отменено\"\n            },\n            \"step_not_started\": \"Этот шаг ещё не запущен.\",\n            \"tasks\": \"Задачи\",\n            \"warnings\": \"Предупреждения\",\n            \"we_got_some_errors\": \"О нет, возникла ошибка!\",\n            \"no_logs\": \"Нет записей журнала\",\n            \"created\": \"Создано: {created}\",\n            \"duration\": \"Время работы конвейера: {duration}\",\n            \"log_delete_confirm\": \"Вы действительно хотите удалить журналы этого шага?\",\n            \"log_delete_error\": \"При удалении журналов шага произошла ошибка\",\n            \"debug\": {\n                \"metadata_exec_desc\": \"Скачайте метаданные конвейера, чтобы запустить его на своей системе. Это даст вам возможность отладить проблемы и проверить изменения перед их публикацией. У Вас должен быть установлен `woodpecker-cli`, версия которого совпадает с версией Woodpecker на сервере.\",\n                \"title\": \"Отладка\",\n                \"download_metadata\": \"Скачать метаданные\",\n                \"metadata_download_successful\": \"Метаданные успешно загружены\",\n                \"metadata_download_error\": \"Ошибка загрузки метаданных\",\n                \"no_permission\": \"У вас нет доступа к отладочной информации\",\n                \"metadata_exec_title\": \"Перезапустить конвейер локально\"\n            },\n            \"view\": \"Посмотреть конвейер\",\n            \"cancel_info\": {\n                \"canceled_by_user\": \"Отменено {user}\",\n                \"superseded_by\": \"Заменено на #{pipelineId}\",\n                \"canceled_by_step\": \"Отменено из-за шага {step}\"\n            },\n            \"load_more\": \"Загрузить ещё\",\n            \"version_header\": \"Версия Woodpecker\",\n            \"version\": \"Версия Woodpecker, на которой был выполнен этот конвейер.\"\n        },\n        \"pull_requests\": \"Запросы на слияние\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Действия\",\n                \"delete\": {\n                    \"confirm\": \"Все данные будут потеряны после этого действия!\\n\\nВы действительно хотите продолжить?\",\n                    \"delete\": \"Удалить репозиторий\",\n                    \"success\": \"Репозиторий удалён\"\n                },\n                \"disable\": {\n                    \"disable\": \"Отключить репозиторий\",\n                    \"success\": \"Репозиторий отключен\"\n                },\n                \"enable\": {\n                    \"enable\": \"Включить репозиторий\",\n                    \"success\": \"Репозиторий включён\"\n                },\n                \"repair\": {\n                    \"repair\": \"Восстановить репозиторий\",\n                    \"success\": \"Репозиторий восстановлен\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Бейдж\",\n                \"branch\": \"Ветвь\",\n                \"type\": \"Синтаксис\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\",\n                \"events\": \"События\",\n                \"workflow\": \"Рабочий процесс\",\n                \"step\": \"Шаг\"\n            },\n            \"crons\": {\n                \"add\": \"Добавить задачу cron\",\n                \"branch\": {\n                    \"placeholder\": \"Ветвь (если пусто, используется ветвь по умолчанию)\",\n                    \"title\": \"Ветвь\"\n                },\n                \"created\": \"Задача cron создана\",\n                \"crons\": \"Задачи cron\",\n                \"delete\": \"Удалить задачу cron\",\n                \"deleted\": \"Задача cron удалена\",\n                \"desc\": \"Задачи cron можно использовать для регулярного запуска конвейеров.\",\n                \"edit\": \"Редактировать задачу cron\",\n                \"name\": {\n                    \"name\": \"Название\",\n                    \"placeholder\": \"Имя задачи cron\"\n                },\n                \"next_exec\": \"Следующий запуск\",\n                \"none\": \"Пока нет ни одной задачи cron.\",\n                \"not_executed_yet\": \"Ещё не запущено\",\n                \"run\": \"Запустить сейчас\",\n                \"save\": \"Сохранить задачу cron\",\n                \"saved\": \"Задача cron сохранена\",\n                \"schedule\": {\n                    \"placeholder\": \"Расписание\",\n                    \"title\": \"Расписание (по UTC)\"\n                },\n                \"show\": \"Показать задачи cron\",\n                \"enabled\": \"Включено\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Разрешить запросы на слияние\",\n                    \"desc\": \"Разрешить запуск конвейеров для запросов на слияние.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Принудительно завершить все предыдущие конвейеры\",\n                    \"desc\": \"Выбранные триггеры отменят ожидающие и запущенные конвейеры того же события, после чего будет запущен следующий конвейер.\"\n                },\n                \"general\": \"Проект\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"Плагины, которые получат доступ к учётным данным netrc, которые можно использовать для клонирования или публикации кода на платформу.\",\n                    \"netrc_only_trusted\": \"Только доверенные плагины клонирования\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"По умолчанию: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml\",\n                    \"desc\": \"Путь к конфигурации вашего конвейера (например: {0}). При указании директории путь должен заканчиваться символом {1}.\",\n                    \"desc_path_example\": \"мой/путь/\",\n                    \"path\": \"Конфигурация конвейера\"\n                },\n                \"project\": \"Настройки проекта\",\n                \"protected\": {\n                    \"desc\": \"Каждый конвейер должен быть проверен до начала выполнения.\",\n                    \"protected\": \"Защищён\"\n                },\n                \"save\": \"Сохранить настройки\",\n                \"success\": \"Настройки проекта обновлены\",\n                \"timeout\": {\n                    \"minutes\": \"минуты\",\n                    \"timeout\": \"Время ожидания\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Доверенные конвейеры получат доступ к дополнительным возможностям (например, монтированию томов).\",\n                    \"trusted\": \"Доверенный\",\n                    \"network\": {\n                        \"network\": \"Сеть\",\n                        \"desc\": \"Контейнеры конвейера получат доступ к сетевым привилегиям, таким как изменение настроек DNS.\"\n                    },\n                    \"security\": {\n                        \"security\": \"Безопасность\",\n                        \"desc\": \"Контейнеры конвейера получат доступ к привилегиям безопасности.\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"Томы\",\n                        \"desc\": \"Контейнеры конвейера получат доступ к привилегиям томов.\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Только пользователи, вошедшие в систему, смогут видеть этот проект.\",\n                        \"internal\": \"Внутренний\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Только вы и другие владельцы этого репозитория смогут видеть его.\",\n                        \"private\": \"Приватный\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Любой незарегистрированный пользователь сможет увидеть этот проект.\",\n                        \"public\": \"Публичный\"\n                    },\n                    \"visibility\": \"Видимость проекта\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Разрешить развёртывание\",\n                    \"desc\": \"Разрешить развёртывание для успешных конвейеров. Будьте осторожны: их сможет запустить любой пользователь с правами push.\"\n                }\n            },\n            \"not_allowed\": \"У вас нет доступа к настройкам этого репозитория\",\n            \"registries\": {\n                \"add\": \"Добавить реестр\",\n                \"address\": {\n                    \"address\": \"Адрес\",\n                    \"placeholder\": \"Адрес реестра (например: docker.io)\"\n                },\n                \"created\": \"Данные для доступа к реестру добавлены\",\n                \"credentials\": \"Учётные данные для авторизации в реестре\",\n                \"delete\": \"Удалить реестр\",\n                \"deleted\": \"Данные для доступа к реестру удалены\",\n                \"desc\": \"Можно добавить учетные данные для доступа к реестру, чтобы использовать приветные образы из этого реестра в конвейере.\",\n                \"edit\": \"Изменить реестр\",\n                \"none\": \"Пока тут нет учётных данных для доступа к реестрам.\",\n                \"registries\": \"Реестры с образами\",\n                \"save\": \"Сохранить реестр\",\n                \"saved\": \"Данные для доступа к реестру сохранены\",\n                \"show\": \"Показать реестры\"\n            },\n            \"secrets\": {\n                \"add\": \"Создать секрет\",\n                \"created\": \"Секрет создан\",\n                \"delete\": \"Удалить секрет\",\n                \"delete_confirm\": \"Вы действительно хотите удалить этот секрет?\",\n                \"deleted\": \"Секрет успешно удалён\",\n                \"desc\": \"Секреты могут быть переданы отдельным этапам конвейера во время выполнения в качестве переменных окружения.\",\n                \"edit\": \"Изменить секрет\",\n                \"events\": {\n                    \"events\": \"Доступен для следующих событий\",\n                    \"pr_warning\": \"Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты.\"\n                },\n                \"images\": {\n                    \"desc\": \"Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы\",\n                    \"images\": \"Доступен только для этих образов\"\n                },\n                \"name\": \"Название\",\n                \"none\": \"Пока тут нет секретов.\",\n                \"plugins_only\": \"Доступен только для расширений\",\n                \"save\": \"Сохранить секрет\",\n                \"saved\": \"Секрет успешно сохранён\",\n                \"secrets\": \"Секреты\",\n                \"show\": \"Показать секрет\",\n                \"value\": \"Значение\"\n            },\n            \"settings\": \"Настройки\"\n        },\n        \"user_none\": \"Эта организация/пользователь не имеет ни одного проекта\",\n        \"visibility\": {\n            \"visibility\": \"Видимость проекта\",\n            \"public\": {\n                \"public\": \"Публичный\",\n                \"desc\": \"Любой желающий сможет увидеть ваш проект, не входя в систему.\"\n            },\n            \"private\": {\n                \"private\": \"Закрытый\",\n                \"desc\": \"Проект доступен только вам и другим владельцам репозитория.\"\n            },\n            \"internal\": {\n                \"internal\": \"Внутренний\",\n                \"desc\": \"Проект доступен только вошедшим пользователям этого экземпляра Woodpecker.\"\n            }\n        }\n    },\n    \"repos\": \"Репозитории\",\n    \"repositories\": {\n        \"title\": \"Репозитории\",\n        \"all\": {\n            \"title\": \"Все репозитории\",\n            \"desc\": \"Репозитории, отсортированные по времени последнего создания конвейера\"\n        },\n        \"last\": {\n            \"desc\": \"Последние посещённые репозитории, отсортированные по времени доступа\",\n            \"title\": \"Последние посещённые\"\n        }\n    },\n    \"running_version\": \"Вы используете Woodpecker {0}\",\n    \"search\": \"Поиск…\",\n    \"time\": {\n        \"days_short\": \"д.\",\n        \"hours_short\": \"ч.\",\n        \"min_short\": \"мин.\",\n        \"not_started\": \"ещё не запускался\",\n        \"sec_short\": \"сек.\",\n        \"template\": \"D MMM, YYYY, HH:mm z\",\n        \"weeks_short\": \"нед.\",\n        \"just_now\": \"только что\"\n    },\n    \"unknown_error\": \"Произошла неизвестная ошибка\",\n    \"update_woodpecker\": \"Пожалуйста, обновите свой экземпляр Woodpecker до {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"У вас нет прав для входа в систему\",\n        \"internal_error\": \"Произошла внутренняя ошибка\",\n        \"oauth_error\": \"Ошибка при аутентификации через OAuth провайдера\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"Пример использования API\",\n                \"cli_usage\": \"Пример использования CLI\",\n                \"desc\": \"Токен персонального доступа и использование API\",\n                \"dl_cli\": \"Загрузить CLI\",\n                \"reset_token\": \"Сбросить токен\",\n                \"shell_setup\": \"Настройка оболочки\",\n                \"shell_setup_before\": \"выполните шаги по настройке оболочки перед\",\n                \"swagger_ui\": \"Интерфейс Swagger\",\n                \"token\": \"Токен персонального доступа\"\n            },\n            \"general\": {\n                \"general\": \"Учётная запись\",\n                \"language\": \"Язык\",\n                \"theme\": {\n                    \"auto\": \"Авто\",\n                    \"dark\": \"Тёмная\",\n                    \"light\": \"Светлая\",\n                    \"theme\": \"Тема\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"Добавить секрет\",\n                \"created\": \"Секрет пользователя создан\",\n                \"deleted\": \"Секрет пользователя удален\",\n                \"desc\": \"Пользовательские секреты могут использоваться во всех репозиториях, принадлежащих пользователю.\",\n                \"events\": {\n                    \"events\": \"Доступно на следующих событий\",\n                    \"pr_warning\": \"Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты.\"\n                },\n                \"images\": {\n                    \"desc\": \"Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы\",\n                    \"images\": \"Доступно для следующих образов\"\n                },\n                \"name\": \"Имя\",\n                \"none\": \"Секретов пользователей пока нет.\",\n                \"plugins_only\": \"Доступно только для плагинов\",\n                \"save\": \"Сохранить секрет\",\n                \"saved\": \"Секрет пользователя сохранен\",\n                \"secrets\": \"Секреты\",\n                \"show\": \"Показать секреты\",\n                \"value\": \"Значение\"\n            },\n            \"settings\": \"Настройки пользователя\",\n            \"cli_and_api\": {\n                \"token\": \"Персональный токен доступа\",\n                \"cli_and_api\": \"Командная строка и API\",\n                \"desc\": \"Персональный токен доступа, командная строка и API\",\n                \"download_cli\": \"Скачать интерфейс командной строки\",\n                \"cli_usage\": \"Пример использования командной строки\",\n                \"api_usage\": \"Пример использования API\",\n                \"reset_token\": \"Сбросить токен\",\n                \"swagger_ui\": \"Интерфейс Swagger\"\n            },\n            \"registries\": {\n                \"desc\": \"Добавление учётных данных реестра для пользователя даст возможность использовать частные образы во всех конвейерах пользователя.\"\n            },\n            \"agents\": {\n                \"desc\": \"Обработчики, зарегистрированные для репозиториев вашей учётной записи.\"\n            }\n        }\n    },\n    \"username\": \"Имя пользователя\",\n    \"welcome\": \"Добро пожаловать в Woodpecker\",\n    \"secrets\": {\n        \"desc\": \"Секреты могут использоваться всеми конфейерами этого репозитория.\",\n        \"save\": \"Сохранить секрет\",\n        \"show\": \"Показать секреты\",\n        \"name\": \"Имя\",\n        \"value\": \"Значение\",\n        \"delete_confirm\": \"Вы действительно хотите удалить этот секрет?\",\n        \"deleted\": \"Секрет удалён\",\n        \"created\": \"Секрет создан\",\n        \"saved\": \"Секрет сохранён\",\n        \"add\": \"Добавить секрет\",\n        \"images\": {\n            \"images\": \"Доступно следующим образам\",\n            \"desc\": \"Список образов, которым доступен этот секрет; оставьте пустым, чтобы разрешить доступ всем образам.\"\n        },\n        \"secrets\": \"Секреты\",\n        \"none\": \"Секретов пока нет.\",\n        \"events\": {\n            \"events\": \"Доступно следующим событиям\",\n            \"pr_warning\": \"Пожалуйста, будьте осторожны с этой опцией: злоумышленник может раскрыть ваши секреты, отправив вредоносный запрос на слияние.\",\n            \"warning\": \"Раскрытие секретов запросам на слияние может позволить злоумышленникам украсть ваши секреты с помощью вредоносного запроса.\"\n        },\n        \"edit\": \"Редактировать секрет\",\n        \"delete\": \"Удалить секрет\",\n        \"plugins\": {\n            \"images\": \"Доступно только следующим плагинам\",\n            \"desc\": \"Список образов плагинов, которым доступен этот секрет. Оставьте значение пустым, чтобы разрешить доступ для всем плагинам и для обычным шагам конвейера.\"\n        },\n        \"note\": \"Комментарий\"\n    },\n    \"internal_error\": \"Произошла внутренняя ошибка\",\n    \"registration_closed\": \"Регистрация закрыта\",\n    \"registries\": {\n        \"address\": {\n            \"desc\": \"Адрес реестра (например, docker.io)\",\n            \"address\": \"Адрес\"\n        },\n        \"show\": \"Показать реестры\",\n        \"save\": \"Сохранить реестр\",\n        \"add\": \"Добавить реестр\",\n        \"view\": \"Просмотр реестра\",\n        \"none\": \"Учётных данных реестра пока нет.\",\n        \"registries\": \"Реестры\",\n        \"credentials\": \"Учётные данные реестра\",\n        \"desc\": \"Можно добавить учётные данные реестра, чтобы использовать частные образы в конвейерах.\",\n        \"edit\": \"Редактировать реестр\",\n        \"delete\": \"Удалить реестр\",\n        \"delete_confirm\": \"Вы действительно хотите удалить этот реестр?\",\n        \"created\": \"Учётные данные реестра созданы\",\n        \"saved\": \"Учётные данные реестра сохранены\",\n        \"deleted\": \"Учётные данные реестра удалены\"\n    },\n    \"login_to_cli\": \"Вход через командную строку\",\n    \"abort\": \"Прервать\",\n    \"cli_login_success\": \"Успешный вход в интерфейс командной строки\",\n    \"cli_login_failed\": \"Вход в интерфейс командной строки не удался\",\n    \"return_to_cli\": \"Теперь вы можете закрыть эту вкладку и вернуться в командную строку.\",\n    \"settings\": \"Настройки\",\n    \"login_to_cli_description\": \"Если вы продолжите, то войдёте в интерфейс командной строки.\",\n    \"cli_login_denied\": \"Вход через командную строку запрещён\",\n    \"invalid_state\": \"Некорректное состояние OAuth\",\n    \"oauth_error\": \"Ошибка при аутентификации в провайдере OAuth\",\n    \"access_denied\": \"Вам не разрешён доступ к этому экземпляру\",\n    \"empty_list\": \"{entity} не найдены!\",\n    \"login_with\": \"Вход через {forge}\",\n    \"all_repositories\": \"Все репозитории\",\n    \"no_search_results\": \"Результаты не найдены\",\n    \"require_approval\": {\n        \"forks\": \"Запрос на слияние из форка\",\n        \"pull_requests\": \"Все запросы на слияние\",\n        \"all_events\": \"Все события платформы\",\n        \"none_desc\": \"Каждое событие инициирует выполнение конвейеров, включая запросы на слияние. Эта небезопасная настройка, рекомендуемая только для частных экземпляров Woodpecker.\",\n        \"none\": \"Нет\",\n        \"desc\": \"Предотвратить утечку конфиденциальных данных или выполнение вредоносных задач через ручное подтверждение перед началом выполнения.\",\n        \"require_approval_for\": \"Требования подтверждения\",\n        \"allowed_users\": {\n            \"allowed_users\": \"Разрешённые пользователи\",\n            \"desc\": \"Конвейеры, созданные перечисленными пользователями, не требуют подтверждения.\"\n        }\n    },\n    \"org_access_denied\": \"У вас нет доступа к этой организации\",\n    \"show_forges\": \"Показать платформы\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"addon\": \"Дополнение\",\n    \"oauth_client_id\": \"OAuth Client ID\",\n    \"public_only\": \"Только публичные\",\n    \"public_only_desc\": \"Показывать только публичные репозитории.\",\n    \"git_username\": \"Имя пользователя Git\",\n    \"git_password\": \"Пароль Git\",\n    \"executable\": \"Исполняемый файл\",\n    \"executable_desc\": \"Путь к исполняемому файлу расширения.\",\n    \"save\": \"Сохранить\",\n    \"add\": \"Добавить\",\n    \"skip_verify\": \"Пропустить проверку SSL\",\n    \"forge_managed_by_env\": \"Основная платформа управляется переменными окружения. Любые изменения этой платформы будут отменены при перезапуске.\",\n    \"oauth_redirect_uri\": \"URI перенаправления OAuth\",\n    \"forge_created\": \"Платформа создана\",\n    \"oauth_client_secret\": \"Секрет клиента OAuth\",\n    \"merge_ref\": \"Ref слияния\",\n    \"forge_deleted\": \"Платформа удалена\",\n    \"edit_forge\": \"Редактировать платформу\",\n    \"delete_forge\": \"Удалить платформу\",\n    \"no_forges\": \"Платформ пока нет.\",\n    \"developer_settings\": \"настройки разработчика\",\n    \"public_url_for_oauth_if\": \"Публичная URL для OAuth, если отличается от URL ({0})\",\n    \"forge_saved\": \"Платформа сохранена\",\n    \"forges\": \"Платформы\",\n    \"forge_type\": \"Тип платформы\",\n    \"git_username_desc\": \"Имя для пользователя Git.\",\n    \"skip_verify_desc\": \"Пропустить проверку SSL для подключения API. Не рекомендуется использовать в рабочей среде.\",\n    \"add_forge\": \"Добавить платформу\",\n    \"oauth_host\": \"Сервер OAuth\",\n    \"advanced_options\": \"Расширенные настройки\",\n    \"forge_delete_confirm\": \"Вы действительно хотите удалить эту платформу? Её удаление также повлечёт удаление привязанных к ней репозиториев, пользователей и конвейеров.\",\n    \"git_password_desc\": \"Пароль или персональный токен доступа для пользователя Git.\",\n    \"use_this_redirect_uri_to_create\": \"Используйте эту URI перенаправления для создания или обновления приложения OAuth. Перейдите на {0} и настройте приложение OAuth.\",\n    \"leave_empty_to_keep_current_value\": \"Оставьте пустым, чтобы сохранить текущее значение\",\n    \"forges_desc\": \"Настройте платформы c репозиториями, для которых требуется выполнение Woodpecker.\",\n    \"merge_ref_desc\": \"Ref-основание для слияния. Используется для определения изменений в запросах на слияние.\",\n    \"login_to_woodpecker_with\": \"Войти в Woodpecker через\",\n    \"fullscreen\": \"Полноэкранный режим\",\n    \"exit_fullscreen\": \"Выйти из полноэкранного режима\",\n    \"oauth_redirect_url\": \"URL перенаправления OAuth\",\n    \"weblate\": \"нашем Weblate\",\n    \"help_translating\": \"Вы можете помочь с переводом Woodpecker на свой язык на {0}.\",\n    \"use_this_redirect_url_to_create\": \"Используйте эту URL перенаправления для создания или обновления приложения OAuth.\",\n    \"developer_settings_to_create\": \"Перейдите в {0} и настройте приложение OAuth.\",\n    \"extensions\": \"Расширения\",\n    \"extensions_description\": \"Расширения — это HTTP-службы, которые Woodpecker может вызывать вместо использования встроенных служб.\",\n    \"extension_endpoint_placeholder\": \"например, https://example.com/api\",\n    \"extensions_signatures_public_key\": \"Открытый ключ для подписей\",\n    \"extensions_signatures_public_key_description\": \"Этот открытый ключ должен использоваться вашими расширениями для проверки запросов, отправляемых Woodpecker.\",\n    \"extensions_configuration_saved\": \"Настройки расширений сохранены\",\n    \"disabled\": \"Отключено\",\n    \"config_extension_endpoint\": \"Конечная точка расширения конфигурации\",\n    \"config_extension_exclusive_desc\": \"Отключает все другие способы получения конфигурации, в том числе от платформы.\",\n    \"config_extension_exclusive\": \"Эксклюзивно\",\n    \"registry_extension_endpoint\": \"Конечная точка расширения реестра\",\n    \"global_level_registry\": \"глобальный реестр\",\n    \"org_level_registry\": \"реестр организации\",\n    \"secret_extension_endpoint\": \"Конечная точка расширения секретов\",\n    \"secret_extension_netrc\": \"Включить учётные данные netrc\",\n    \"secret_extension_netrc_desc\": \"Отправлять расширению учётные данные netrc платформы.\",\n    \"extension_netrc_desc\": \"Передавать учётные данные netrc расширению.\",\n    \"extension_netrc\": \"Включить учётные данные netrc\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/uk.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"not_allowed\": \"Ви не маєте права доступу до налаштувань сервера\",\n            \"secrets\": {\n                \"add\": \"Додати секрет\",\n                \"created\": \"Створено глобальний секрет\",\n                \"deleted\": \"Глобальну таємницю видалено\",\n                \"desc\": \"Глобальні секрети можуть бути передані всім сховищам, окремим крокам конвеєра під час виконання як змінні середовища.\",\n                \"events\": {\n                    \"events\": \"Доступно на наступних заходах\",\n                    \"pr_warning\": \"Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети.\"\n                },\n                \"images\": {\n                    \"desc\": \"Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення\",\n                    \"images\": \"Доступно для наступних зображень\"\n                },\n                \"name\": \"Найменування\",\n                \"none\": \"Глобальних секретів поки що не існує.\",\n                \"save\": \"Зберегти секрет\",\n                \"saved\": \"Глобальний секрет збережено\",\n                \"secrets\": \"Секрети\",\n                \"show\": \"Показати секрети\",\n                \"value\": \"Значення\",\n                \"warning\": \"Ці секрети будуть доступні для всіх користувачів сервера.\"\n            },\n            \"settings\": \"Налаштування\",\n            \"agents\": {\n                \"id\": \"ID\",\n                \"name\": {\n                    \"name\": \"Назва\"\n                },\n                \"platform\": {\n                    \"platform\": \"Платформа\",\n                    \"badge\": \"платформа\"\n                },\n                \"version\": \"Версія\",\n                \"never\": \"Ніколи\"\n            },\n            \"queue\": {\n                \"queue\": \"Черга\",\n                \"tasks\": \"Завдання\"\n            }\n        }\n    },\n    \"back\": \"Назад\",\n    \"cancel\": \"Скасувати\",\n    \"docs\": \"Документи\",\n    \"documentation_for\": \"Документація для \\\"{topic}\\\"\",\n    \"errors\": {\n        \"not_found\": \"Серверу не вдалося знайти запитуваний об'єкт\"\n    },\n    \"login\": \"Логін\",\n    \"logout\": \"Вихід\",\n    \"not_found\": {\n        \"back_home\": \"Повертаємося додому\",\n        \"not_found\": \"Ого 404, або ми щось зламали, або у вас помилка при наборі тексту :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"Ви не маєте права доступу до налаштувань цієї організації\",\n            \"secrets\": {\n                \"add\": \"Додати секрет\",\n                \"created\": \"Секрет організації збережено\",\n                \"deleted\": \"Організаційну таємницю видалено\",\n                \"desc\": \"Секрети організації можуть бути передані всім окремим крокам конвеєра сховища організації під час виконання як змінні середовища.\",\n                \"events\": {\n                    \"events\": \"Доступно на наступних заходах\",\n                    \"pr_warning\": \"Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети.\"\n                },\n                \"images\": {\n                    \"desc\": \"Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення\",\n                    \"images\": \"Доступно для наступних зображень\"\n                },\n                \"name\": \"Найменування\",\n                \"none\": \"Секретів організації поки що немає.\",\n                \"save\": \"Зберегти секрет\",\n                \"saved\": \"Секрет організації збережено\",\n                \"secrets\": \"Секрети\",\n                \"show\": \"Показати секрети\",\n                \"value\": \"Значення\"\n            },\n            \"settings\": \"Налаштування\"\n        }\n    },\n    \"password\": \"Пароль\",\n    \"pipeline_feed\": \"Трубопровідна подача\",\n    \"repo\": {\n        \"activity\": \"Активність\",\n        \"add\": \"Додати репозиторій\",\n        \"branches\": \"Відділення\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"Цільове середовище розгортання\",\n            \"variables\": {\n                \"add\": \"Додати змінну\",\n                \"desc\": \"Вкажіть додаткові змінні для використання у конвеєрі. Змінні з однаковими іменами буде перезаписано.\",\n                \"title\": \"Додаткові змінні конвеєра\",\n                \"delete\": \"Видалити змінну\",\n                \"name\": \"Ім'я змінної\",\n                \"value\": \"Змінне значення\"\n            },\n            \"enter_task\": \"Завдання на розгортання\",\n            \"title\": \"Запустити розгортання для поточного конвеєра #{pipelineId}\",\n            \"trigger\": \"Розгорнути\"\n        },\n        \"enable\": {\n            \"enable\": \"Увімкнути\",\n            \"enabled\": \"Вже увімкнено\",\n            \"list_reloaded\": \"Оновлений список репозиторіїв\",\n            \"reload\": \"Перезавантажити репозиторії\",\n            \"success\": \"Репозиторій увімкнено\",\n            \"disabled\": \"Вимкнено\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"Оберіть відділення\",\n            \"title\": \"Запустити ручний прогін трубопроводу\",\n            \"trigger\": \"Запуск конвеєра\",\n            \"variables\": {\n                \"add\": \"Додати змінну\",\n                \"desc\": \"Вкажіть додаткові змінні, які будуть використовуватися у вашому конвеєрі. Змінні з однаковими іменами перезаписуються.\",\n                \"name\": \"Ім'я змінної\",\n                \"title\": \"Додаткові змінні трубопроводу\",\n                \"value\": \"Значення змінної\",\n                \"delete\": \"Вилучити змінну\"\n            }\n        },\n        \"not_allowed\": \"Ви не маєте права доступу до цього сховища\",\n        \"open_in_forge\": \"Відкрити репозиторій у системі керування версіями\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"Скасувати\",\n                \"cancel_success\": \"Трубопровід скасовано\",\n                \"canceled\": \"Цей крок було скасовано.\",\n                \"log_auto_scroll\": \"Автоматична прокрутка вниз\",\n                \"log_auto_scroll_off\": \"Вимкнути автоматичну прокрутку\",\n                \"log_download\": \"Завантажити\",\n                \"restart\": \"Перезапуск\",\n                \"restart_success\": \"Трубопровід перезапущено\"\n            },\n            \"config\": \"Конфіг\",\n            \"event\": {\n                \"cron\": \"Крон\",\n                \"deploy\": \"Розгорнути\",\n                \"manual\": \"Посібник\",\n                \"pr\": \"Запит на вилучення\",\n                \"push\": \"Натисни\",\n                \"tag\": \"Тег\"\n            },\n            \"exit_code\": \"код виходу {exitCode}\",\n            \"files\": \"Змінені файли ({files})\",\n            \"loading\": \"Загрузка…\",\n            \"log_download_error\": \"Виникла помилка при завантаженні лог-файлу\",\n            \"no_files\": \"Жодні файли не були змінені.\",\n            \"no_pipeline_steps\": \"Сходинки трубопроводу відсутні!\",\n            \"no_pipelines\": \"Жоден трубопровід ще не був запущений.\",\n            \"pipeline\": \"Трубопровід #{pipelineId}\",\n            \"pipelines_for\": \"Трубопроводи для відгалуження \\\"{branch}\\\"\",\n            \"protected\": {\n                \"approve\": \"Затвердити\",\n                \"approve_success\": \"Трубопровід схвалено\",\n                \"awaits\": \"Цей трубопровід чекає на погодження якогось експлуатаційника!\",\n                \"decline\": \"Спад\",\n                \"decline_success\": \"Трубопровід відхилено\",\n                \"declined\": \"Від цього газопроводу відмовилися!\"\n            },\n            \"step_not_started\": \"Цей крок ще не розпочався.\",\n            \"tasks\": \"Задачі\",\n            \"pipelines_for_pr\": \"Конвеєри для запиту на пул #{index}\",\n            \"status\": {\n                \"blocked\": \"заблоковано\",\n                \"pending\": \"на розгляді\",\n                \"error\": \"помилка\",\n                \"failure\": \"невдача\"\n            },\n            \"errors\": \"Помилки\",\n            \"warnings\": \"Попередження\"\n        },\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"Дії\",\n                \"delete\": {\n                    \"confirm\": \"Всі дані будуть втрачені після цієї дії!!!\\n\\nВи дійсно хочете продовжити?\",\n                    \"delete\": \"Видалити сховище\",\n                    \"success\": \"Репозиторій видалено\"\n                },\n                \"disable\": {\n                    \"disable\": \"Відключити репозиторій\",\n                    \"success\": \"Репозиторій відключено\"\n                },\n                \"repair\": {\n                    \"repair\": \"Ремонтний репозиторій\",\n                    \"success\": \"Сховище відремонтовано\"\n                },\n                \"enable\": {\n                    \"enable\": \"Увімкнути репозиторій\",\n                    \"success\": \"Репозиторій увімкнено\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"Бейдж\",\n                \"branch\": \"Філія\",\n                \"type\": \"Синтаксис\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Уцінка\",\n                \"type_url\": \"URL\"\n            },\n            \"crons\": {\n                \"add\": \"Додати cron\",\n                \"branch\": {\n                    \"placeholder\": \"Гілка (використовує гілку за замовчуванням, якщо порожня)\",\n                    \"title\": \"Філія\"\n                },\n                \"created\": \"Створено Cron\",\n                \"crons\": \"Крони\",\n                \"delete\": \"Видалити cron\",\n                \"deleted\": \"Cron видалено\",\n                \"desc\": \"Завдання Cron можна використовувати для регулярного запуску трубопроводів.\",\n                \"edit\": \"Редагувати cron\",\n                \"name\": {\n                    \"name\": \"Назва\",\n                    \"placeholder\": \"Назва cron завдання\"\n                },\n                \"next_exec\": \"Наступне виконання\",\n                \"none\": \"Крон поки що немає.\",\n                \"not_executed_yet\": \"Ще не виконано\",\n                \"save\": \"Зберегти cron\",\n                \"saved\": \"Cron збережено\",\n                \"schedule\": {\n                    \"placeholder\": \"Розклад\",\n                    \"title\": \"Розклад (на основі UTC)\"\n                },\n                \"show\": \"Показати крони\",\n                \"run\": \"Виконати зараз\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"Дозволити запити на витяг\",\n                    \"desc\": \"Конвеєри можуть працювати на основі запитів.\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"Скасувати попередні трубопроводи\",\n                    \"desc\": \"Дозволяє скасовувати відкладені та запущені конвеєри однієї і тієї ж події та контексту перед запуском нового конвеєра.\"\n                },\n                \"general\": \"Генерал\",\n                \"pipeline_path\": {\n                    \"default\": \"За замовчуванням: .woodpecker/*.yml -> .woodpecker.yml\",\n                    \"path\": \"Траса трубопроводу\",\n                    \"desc_path_example\": \"мій/шлях/\"\n                },\n                \"project\": \"Налаштування проекту\",\n                \"protected\": {\n                    \"desc\": \"Кожен трубопровід повинен бути схвалений перед початком будівництва.\",\n                    \"protected\": \"Захищений\"\n                },\n                \"save\": \"Зберегти настройки\",\n                \"success\": \"Оновлено налаштування репозиторію\",\n                \"timeout\": {\n                    \"minutes\": \"хвилини\",\n                    \"timeout\": \"Таймаут\"\n                },\n                \"trusted\": {\n                    \"desc\": \"Кожен трубопровід повинен бути схвалений перед початком будівництва.\",\n                    \"trusted\": \"Довірені\"\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"Цей проект можуть бачити лише авторизовані користувачі інстанції Woodpecker.\",\n                        \"internal\": \"Внутрішній\"\n                    },\n                    \"private\": {\n                        \"desc\": \"Цей проект можете бачити тільки ви та інші власники сховища.\",\n                        \"private\": \"Приватний\"\n                    },\n                    \"public\": {\n                        \"desc\": \"Кожен користувач може побачити ваш проект, не входячи в систему.\",\n                        \"public\": \"Публічні\"\n                    },\n                    \"visibility\": \"Прозорість проекту\"\n                },\n                \"netrc_only_trusted\": {\n                    \"netrc_only_trusted\": \"Вставляйте облікові дані netrc лише в надійні контейнери\",\n                    \"desc\": \"Вставляйте облікові дані netrc лише в надійні контейнери (рекомендовано).\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"Дозволити розгортання\",\n                    \"desc\": \"Дозволити розгортання з успішних конвеєрів. Використовуйте лише в тому випадку, якщо ви довіряєте всім користувачам із доступом push.\"\n                }\n            },\n            \"not_allowed\": \"Ви не маєте права доступу до налаштувань цього сховища\",\n            \"registries\": {\n                \"add\": \"Додати реєстр\",\n                \"address\": {\n                    \"address\": \"Адреса\",\n                    \"placeholder\": \"Адреса реєстру (наприклад, docker.io)\"\n                },\n                \"created\": \"Створено облікові дані реєстру\",\n                \"credentials\": \"Реквізити реєстру\",\n                \"delete\": \"Видалення реєстру\",\n                \"deleted\": \"Видалено облікові дані реєстру\",\n                \"desc\": \"Облікові дані реєстрів можуть бути додані для використання приватних зображень для вашого конвеєра.\",\n                \"edit\": \"Редагування реєстру\",\n                \"none\": \"Повноважень реєстру поки що немає.\",\n                \"registries\": \"Реєстри\",\n                \"save\": \"Зберегти реєстр\",\n                \"saved\": \"Облікові дані реєстру збережено\",\n                \"show\": \"Показати реєстри\"\n            },\n            \"secrets\": {\n                \"add\": \"Додати секрет\",\n                \"created\": \"Секрет створено\",\n                \"delete\": \"Видалити секрет\",\n                \"deleted\": \"Секрет видалено\",\n                \"desc\": \"Секрети можуть бути передані окремим етапам конвеєра під час виконання як змінні середовища.\",\n                \"edit\": \"Секрет редагування\",\n                \"events\": {\n                    \"events\": \"Доступно на наступних заходах\",\n                    \"pr_warning\": \"Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети.\"\n                },\n                \"images\": {\n                    \"desc\": \"Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення\",\n                    \"images\": \"Доступно для наступних зображень\"\n                },\n                \"name\": \"Назва\",\n                \"none\": \"Секретів поки що немає.\",\n                \"save\": \"Зберегти секрет\",\n                \"saved\": \"Секрет збережено\",\n                \"secrets\": \"Секрети\",\n                \"show\": \"Показати секрети\",\n                \"value\": \"Значення\",\n                \"delete_confirm\": \"Ви справді хочете видалити цей секрет?\",\n                \"plugins_only\": \"Доступно лише для плагінів\"\n            },\n            \"settings\": \"Налаштування\"\n        },\n        \"user_none\": \"Ця організація/користувач ще не має проектів.\",\n        \"pull_requests\": \"Запит на пул\"\n    },\n    \"repos\": \"Репозиторії\",\n    \"repositories\": {\n        \"title\": \"Репозиторії\",\n        \"all\": {\n            \"title\": \"Всі репозиторії\"\n        }\n    },\n    \"search\": \"Обшук…\",\n    \"time\": {\n        \"days_short\": \"д\",\n        \"hours_short\": \"г\",\n        \"min_short\": \"хв\",\n        \"not_started\": \"ще не розпочато\",\n        \"sec_short\": \"сек\",\n        \"template\": \"MMM D, РРРР, ГГ:п z\",\n        \"weeks_short\": \"т\",\n        \"just_now\": \"щойно\"\n    },\n    \"unknown_error\": \"Виникла невідома помилка\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"Ви не авторизовані для входу\",\n        \"internal_error\": \"Виникла внутрішня помилка\",\n        \"oauth_error\": \"Помилка під час автентифікації у провайдера OAuth\"\n    },\n    \"username\": \"Ім'я користувача\",\n    \"welcome\": \"Ласкаво просимо до Woodpcker\",\n    \"api\": \"API\",\n    \"empty_list\": \"{entity} не знайдено!\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/zh-Hans.json",
    "content": "{\n    \"admin\": {\n        \"settings\": {\n            \"agents\": {\n                \"add\": \"添加代理\",\n                \"agents\": \"代理\",\n                \"backend\": {\n                    \"backend\": \"后端\",\n                    \"badge\": \"后端\"\n                },\n                \"capacity\": {\n                    \"badge\": \"容量\",\n                    \"capacity\": \"容量\",\n                    \"desc\": \"该 Agent 并行执行流水线的最大数量。\"\n                },\n                \"created\": \"已创建代理\",\n                \"delete_agent\": \"删除 Agent\",\n                \"delete_confirm\": \"你真的要删除该 Agent 吗？删除后它将无法再次连接到此服务器。\",\n                \"deleted\": \"已删除代理\",\n                \"desc\": \"注册到此服务器的代理。\",\n                \"edit_agent\": \"编辑 Agent\",\n                \"id\": \"ID\",\n                \"last_contact\": {\n                    \"last_contact\": \"上次通信\",\n                    \"badge\": \"上次通信\"\n                },\n                \"name\": {\n                    \"name\": \"名称\",\n                    \"placeholder\": \"代理名称\"\n                },\n                \"never\": \"从未\",\n                \"no_schedule\": {\n                    \"name\": \"禁用代理\",\n                    \"placeholder\": \"阻止代理接受新任务\"\n                },\n                \"none\": \"还未添加任何代理。\",\n                \"platform\": {\n                    \"badge\": \"平台\",\n                    \"platform\": \"平台\"\n                },\n                \"save\": \"保存代理\",\n                \"saved\": \"已保存代理\",\n                \"show\": \"查看代理\",\n                \"token\": \"Token\",\n                \"version\": \"版本\",\n                \"custom_labels\": {\n                    \"custom_labels\": \"自定义标签\",\n                    \"desc\": \"Agent 管理员在 agent 启动时设置的自定义标签。\"\n                },\n                \"org\": {\n                    \"badge\": \"组织\"\n                }\n            },\n            \"not_allowed\": \"你没有访问服务器设置的权限\",\n            \"orgs\": {\n                \"delete_confirm\": \"你真的想删除该组织吗？这也将删除该组织拥有的所有存储库。\",\n                \"delete_org\": \"删除组织\",\n                \"deleted\": \"组织已删除\",\n                \"desc\": \"在此服务器上拥有存储库的组织。\",\n                \"none\": \"还没有任何组织。\",\n                \"org_settings\": \"组织设置\",\n                \"orgs\": \"组织\",\n                \"view\": \"查看组织\"\n            },\n            \"queue\": {\n                \"agent\": \"agent\",\n                \"desc\": \"正在等待 Agents 执行的任务。\",\n                \"pause\": \"暂停\",\n                \"paused\": \"队列已暂停\",\n                \"queue\": \"队列\",\n                \"resume\": \"恢复\",\n                \"resumed\": \"队列已恢复\",\n                \"stats\": {\n                    \"completed_count\": \"已完成的任务\",\n                    \"pending_count\": \"等待中\",\n                    \"running_count\": \"运行中\",\n                    \"waiting_on_deps_count\": \"等待依赖项\",\n                    \"worker_count\": \"空闲\"\n                },\n                \"task_pending\": \"任务正在等待中\",\n                \"task_running\": \"任务正在运行\",\n                \"task_waiting_on_deps\": \"任务正在等待依赖项\",\n                \"tasks\": \"任务\",\n                \"waiting_for\": \"正在等待\"\n            },\n            \"repos\": {\n                \"desc\": \"该服务器上已启用或曾经启用的仓库。\",\n                \"disabled\": \"已禁用\",\n                \"none\": \"目前还没有存储库。\",\n                \"repair\": {\n                    \"repair\": \"修复所有\",\n                    \"success\": \"仓库已修复\"\n                },\n                \"repos\": \"仓库\",\n                \"settings\": \"仓库设置\",\n                \"view\": \"查看仓库\"\n            },\n            \"secrets\": {\n                \"add\": \"添加密钥\",\n                \"created\": \"全局密钥已创建\",\n                \"deleted\": \"全局密钥已删除\",\n                \"desc\": \"全局密钥可用于任意仓库的各个流水线。\",\n                \"events\": {\n                    \"events\": \"对以下事件可用\",\n                    \"pr_warning\": \"慎选这个选项：用户可以提交一个恶意推送请求以暴露你的密钥。\"\n                },\n                \"images\": {\n                    \"desc\": \"此密钥可用于此处填写的镜像列表，用逗号分隔，留空以允许所有镜像\",\n                    \"images\": \"可用于以下镜像\"\n                },\n                \"name\": \"名称\",\n                \"none\": \"现在没有全局密钥。\",\n                \"plugins_only\": \"仅对插件有效\",\n                \"save\": \"保存密钥\",\n                \"saved\": \"全局密钥已保存\",\n                \"secrets\": \"密钥\",\n                \"show\": \"显示所有密钥\",\n                \"value\": \"值\",\n                \"warning\": \"该密钥对所有用户可用。\"\n            },\n            \"settings\": \"设置\",\n            \"users\": {\n                \"add\": \"添加用户\",\n                \"admin\": {\n                    \"admin\": \"管理员\",\n                    \"placeholder\": \"此用户是管理员\"\n                },\n                \"avatar_url\": \"头像链接\",\n                \"cancel\": \"取消\",\n                \"created\": \"用户已创建\",\n                \"delete_confirm\": \"你真的想删除这个用户吗？这也将删除该用户拥有的所有存储库。\",\n                \"delete_user\": \"删除用户\",\n                \"deleted\": \"删除用户\",\n                \"desc\": \"在此服务器上注册的用户。\",\n                \"edit_user\": \"编辑用户\",\n                \"email\": \"邮箱\",\n                \"login\": \"登录\",\n                \"none\": \"还没有任何用户。\",\n                \"save\": \"保存用户\",\n                \"saved\": \"已保存用户\",\n                \"show\": \"显示用户\",\n                \"users\": \"用户\"\n            },\n            \"registries\": {\n                \"desc\": \"可以添加全局注册凭据以在所有流水线中使用私有镜像。\",\n                \"warning\": \"这些注册凭据对所有用户可用。\"\n            }\n        }\n    },\n    \"api\": \"API\",\n    \"back\": \"返回\",\n    \"cancel\": \"取消\",\n    \"default\": \"默认\",\n    \"docs\": \"文档\",\n    \"documentation_for\": \"\\\"{topic}\\\"的文档\",\n    \"errors\": {\n        \"not_found\": \"服务器找不到请求的对象\"\n    },\n    \"info\": \"信息\",\n    \"login\": \"登录\",\n    \"logout\": \"退出登录\",\n    \"not_found\": {\n        \"back_home\": \"回到主页\",\n        \"not_found\": \"啊，404，我们搞砸了什么，或者您拼错了什么词 :-/\"\n    },\n    \"org\": {\n        \"settings\": {\n            \"not_allowed\": \"你没有访问组织设置的权限\",\n            \"secrets\": {\n                \"add\": \"添加密钥\",\n                \"created\": \"组织密钥已创建\",\n                \"deleted\": \"组织密钥已删除\",\n                \"desc\": \"组织密钥可用于该组织所有仓库的各个流水线。\",\n                \"events\": {\n                    \"events\": \"对以下事件可用\",\n                    \"pr_warning\": \"慎选这个选项：用户可以提交一个恶意推送请求以暴露你的密钥。\"\n                },\n                \"images\": {\n                    \"desc\": \"此密钥可用于此处填写的镜像列表，用逗号分隔，留空以允许所有镜像\",\n                    \"images\": \"可用于以下镜像\"\n                },\n                \"name\": \"名称\",\n                \"none\": \"没有组织密钥。\",\n                \"plugins_only\": \"仅对插件有效\",\n                \"save\": \"保存密钥\",\n                \"saved\": \"组织密钥已保存\",\n                \"secrets\": \"密钥\",\n                \"show\": \"显示所有密钥\",\n                \"value\": \"值\"\n            },\n            \"settings\": \"设置\",\n            \"agents\": {\n                \"desc\": \"注册到此组织代理。\"\n            },\n            \"registries\": {\n                \"desc\": \"可以为组织添加注册表凭据，以便在所有流水线中使用私有镜像。\"\n            }\n        }\n    },\n    \"password\": \"密码\",\n    \"pipeline_feed\": \"流水线视图\",\n    \"repo\": {\n        \"activity\": \"活动\",\n        \"add\": \"添加仓库\",\n        \"branches\": \"分支\",\n        \"deploy_pipeline\": {\n            \"enter_target\": \"目标部署环境\",\n            \"title\": \"触发当前流水线 #{pipelineId} 的部署\",\n            \"trigger\": \"部署\",\n            \"variables\": {\n                \"add\": \"添加变量\",\n                \"desc\": \"指定传递到流水线中的变量。相同名称的变量会被覆盖。\",\n                \"name\": \"变量名\",\n                \"title\": \"添加流水线变量\",\n                \"value\": \"变量值\",\n                \"delete\": \"删除变量\"\n            },\n            \"enter_task\": \"部署任务\"\n        },\n        \"enable\": {\n            \"disabled\": \"已禁用\",\n            \"enable\": \"启用\",\n            \"enabled\": \"已经启用了\",\n            \"list_reloaded\": \"仓库列表已刷新\",\n            \"reload\": \"刷新项目列表\",\n            \"success\": \"此仓库已成功启用\",\n            \"new_forge_repo\": \"代码托管平台上的新仓库\",\n            \"stale_wp_repo\": \"Woodpecker 仓库已过期\",\n            \"conflict\": \"冲突\",\n            \"conflict_desc\": \"该仓库已在代码托管平台上使用新 ID 重新创建，但 Woodpecker 中仍存在一个同名的过期记录。请删除过期记录以启用新记录，或者修复旧记录。\",\n            \"forge_repo_missing\": \"代码托管平台仓库已缺失！\"\n        },\n        \"manual_pipeline\": {\n            \"select_branch\": \"选择分支\",\n            \"title\": \"手动触发流水线运行\",\n            \"trigger\": \"运行流水线\",\n            \"variables\": {\n                \"add\": \"添加变量\",\n                \"desc\": \"指定要添加在流水线中使用的变量，相同名称的变量将被覆盖。\",\n                \"name\": \"变量名称\",\n                \"title\": \"添加流水线变量\",\n                \"value\": \"变量值\",\n                \"delete\": \"删除变量\"\n            },\n            \"show_pipelines\": \"查看流水线\",\n            \"no_manual_workflows\": \"未找到匹配的工作流。请确保至少有一个手动触发的工作流运行。\"\n        },\n        \"not_allowed\": \"你没有权限访问这个仓库\",\n        \"open_in_forge\": \"在代码托管平台中打开\",\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"取消\",\n                \"cancel_success\": \"流水线已被取消\",\n                \"canceled\": \"该步骤已被取消。\",\n                \"deploy\": \"部署\",\n                \"log_auto_scroll\": \"启用自动滚动\",\n                \"log_auto_scroll_off\": \"禁用自动滚动\",\n                \"log_download\": \"下载\",\n                \"restart\": \"重启\",\n                \"restart_success\": \"流水线已重启\",\n                \"log_delete\": \"删除\",\n                \"skipped\": \"此步骤已被跳过。\"\n            },\n            \"config\": \"配置\",\n            \"errors\": \"错误\",\n            \"event\": {\n                \"cron\": \"计划任务\",\n                \"deploy\": \"部署\",\n                \"manual\": \"手动\",\n                \"pr\": \"合并请求\",\n                \"push\": \"推送\",\n                \"tag\": \"标签\",\n                \"release\": \"发布\",\n                \"pr_closed\": \"拉取请求已合并/关闭\",\n                \"pr_metadata\": \"拉取请求元数据已改变\"\n            },\n            \"exit_code\": \"退出代码 {exitCode}\",\n            \"files\": \"修改的文件\",\n            \"loading\": \"载入中…\",\n            \"log_download_error\": \"下载日志文件时发生了错误\",\n            \"log_title\": \"步骤日志\",\n            \"no_files\": \"没有文件修改。\",\n            \"no_pipeline_steps\": \"没有流水线步骤可用！\",\n            \"no_pipelines\": \"尚未启动过流水线。\",\n            \"pipeline\": \"流水线 #{pipelineId}\",\n            \"pipelines_for\": \"{branch} 分支的流水线\",\n            \"pipelines_for_pr\": \"来自合并请求 #{index} 的流水线\",\n            \"protected\": {\n                \"approve\": \"同意\",\n                \"approve_success\": \"流水线已被许可\",\n                \"awaits\": \"该流水线正在等待维护者许可！\",\n                \"decline\": \"拒绝\",\n                \"decline_success\": \"流水线已被拒绝\",\n                \"declined\": \"该流水线已被拒绝！\",\n                \"review\": \"审查变更\"\n            },\n            \"show_errors\": \"显示错误\",\n            \"status\": {\n                \"blocked\": \"已屏蔽\",\n                \"declined\": \"已拒绝\",\n                \"error\": \"错误\",\n                \"failure\": \"失败\",\n                \"killed\": \"已终止\",\n                \"pending\": \"等待中\",\n                \"running\": \"运行中\",\n                \"skipped\": \"已跳过\",\n                \"started\": \"已启动\",\n                \"status\": \"状态：{status}\",\n                \"success\": \"成功\",\n                \"canceled\": \"已取消\"\n            },\n            \"step_not_started\": \"该步骤尚未启动。\",\n            \"tasks\": \"任务\",\n            \"warnings\": \"警告\",\n            \"we_got_some_errors\": \"哦不，发生了一些错误！\",\n            \"no_logs\": \"无日志\",\n            \"created\": \"创建于：{created}\",\n            \"debug\": {\n                \"title\": \"调试\",\n                \"download_metadata\": \"下载元数据\",\n                \"metadata_download_error\": \"下载元数据时出错\",\n                \"metadata_download_successful\": \"元数据下载成功\",\n                \"no_permission\": \"您无权访问调试信息\",\n                \"metadata_exec_title\": \"本地重新运行流水线\",\n                \"metadata_exec_desc\": \"下载此流水线的元数据以便在本地运行。这样可以在提交更改前修复问题并进行测试。Woodpecker CLI 必须与服务器版本一致并已在本地安装。\"\n            },\n            \"duration\": \"流水线耗时：{duration}\",\n            \"log_delete_confirm\": \"您确定要删除步骤日志吗？\",\n            \"log_delete_error\": \"删除步骤日志时发生了错误\",\n            \"view\": \"查看流水线\",\n            \"cancel_info\": {\n                \"superseded_by\": \"#{pipelineId} 已取消\",\n                \"canceled_by_user\": \"{user} 已取消\",\n                \"canceled_by_step\": \"因 {step} 而取消\"\n            },\n            \"load_more\": \"加载更多\",\n            \"version\": \"此流水线在该 Woodpecker 版本上执行。\",\n            \"version_header\": \"Woodpecker 版本\"\n        },\n        \"pull_requests\": \"合并请求\",\n        \"settings\": {\n            \"actions\": {\n                \"actions\": \"操作\",\n                \"delete\": {\n                    \"confirm\": \"该操作将会删除所有数据！\\n\\n你确定真的要继续吗？\",\n                    \"delete\": \"删除仓库\",\n                    \"success\": \"仓库已删除\"\n                },\n                \"disable\": {\n                    \"disable\": \"禁用仓库\",\n                    \"success\": \"仓库已禁用\"\n                },\n                \"enable\": {\n                    \"enable\": \"启用仓库\",\n                    \"success\": \"此仓库已成功启用\"\n                },\n                \"repair\": {\n                    \"repair\": \"修复仓库\",\n                    \"success\": \"仓库已修复\"\n                }\n            },\n            \"badge\": {\n                \"badge\": \"徽标\",\n                \"branch\": \"分支\",\n                \"type\": \"语法\",\n                \"type_html\": \"HTML\",\n                \"type_markdown\": \"Markdown\",\n                \"type_url\": \"URL\",\n                \"events\": \"事件\",\n                \"step\": \"步骤\",\n                \"workflow\": \"工作流\"\n            },\n            \"crons\": {\n                \"add\": \"创建计划\",\n                \"branch\": {\n                    \"placeholder\": \"分支（若为空，将使用默认分支）\",\n                    \"title\": \"分支\"\n                },\n                \"created\": \"计划已创建\",\n                \"crons\": \"计划\",\n                \"delete\": \"删除计划\",\n                \"deleted\": \"计划已删除\",\n                \"desc\": \"计划作业可用于定期触发流水线。\",\n                \"edit\": \"编辑计划\",\n                \"name\": {\n                    \"name\": \"名称\",\n                    \"placeholder\": \"此计划的名称\"\n                },\n                \"next_exec\": \"下次执行\",\n                \"none\": \"还未添加任何计划。\",\n                \"not_executed_yet\": \"还没有执行过\",\n                \"run\": \"立即执行\",\n                \"save\": \"保存计划\",\n                \"saved\": \"计划已保存\",\n                \"schedule\": {\n                    \"placeholder\": \"计划任务\",\n                    \"title\": \"计划任务（基于 UTC）\"\n                },\n                \"show\": \"显示所有计划\",\n                \"enabled\": \"启用\"\n            },\n            \"general\": {\n                \"allow_pr\": {\n                    \"allow\": \"允许拉取请求\",\n                    \"desc\": \"允许运行拉取请求中的流水线。\"\n                },\n                \"cancel_prev\": {\n                    \"cancel\": \"取消之前的流水线\",\n                    \"desc\": \"启用后，启动新触发的流水线之前会取消相同事件和上下文的挂起和正在运行的流水线。\"\n                },\n                \"general\": \"项目\",\n                \"netrc_only_trusted\": {\n                    \"desc\": \"可获取用于克隆或推送仓库至代码托管平台的 netrc 凭证的插件。\",\n                    \"netrc_only_trusted\": \"自定义信任的插件\"\n                },\n                \"pipeline_path\": {\n                    \"default\": \"默认: .woodpecker/*.yml -> .woodpecker.yml\",\n                    \"desc\": \"流水线配置文件的路径（例如 {0}）。文件夹路径应以 {1} 结尾。\",\n                    \"desc_path_example\": \"my/path/\",\n                    \"path\": \"流水线路径\"\n                },\n                \"project\": \"项目设置\",\n                \"protected\": {\n                    \"desc\": \"每个流水线都需要在执行之前获得批准。\",\n                    \"protected\": \"受保护\"\n                },\n                \"save\": \"保存设置\",\n                \"success\": \"项目设置已更新\",\n                \"timeout\": {\n                    \"minutes\": \"分钟\",\n                    \"timeout\": \"流水线最大执行时长\"\n                },\n                \"trusted\": {\n                    \"desc\": \"流水线容器可以使用高级功能，比如挂载卷。\",\n                    \"trusted\": \"受信任\",\n                    \"network\": {\n                        \"network\": \"网络\",\n                        \"desc\": \"流水线容器可以获得网络权限，例如更改 DNS。\"\n                    },\n                    \"security\": {\n                        \"security\": \"安全\",\n                        \"desc\": \"流水线容器可以获得安全权限。\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"卷\",\n                        \"desc\": \"流水线容器允许被挂载卷。\"\n                    }\n                },\n                \"visibility\": {\n                    \"internal\": {\n                        \"desc\": \"只有经过身份验证的 Woodpecker 用户才能看到此项目。\",\n                        \"internal\": \"内部\"\n                    },\n                    \"private\": {\n                        \"desc\": \"只有你和此仓库的所有者可以看到此项目。\",\n                        \"private\": \"私有\"\n                    },\n                    \"public\": {\n                        \"desc\": \"用户无需登录即可看到你的项目。\",\n                        \"public\": \"公开\"\n                    },\n                    \"visibility\": \"项目可见性\"\n                },\n                \"allow_deploy\": {\n                    \"allow\": \"允许部署\",\n                    \"desc\": \"允许成功的流水线进行部署。所有具有推送权限的用户都可以触发这些操作，因此请谨慎使用。\"\n                }\n            },\n            \"not_allowed\": \"你没有权限访问此仓库的设置\",\n            \"registries\": {\n                \"add\": \"添加 Registry\",\n                \"address\": {\n                    \"address\": \"地址\",\n                    \"placeholder\": \"Registry 地址（如 docker.io）\"\n                },\n                \"created\": \"Registry 密码已创建\",\n                \"credentials\": \"Registry 凭据\",\n                \"delete\": \"删除 registry\",\n                \"deleted\": \"Registry 密码已删除\",\n                \"desc\": \"可以添加 Registry 密码，以在流水线中使用私有镜像。\",\n                \"edit\": \"编辑 Registry\",\n                \"none\": \"现在没有 Registry 密码。\",\n                \"registries\": \"注册表\",\n                \"save\": \"保存 Registry\",\n                \"saved\": \"Registry 密码已保存\",\n                \"show\": \"显示 Registry\"\n            },\n            \"secrets\": {\n                \"add\": \"添加秘密\",\n                \"created\": \"秘密已创建\",\n                \"delete\": \"删除密钥\",\n                \"delete_confirm\": \"你真的要删除这个密钥吗？\",\n                \"deleted\": \"秘密已删除\",\n                \"desc\": \"密钥可以在运行时作为环境变量传递给各个流水线步骤。\",\n                \"edit\": \"编辑密钥\",\n                \"events\": {\n                    \"events\": \"对以下事件可用\",\n                    \"pr_warning\": \"慎选这个选项：用户可以提交一个恶意推送请求以暴露你的密钥。\"\n                },\n                \"images\": {\n                    \"desc\": \"此秘密可用于此处填写的镜像列表，用逗号分隔，留空以允许所有镜像\",\n                    \"images\": \"可用于以下镜像\"\n                },\n                \"name\": \"名称\",\n                \"none\": \"还未添加任何秘密。\",\n                \"plugins_only\": \"仅对插件有效\",\n                \"save\": \"保存秘密\",\n                \"saved\": \"秘密已保存\",\n                \"secrets\": \"密钥\",\n                \"show\": \"显示所有秘密\",\n                \"value\": \"值\"\n            },\n            \"settings\": \"设置\"\n        },\n        \"user_none\": \"该组织/用户尚无项目\",\n        \"visibility\": {\n            \"visibility\": \"项目可见性\",\n            \"public\": {\n                \"desc\": \"任何用户无需登录即可看到你的项目。\",\n                \"public\": \"公共的\"\n            },\n            \"private\": {\n                \"private\": \"私有的\",\n                \"desc\": \"只有你和其他仓库拥有者才能看到这个项目。\"\n            },\n            \"internal\": {\n                \"internal\": \"内部的\",\n                \"desc\": \"只有Woodpecker的登录用户可以看到这个项目。\"\n            }\n        }\n    },\n    \"repos\": \"仓库\",\n    \"repositories\": {\n        \"title\": \"仓库\",\n        \"all\": {\n            \"title\": \"所有仓库\",\n            \"desc\": \"所有仓库（按流水线创建时间排序）\"\n        },\n        \"last\": {\n            \"title\": \"上次访问\",\n            \"desc\": \"最近访问的仓库（按访问时间排序）\"\n        }\n    },\n    \"running_version\": \"你正在运行 Woodpecker {0}\",\n    \"search\": \"查找仓库…\",\n    \"time\": {\n        \"days_short\": \"天\",\n        \"hours_short\": \"小时\",\n        \"min_short\": \"分\",\n        \"not_started\": \"还没有运行过\",\n        \"sec_short\": \"秒\",\n        \"template\": \"YYYY 年 MM 月 D 日 HH:mm z\",\n        \"weeks_short\": \"周\",\n        \"just_now\": \"刚刚\"\n    },\n    \"unknown_error\": \"发生了未知错误\",\n    \"update_woodpecker\": \"请更新你的 Woodpecker 实例到 {0}\",\n    \"url\": \"URL\",\n    \"user\": {\n        \"access_denied\": \"你没有登陆的权限\",\n        \"internal_error\": \"发生了一些内部错误\",\n        \"oauth_error\": \"Oauth 认证错误\",\n        \"settings\": {\n            \"api\": {\n                \"api\": \"API\",\n                \"api_usage\": \"API 用例\",\n                \"cli_usage\": \"CLI 用例\",\n                \"desc\": \"个人访问令牌和 API 使用\",\n                \"dl_cli\": \"下载 CLI\",\n                \"reset_token\": \"重置令牌\",\n                \"shell_setup\": \"Shell 设置\",\n                \"shell_setup_before\": \"在 shell 设置步骤之前\",\n                \"swagger_ui\": \"Swagger UI\",\n                \"token\": \"个人访问令牌\"\n            },\n            \"general\": {\n                \"general\": \"账户设置\",\n                \"language\": \"语言\",\n                \"theme\": {\n                    \"auto\": \"自动\",\n                    \"dark\": \"暗色\",\n                    \"light\": \"亮色\",\n                    \"theme\": \"主题\"\n                }\n            },\n            \"secrets\": {\n                \"add\": \"添加密钥\",\n                \"created\": \"已创建用户密钥\",\n                \"deleted\": \"已删除用户密钥\",\n                \"desc\": \"用户密钥可用于该用户所有仓库的各个流水线。\",\n                \"events\": {\n                    \"events\": \"对以下事件可用\",\n                    \"pr_warning\": \"慎选这个选项：用户可以提交一个恶意推送请求以暴露你的密钥。\"\n                },\n                \"images\": {\n                    \"desc\": \"此密钥可用于此处填写的镜像，多个镜像请使用逗号分隔，留空则允许所有镜像\",\n                    \"images\": \"可用于以下镜像\"\n                },\n                \"name\": \"名称\",\n                \"none\": \"还未添加任何密钥。\",\n                \"plugins_only\": \"仅对插件有效\",\n                \"save\": \"保存密钥\",\n                \"saved\": \"已保存用户密钥\",\n                \"secrets\": \"密钥\",\n                \"show\": \"显示所有密钥\",\n                \"value\": \"值\"\n            },\n            \"settings\": \"用户设置\",\n            \"cli_and_api\": {\n                \"token\": \"个人访问令牌\",\n                \"api_usage\": \"API 用法示例\",\n                \"cli_usage\": \"CLI 用法示例\",\n                \"download_cli\": \"下载 CLI\",\n                \"reset_token\": \"重置令牌\",\n                \"swagger_ui\": \"Swagger 界面\",\n                \"desc\": \"个人访问令牌、CLI 和 API 用法\",\n                \"cli_and_api\": \"CLI & API\"\n            },\n            \"registries\": {\n                \"desc\": \"可以添加用户注册表凭据，以便在所有个人流水线中使用私有镜像。\"\n            },\n            \"agents\": {\n                \"desc\": \"在您的账号仓库中已注册的 Agent。\"\n            }\n        }\n    },\n    \"username\": \"用户名\",\n    \"welcome\": \"欢迎来到 Woodpecker\",\n    \"empty_list\": \"找不到 {entity} ！\",\n    \"login_to_cli\": \"登录到 CLI\",\n    \"global_level_secret\": \"全局密钥\",\n    \"org_level_secret\": \"组织密钥\",\n    \"abort\": \"中止\",\n    \"cli_login_success\": \"登录到 CLI 成功\",\n    \"cli_login_failed\": \"登录到 CLI 失败\",\n    \"cli_login_denied\": \"登录 CLI 被拒绝\",\n    \"login_to_cli_description\": \"点击继续以登录到 CLI。\",\n    \"return_to_cli\": \"您现在可以关闭此选项卡并返回 CLI。\",\n    \"login_with\": \"使用 {forge} 登录\",\n    \"require_approval\": {\n        \"allowed_users\": {\n            \"allowed_users\": \"已允许的用户\",\n            \"desc\": \"由列表中用户创建的流水线无需批准。\"\n        },\n        \"none\": \"无\",\n        \"none_desc\": \"所有事件都会触发管道，包括拉取请求。此设置可能很危险，仅建议用于私有实例。\",\n        \"pull_requests\": \"所有拉取请求\",\n        \"forks\": \"从已 fork 的仓库进行 Pull request\",\n        \"desc\": \"通过执行前批准以防止恶意流水线暴露密钥或运行有害的任务。\",\n        \"require_approval_for\": \"批准要求\",\n        \"all_events\": \"代码托管平台的所有事件\"\n    },\n    \"secrets\": {\n        \"secrets\": \"密钥\",\n        \"add\": \"添加密钥\",\n        \"save\": \"保存密钥\",\n        \"show\": \"显示密钥\",\n        \"created\": \"密钥已创建\",\n        \"saved\": \"密钥已保存\",\n        \"edit\": \"编辑密钥\",\n        \"delete\": \"删除密钥\",\n        \"name\": \"名称\",\n        \"value\": \"值\",\n        \"deleted\": \"密钥已删除\",\n        \"delete_confirm\": \"您确实要删除这个密钥吗？\",\n        \"events\": {\n            \"events\": \"适用于以下事件\",\n            \"warning\": \"向所有拉取请求暴露密钥可能导致密钥被恶意拉取请求窃取。\"\n        },\n        \"none\": \"目前没有密钥。\",\n        \"desc\": \"密钥可以被用于这个仓库的所有流水线中。\",\n        \"plugins\": {\n            \"images\": \"仅适用于以下的插件\",\n            \"desc\": \"该密钥生效的插件镜像列表。置空以允许所有插件和常规步骤。\"\n        },\n        \"note\": \"备注\"\n    },\n    \"no_search_results\": \"未找到结果\",\n    \"org_access_denied\": \"您无权访问此组织\",\n    \"invalid_state\": \"OAuth 状态无效\",\n    \"internal_error\": \"发生内部错误\",\n    \"settings\": \"设置\",\n    \"access_denied\": \"您无权访问此实例\",\n    \"registries\": {\n        \"address\": {\n            \"address\": \"地址\",\n            \"desc\": \"注册表地址（如 docker.io）\"\n        },\n        \"deleted\": \"已删除注册表凭据\",\n        \"show\": \"显示注册表\",\n        \"save\": \"保存注册表\",\n        \"add\": \"添加注册表\",\n        \"view\": \"查看注册表\",\n        \"edit\": \"编辑注册表\",\n        \"delete\": \"删除注册表\",\n        \"created\": \"已创建注册表凭据\",\n        \"registries\": \"注册表\",\n        \"delete_confirm\": \"您真的要删除此注册表吗？\",\n        \"credentials\": \"注册表凭据\",\n        \"desc\": \"在流水线中可添加注册表凭据使用私有镜像。\",\n        \"none\": \"目前还没有注册表凭据。\",\n        \"saved\": \"已保存注册表凭据\"\n    },\n    \"registration_closed\": \"已关闭注册\",\n    \"oauth_error\": \"与 OAuth 提供商进行身份验证时出错\",\n    \"public_url_for_oauth_if\": \"如果用于 OAuth 的对外可访问地址与当前系统的基础 URL 不同，请在({0})填入\",\n    \"edit_forge\": \"编辑代码托管平台\",\n    \"forge_delete_confirm\": \"您真的想要删除这个代码托管平台吗？这将也会删除与此代码托管平台相关的所有仓库、用户和流水线。\",\n    \"no_forges\": \"还没有配置任何代码托管平台.\",\n    \"developer_settings\": \"开发者设置\",\n    \"delete_forge\": \"删除代码托管平台\",\n    \"login_to_woodpecker_with\": \"登录 Woodpecker 使用\",\n    \"extensions\": \"扩展程序\",\n    \"extensions_description\": \"扩展程序是可被 Woodpecker 调用的 HTTP 服务。\",\n    \"extension_endpoint_placeholder\": \"e.g. https://example.com/api\",\n    \"config_extension_endpoint\": \"配置扩展程序端点\",\n    \"extensions_signatures_public_key\": \"用于签名的公钥\",\n    \"extensions_signatures_public_key_description\": \"扩展程序应使用此公钥检验请求是否由 Woodpecker 发起。\",\n    \"extensions_configuration_saved\": \"扩展程序配置已保存\",\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"save\": \"保存\",\n    \"add\": \"添加\",\n    \"skip_verify\": \"跳过 SSL 验证\",\n    \"skip_verify_desc\": \"为 API 请求跳过 SSL 验证。不建议用于生产环境。\",\n    \"forge_created\": \"已创建代码托管平台\",\n    \"advanced_options\": \"高级选项\",\n    \"leave_empty_to_keep_current_value\": \"置空以保留原值\",\n    \"forge_deleted\": \"已删除代码托管平台\",\n    \"oauth_redirect_url\": \"OAuth 重定向 URL\",\n    \"forge_managed_by_env\": \"主要代码托管平台由环境变量控制。任何主要代码托管平台的更改将在重启时重置。\",\n    \"use_this_redirect_url_to_create\": \"用这个重定向 URL 来创建或更新 OAuth 应用。\",\n    \"forge_saved\": \"已保存代码托管平台\",\n    \"fullscreen\": \"全屏\",\n    \"exit_fullscreen\": \"退出全屏\",\n    \"help_translating\": \"您可以在 {0} 帮助翻译 Woodpecker 为您的语言。\",\n    \"weblate\": \"我们的 Weblate\",\n    \"disabled\": \"禁用\",\n    \"forges\": \"代码托管平台\",\n    \"forges_desc\": \"配置 Woodpecker 为其工作的代码托管平台。\",\n    \"add_forge\": \"添加代码托管平台\",\n    \"show_forges\": \"显示代码托管平台\",\n    \"addon\": \"Addon\",\n    \"forge_type\": \"代码托管平台类型\",\n    \"oauth_client_id\": \"OAuth Client ID\",\n    \"oauth_client_secret\": \"OAuth Client Secret\",\n    \"oauth_host\": \"OAuth host\",\n    \"merge_ref\": \"合并 ref\",\n    \"merge_ref_desc\": \"用于合并的 ref。这被用于确定拉取请求的 diff。\",\n    \"public_only\": \"仅公开\",\n    \"public_only_desc\": \"仅显示公开仓库。\",\n    \"git_username\": \"Git 用户名\",\n    \"git_username_desc\": \"Git 用户的用户名。\",\n    \"git_password\": \"Git 密码\",\n    \"git_password_desc\": \"Git 用户的密码或通行密钥（personal access token）。\",\n    \"executable\": \"可执行文件\",\n    \"executable_desc\": \"Addon 的可执行文件的路径。\",\n    \"developer_settings_to_create\": \"前往 {0} 并设置 OAuth 应用。\",\n    \"config_extension_exclusive\": \"独占\",\n    \"config_extension_exclusive_desc\": \"启用后，将跳过所有其他代码托管平台的配置获取方式。\",\n    \"registry_extension_endpoint\": \"注册扩展程序端点\",\n    \"global_level_registry\": \"全局注册表\",\n    \"org_level_registry\": \"组织注册表\",\n    \"secret_extension_endpoint\": \"密钥扩展程序端点\",\n    \"secret_extension_netrc\": \"包含 netrc 凭证\",\n    \"secret_extension_netrc_desc\": \"将代码托管平台 netrc 凭证发送给密钥扩展程序。\",\n    \"extension_netrc\": \"包含 netrc 凭证\",\n    \"extension_netrc_desc\": \"向扩展程序发送代码托管平台的 netrc 凭证。\"\n}\n"
  },
  {
    "path": "web/src/assets/locales/zh-Hant.json",
    "content": "{\n    \"api\": \"API\",\n    \"back\": \"返回\",\n    \"login\": \"登入\",\n    \"logout\": \"登出\",\n    \"password\": \"密碼\",\n    \"repos\": \"儲存庫\",\n    \"repositories\": {\n        \"title\": \"儲存庫\",\n        \"all\": {\n            \"title\": \"所有儲存庫\"\n        },\n        \"last\": {\n            \"title\": \"上次瀏覽\"\n        }\n    },\n    \"search\": \"搜尋…\",\n    \"unknown_error\": \"出現未知錯誤\",\n    \"url\": \"URL\",\n    \"welcome\": \"歡迎使用 Woodpecker\",\n    \"repo\": {\n        \"manual_pipeline\": {\n            \"select_branch\": \"選擇分支\",\n            \"show_pipelines\": \"顯示 pipeline\",\n            \"title\": \"手動觸發 pipeline 執行\",\n            \"trigger\": \"執行 pipeline\",\n            \"variables\": {\n                \"value\": \"變數值\",\n                \"title\": \"額外 pipeline 變數\",\n                \"desc\": \"指定在 pipeline 中額外使用的變數。相同變數名稱會被覆蓋掉。\",\n                \"name\": \"變數名稱\",\n                \"delete\": \"刪除變數\"\n            }\n        },\n        \"pipeline\": {\n            \"actions\": {\n                \"cancel\": \"取消\",\n                \"restart\": \"重新啟動\",\n                \"deploy\": \"部署\",\n                \"log_download\": \"下載\",\n                \"log_delete\": \"刪除\"\n            },\n            \"tasks\": \"任務\",\n            \"status\": {\n                \"skipped\": \"已略過\",\n                \"success\": \"成功\",\n                \"failure\": \"失敗\",\n                \"error\": \"錯誤\",\n                \"running\": \"執行中\",\n                \"pending\": \"等待中\",\n                \"declined\": \"已拒絕\"\n            },\n            \"load_more\": \"載入更多\",\n            \"config\": \"配置\",\n            \"event\": {\n                \"deploy\": \"部署\",\n                \"push\": \"推送\",\n                \"tag\": \"標記\",\n                \"manual\": \"手動\",\n                \"release\": \"發佈\"\n            },\n            \"errors\": \"錯誤\",\n            \"warnings\": \"警告\",\n            \"show_errors\": \"顯示錯誤\",\n            \"debug\": {\n                \"title\": \"除錯\",\n                \"download_metadata\": \"下載詮釋資料\",\n                \"metadata_download_successful\": \"詮釋資料下載成功\"\n            },\n            \"no_logs\": \"無紀錄\",\n            \"loading\": \"載入中…\",\n            \"exit_code\": \"退出代碼 {exitCode}\",\n            \"pipeline\": \"Pipeline #{pipelineId}\",\n            \"protected\": {\n                \"approve\": \"核准\",\n                \"decline\": \"拒絕\"\n            },\n            \"version_header\": \"Woodpecker 版本\",\n            \"created\": \"\",\n            \"view\": \"檢視 pipeline\",\n            \"cancel_info\": {\n                \"canceled_by_step\": \"因為 {step} 所以被取消\",\n                \"canceled_by_user\": \"被 {user} 取消\"\n            }\n        },\n        \"settings\": {\n            \"general\": {\n                \"save\": \"儲存設定\",\n                \"timeout\": {\n                    \"timeout\": \"逾時\",\n                    \"minutes\": \"分鐘\"\n                },\n                \"general\": \"專案\",\n                \"project\": \"專案設定\",\n                \"allow_deploy\": {\n                    \"allow\": \"允許部署\"\n                },\n                \"trusted\": {\n                    \"security\": {\n                        \"security\": \"安全\"\n                    },\n                    \"network\": {\n                        \"network\": \"網路\"\n                    },\n                    \"volumes\": {\n                        \"volumes\": \"儲存區\"\n                    }\n                },\n                \"pipeline_path\": {\n                    \"path\": \"Pipeline 路徑\",\n                    \"desc_path_example\": \"my/path/\"\n                },\n                \"allow_pr\": {\n                    \"allow\": \"允許拉取請求\"\n                }\n            },\n            \"badge\": {\n                \"branch\": \"分支\",\n                \"badge\": \"徽章\",\n                \"type\": \"語法\",\n                \"type_url\": \"URL\",\n                \"type_markdown\": \"Markdown\",\n                \"type_html\": \"HTML\",\n                \"events\": \"事件\",\n                \"workflow\": \"工作流程\"\n            },\n            \"crons\": {\n                \"schedule\": {\n                    \"placeholder\": \"排程\"\n                },\n                \"run\": \"現在執行\",\n                \"branch\": {\n                    \"title\": \"分支\"\n                },\n                \"name\": {\n                    \"name\": \"名稱\"\n                },\n                \"enabled\": \"已啟用\"\n            },\n            \"actions\": {\n                \"repair\": {\n                    \"repair\": \"修復儲存庫\",\n                    \"success\": \"儲存庫已修復\"\n                },\n                \"disable\": {\n                    \"disable\": \"停用儲存庫\",\n                    \"success\": \"儲存庫已停用\"\n                },\n                \"enable\": {\n                    \"enable\": \"啟用儲存庫\",\n                    \"success\": \"儲存庫已啟用\"\n                },\n                \"delete\": {\n                    \"delete\": \"刪除儲存庫\",\n                    \"success\": \"儲存庫已刪除\"\n                }\n            }\n        },\n        \"visibility\": {\n            \"visibility\": \"專案可見性\",\n            \"public\": {\n                \"public\": \"公開\",\n                \"desc\": \"所有人包含非登入的使用者都可以看到你的專案。\"\n            },\n            \"private\": {\n                \"private\": \"私有\",\n                \"desc\": \"只有你和其他儲存庫的擁有者可以看到這個專案。\"\n            },\n            \"internal\": {\n                \"internal\": \"內部的\",\n                \"desc\": \"只有已認證的使用者可以看到這個專案。\"\n            }\n        },\n        \"deploy_pipeline\": {\n            \"title\": \"觸發當前 pipeline #{pipelineId} 的部署\",\n            \"enter_target\": \"目標部署環境\",\n            \"variables\": {\n                \"title\": \"額外 pipeline 變數\",\n                \"delete\": \"刪除變數\",\n                \"name\": \"變數名稱\",\n                \"value\": \"變數值\"\n            },\n            \"trigger\": \"部署\",\n            \"enter_task\": \"部署任務\"\n        },\n        \"pull_requests\": \"拉取請求\",\n        \"add\": \"新增儲存庫\",\n        \"user_none\": \"這個組織/使用者還沒有任何專案\",\n        \"not_allowed\": \"你不被允許存取這個儲存庫\",\n        \"enable\": {\n            \"enable\": \"啟用\",\n            \"enabled\": \"已啟用\",\n            \"disabled\": \"已停用\",\n            \"success\": \"儲存庫已啟用\",\n            \"stale_wp_repo\": \"過時的 Woodpecker 儲存庫\",\n            \"conflict\": \"衝突\"\n        },\n        \"open_in_forge\": \"\",\n        \"activity\": \"活動\",\n        \"branches\": \"分支\"\n    },\n    \"cancel\": \"取消\",\n    \"not_found\": {\n        \"back_home\": \"返回首頁\"\n    },\n    \"admin\": {\n        \"settings\": {\n            \"users\": {\n                \"cancel\": \"取消\",\n                \"login\": \"登入\",\n                \"email\": \"電子郵件\",\n                \"avatar_url\": \"Avatar URL\",\n                \"save\": \"儲存使用者\",\n                \"show\": \"顯示使用者\",\n                \"add\": \"新增使用者\",\n                \"admin\": {\n                    \"admin\": \"管理員\"\n                },\n                \"delete_user\": \"刪除使用者\",\n                \"edit_user\": \"編輯使用者\",\n                \"users\": \"使用者\",\n                \"deleted\": \"使用者已刪除\",\n                \"created\": \"使用者已建立\",\n                \"saved\": \"使用者已儲存\"\n            },\n            \"agents\": {\n                \"name\": {\n                    \"name\": \"名稱\"\n                },\n                \"id\": \"ID\",\n                \"token\": \"權杖\",\n                \"platform\": {\n                    \"platform\": \"平臺\",\n                    \"badge\": \"平臺\"\n                },\n                \"backend\": {\n                    \"backend\": \"後端\",\n                    \"badge\": \"後端\"\n                },\n                \"capacity\": {\n                    \"capacity\": \"容量\",\n                    \"badge\": \"容量\"\n                },\n                \"version\": \"版本\",\n                \"org\": {\n                    \"badge\": \"組織\"\n                }\n            },\n            \"repos\": {\n                \"disabled\": \"已停用\",\n                \"settings\": \"儲存庫設定\",\n                \"repos\": \"儲存庫\",\n                \"view\": \"檢視儲存庫\"\n            },\n            \"queue\": {\n                \"queue\": \"佇列\",\n                \"pause\": \"暫停\",\n                \"resume\": \"恢復\",\n                \"paused\": \"佇列已暫停\",\n                \"resumed\": \"佇列已恢復\",\n                \"tasks\": \"任務\",\n                \"stats\": {\n                    \"running_count\": \"執行中\",\n                    \"pending_count\": \"等待中\"\n                },\n                \"task_running\": \"任務執行中\",\n                \"task_pending\": \"任務等待中\",\n                \"waiting_for\": \"正在等待\"\n            },\n            \"orgs\": {\n                \"orgs\": \"組織\",\n                \"delete_org\": \"刪除組織\",\n                \"org_settings\": \"組織設定\",\n                \"deleted\": \"組織已刪除\",\n                \"view\": \"檢視組織\"\n            },\n            \"settings\": \"管理員設定\"\n        }\n    },\n    \"username\": \"帳號\",\n    \"time\": {\n        \"just_now\": \"剛剛\",\n        \"not_started\": \"尚未開始\"\n    },\n    \"empty_list\": \"找不到 {entity}！\",\n    \"default\": \"預設\",\n    \"info\": \"資訊\",\n    \"login_to_woodpecker_with\": \"使用以下方式登入 Woodpecker\",\n    \"docs\": \"說明文件\",\n    \"secrets\": {\n        \"name\": \"名稱\",\n        \"value\": \"值\",\n        \"secrets\": \"秘密\",\n        \"add\": \"新增秘密\",\n        \"save\": \"儲存秘密\",\n        \"show\": \"顯示秘密\",\n        \"deleted\": \"秘密已刪除\",\n        \"created\": \"秘密已建立\",\n        \"saved\": \"秘密已儲存\",\n        \"edit\": \"編輯秘密\",\n        \"delete\": \"刪除秘密\",\n        \"note\": \"說明\"\n    },\n    \"settings\": \"設定\",\n    \"extensions\": \"擴充功能\",\n    \"require_approval\": {\n        \"none\": \"無\",\n        \"require_approval_for\": \"批准要求\",\n        \"pull_requests\": \"所有拉取請求\"\n    },\n    \"github\": \"GitHub\",\n    \"gitlab\": \"GitLab\",\n    \"bitbucket\": \"Bitbucket\",\n    \"gitea\": \"Gitea\",\n    \"forgejo\": \"Forgejo\",\n    \"save\": \"儲存\",\n    \"add\": \"新增\",\n    \"skip_verify\": \"略過 SSL 驗證\",\n    \"advanced_options\": \"進階選項\",\n    \"git_username\": \"Git 使用者名稱\",\n    \"git_password\": \"Git 密碼\",\n    \"oauth_redirect_url\": \"OAuth 重新導向 URL\",\n    \"fullscreen\": \"全螢幕\",\n    \"exit_fullscreen\": \"退出全螢幕\",\n    \"weblate\": \"我們的 Weblate\",\n    \"disabled\": \"已停用\",\n    \"user\": {\n        \"settings\": {\n            \"settings\": \"使用者設定\",\n            \"general\": {\n                \"general\": \"帳號\",\n                \"language\": \"語言\",\n                \"theme\": {\n                    \"theme\": \"主題\",\n                    \"light\": \"亮色\",\n                    \"dark\": \"暗色\",\n                    \"auto\": \"自動\"\n                }\n            },\n            \"cli_and_api\": {\n                \"download_cli\": \"下載 CLI\",\n                \"reset_token\": \"重設權杖\",\n                \"swagger_ui\": \"Swagger UI\",\n                \"api_usage\": \"API 使用範例\",\n                \"cli_usage\": \"CLI 使用範例\",\n                \"token\": \"個人存取權杖\",\n                \"cli_and_api\": \"CLI & API\"\n            }\n        }\n    },\n    \"registries\": {\n        \"address\": {\n            \"address\": \"位址\",\n            \"desc\": \"註冊表位址 (例如 docker.io)\"\n        },\n        \"registries\": \"註冊表\",\n        \"credentials\": \"註冊表憑證\",\n        \"show\": \"顯示註冊表\",\n        \"save\": \"儲存註冊表\",\n        \"add\": \"新增註冊表\",\n        \"view\": \"檢視註冊表\",\n        \"edit\": \"編輯註冊表\",\n        \"delete\": \"刪除註冊表\"\n    },\n    \"developer_settings\": \"開發者設定\",\n    \"oauth_host\": \"OAuth 主機\",\n    \"extension_endpoint_placeholder\": \"例如 https://example.com/api\",\n    \"global_level_registry\": \"全域註冊表\",\n    \"org_level_registry\": \"組織註冊表\",\n    \"bitbucket_dc\": \"Bitbucket Data Center\",\n    \"public_only\": \"僅公開\",\n    \"public_only_desc\": \"僅顯示公開儲存庫。\",\n    \"addon\": \"附加元件\",\n    \"config_extension_exclusive\": \"獨佔\",\n    \"abort\": \"中止\"\n}\n"
  },
  {
    "path": "web/src/components/FileTree.vue",
    "content": "<template>\n  <div>\n    <div\n      class=\"group hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 flex cursor-pointer items-center rounded-md px-2 py-1.5 transition-all duration-150\"\n      :class=\"{ 'font-medium': node.isDirectory }\"\n      tabindex=\"0\"\n      role=\"button\"\n      :aria-expanded=\"node.isDirectory ? !collapsed : undefined\"\n      :aria-label=\"node.isDirectory ? `${collapsed ? 'Expand' : 'Collapse'} folder ${node.name}` : `File ${node.name}`\"\n      @click=\"collapsed = !collapsed\"\n      @keydown.enter=\"collapsed = !collapsed\"\n      @keydown.space=\"collapsed = !collapsed\"\n    >\n      <div class=\"mr-1 flex w-4 items-center justify-start\">\n        <Icon\n          v-if=\"node.isDirectory\"\n          name=\"chevron-right\"\n          class=\"text-wp-text-alt-100 group-hover:text-wp-text-200 h-6 min-w-6 transition-transform duration-150\"\n          :class=\"{ 'rotate-90 transform': !collapsed }\"\n        />\n      </div>\n\n      <Icon\n        :name=\"iconName\"\n        class=\"text-wp-text-alt-100 group-hover:text-wp-text-200 mr-3 transition-colors duration-150\"\n      />\n\n      <span\n        class=\"text-wp-text-200 group-hover:text-wp-text-100 truncate text-sm transition-colors duration-150\"\n        :class=\"{ 'text-wp-text-100': node.isDirectory }\"\n        :title=\"node.name\"\n      >\n        {{ node.name }}\n      </span>\n    </div>\n\n    <div\n      v-if=\"node.isDirectory && !collapsed\"\n      class=\"border-wp-background-400 dark:border-wp-background-100 mt-1 ml-2 border-l pl-1 transition-all duration-200\"\n    >\n      <FileTree v-for=\"child in node.children\" :key=\"child.path\" :node=\"child\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup name=\"FileTreeNode\">\nimport { computed, ref } from 'vue';\n\nimport Icon from '~/components/atomic/Icon.vue';\n\nexport interface TreeNode {\n  name: string;\n  path: string;\n  isDirectory: boolean;\n  children: TreeNode[];\n}\n\nconst props = defineProps<{\n  node: TreeNode;\n}>();\n\nconst collapsed = ref(false);\n\nconst iconName = computed(() => {\n  if (props.node.isDirectory) {\n    return collapsed.value ? 'folder' : 'folder-open';\n  }\n  return 'file';\n});\n</script>\n"
  },
  {
    "path": "web/src/components/admin/settings/forges/AdminForgeForm.vue",
    "content": "<template>\n  <form @submit.prevent=\"submit\">\n    <Warning v-if=\"!isNew && forge.id === 1\" :text=\"$t('forge_managed_by_env')\" />\n\n    <InputField v-slot=\"{ id }\" :label=\"$t('forge_type')\">\n      <SelectField :id=\"id\" v-model=\"forgeType\" :options=\"forgeTypeOptions\" required />\n    </InputField>\n\n    <InputField v-if=\"forge.type !== 'bitbucket'\" v-slot=\"{ id }\" :label=\"$t('url')\">\n      <TextField :id=\"id\" v-model=\"forge.url\" required />\n    </InputField>\n\n    <InputField :label=\"$t('oauth_redirect_url')\" description=\"foo\">\n      <template #default=\"{ id }\">\n        <TextField :id=\"id\" class=\"mt-2\" :model-value=\"redirectUri\" disabled />\n      </template>\n      <template #description>\n        {{ $t('use_this_redirect_url_to_create') }}\n        <i18n-t v-if=\"forge.type !== 'addon'\" keypath=\"developer_settings_to_create\" tag=\"span\">\n          <a rel=\"noopener noreferrer\" :href=\"oauthAppForgeUrl\" target=\"_blank\" class=\"underline\">{{\n            $t('developer_settings')\n          }}</a>\n        </i18n-t>\n      </template>\n    </InputField>\n\n    <template v-if=\"forge.type !== 'addon'\">\n      <InputField v-slot=\"{ id }\" :label=\"$t('oauth_client_id')\">\n        <TextField :id=\"id\" v-model=\"forge.client\" required />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('oauth_client_secret')\">\n        <TextField\n          :id=\"id\"\n          v-model=\"forge.oauth_client_secret\"\n          :placeholder=\"isNew ? '' : $t('leave_empty_to_keep_current_value')\"\n          :required=\"isNew\"\n        />\n      </InputField>\n    </template>\n\n    <template v-else>\n      <InputField v-slot=\"{ id }\" :label=\"$t('executable')\">\n        <p>{{ $t('executable_desc') }}</p>\n        <TextField\n          :id=\"id\"\n          :model-value=\"getAdditionalOptions('addon', 'executable')\"\n          @update:model-value=\"setAdditionalOptions('addon', 'executable', $event)\"\n        />\n      </InputField>\n    </template>\n\n    <Panel\n      v-if=\"forge.type !== 'bitbucket'\"\n      collapsable\n      collapsed-by-default\n      :title=\"$t('advanced_options')\"\n      class=\"mb-4\"\n    >\n      <InputField v-slot=\"{ id }\" :label=\"$t('oauth_host')\">\n        <TextField :id=\"id\" v-model=\"forge.oauth_host\" :placeholder=\"$t('public_url_for_oauth_if', [forge.url])\" />\n      </InputField>\n\n      <template v-if=\"forge.type === 'github'\">\n        <InputField :label=\"$t('merge_ref')\">\n          <Checkbox\n            :label=\"$t('merge_ref_desc')\"\n            :model-value=\"getAdditionalOptions('github', 'merge-ref') ?? false\"\n            @update:model-value=\"setAdditionalOptions('github', 'merge-ref', $event)\"\n          />\n        </InputField>\n\n        <InputField :label=\"$t('public_only')\">\n          <Checkbox\n            :label=\"$t('public_only_desc')\"\n            :model-value=\"getAdditionalOptions('github', 'public-only') ?? false\"\n            @update:model-value=\"setAdditionalOptions('github', 'public-only', $event)\"\n          />\n        </InputField>\n      </template>\n      <template v-if=\"forge.type === 'bitbucket-dc'\">\n        <InputField v-slot=\"{ id }\" :label=\"$t('git_username')\">\n          <p>{{ $t('git_username_desc') }}</p>\n          <TextField\n            :id=\"id\"\n            :model-value=\"getAdditionalOptions('bitbucket-dc', 'git-username')\"\n            @update:model-value=\"setAdditionalOptions('bitbucket-dc', 'git-username', $event)\"\n          />\n        </InputField>\n        <InputField v-slot=\"{ id }\" :label=\"$t('git_password')\">\n          <p>{{ $t('git_password_desc') }}</p>\n          <TextField\n            :id=\"id\"\n            :model-value=\"getAdditionalOptions('bitbucket-dc', 'git-password')\"\n            @update:model-value=\"setAdditionalOptions('bitbucket-dc', 'git-password', $event)\"\n          />\n        </InputField>\n      </template>\n\n      <InputField :label=\"$t('skip_verify')\">\n        <Checkbox\n          :label=\"$t('skip_verify_desc')\"\n          :model-value=\"forge.skip_verify || false\"\n          @update:model-value=\"forge!.skip_verify = $event\"\n        />\n      </InputField>\n    </Panel>\n\n    <div class=\"flex gap-2\">\n      <Button :text=\"$t('cancel')\" :to=\"{ name: 'admin-settings-forges' }\" />\n\n      <Button :is-loading=\"isSaving\" type=\"submit\" color=\"green\" :text=\"isNew ? $t('add') : $t('save')\" />\n    </div>\n  </form>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Warning from '~/components/atomic/Warning.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport SelectField from '~/components/form/SelectField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport useConfig from '~/compositions/useConfig';\nimport type { Forge, ForgeType } from '~/lib/api/types';\n\ndefineProps<{\n  isNew?: boolean;\n  isSaving?: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'submit'): void;\n}>();\n\nconst { t } = useI18n();\n\nconst config = useConfig();\n\nconst forge = defineModel<Partial<Forge>>('forge', {\n  required: true,\n});\n\n// Define forge type options\nconst forgeTypeOptions = [\n  { value: 'github', text: t('github') },\n  { value: 'gitlab', text: t('gitlab') },\n  { value: 'gitea', text: t('gitea') },\n  { value: 'forgejo', text: t('forgejo') },\n  { value: 'bitbucket', text: t('bitbucket') },\n  { value: 'bitbucket-dc', text: t('bitbucket_dc') },\n  { value: 'addon', text: t('addon') },\n];\n\n// Function to get default URL for a forge type\nfunction getDefaultUrl(forgeType: ForgeType): string {\n  switch (forgeType) {\n    case 'github':\n      return 'github.com';\n    case 'gitlab':\n      return 'gitlab.com';\n    case 'bitbucket':\n      return 'bitbucket.org';\n    default:\n      return '';\n  }\n}\n\n// Initialize forge type to have a default value (first option)\nif (!forge.value.type) {\n  const defaultType = forgeTypeOptions[0].value as ForgeType;\n  forge.value.type = defaultType;\n  forge.value.url = forge.value.url || getDefaultUrl(defaultType);\n}\n\n// Initialize forge type to have a default value\nif (!forge.value.type) {\n  forge.value.type = 'github';\n}\n\ninterface GitHubAdditionOptions {\n  'merge-ref'?: boolean;\n  'public-only'?: boolean;\n}\n\ninterface BitbucketAdditionOptions {\n  'git-username'?: string;\n  'git-password'?: string;\n}\n\ninterface AddonAdditionOptions {\n  executable?: string;\n}\n\nfunction getAdditionalOptions<T extends keyof GitHubAdditionOptions>(\n  forgeType: 'github',\n  key: T,\n): GitHubAdditionOptions[T];\n// eslint-disable-next-line no-redeclare\nfunction getAdditionalOptions<T extends keyof BitbucketAdditionOptions>(\n  forgeType: 'bitbucket-dc',\n  key: T,\n): BitbucketAdditionOptions[T];\n// eslint-disable-next-line no-redeclare\nfunction getAdditionalOptions<T extends keyof AddonAdditionOptions>(\n  forgeType: 'addon',\n  key: T,\n): AddonAdditionOptions[T];\n// eslint-disable-next-line no-redeclare\nfunction getAdditionalOptions<T extends keyof Record<string, unknown>>(_forgeType: ForgeType, key: T): unknown {\n  return forge.value?.additional_options?.[key];\n}\n\nfunction setAdditionalOptions<T extends keyof GitHubAdditionOptions>(\n  forgeType: 'github',\n  key: T,\n  value: GitHubAdditionOptions[T],\n): void;\n// eslint-disable-next-line no-redeclare\nfunction setAdditionalOptions<T extends keyof BitbucketAdditionOptions>(\n  forgeType: 'bitbucket-dc',\n  key: T,\n  value: BitbucketAdditionOptions[T],\n): void;\n// eslint-disable-next-line no-redeclare\nfunction setAdditionalOptions<T extends keyof AddonAdditionOptions>(\n  forgeType: 'addon',\n  key: T,\n  value: AddonAdditionOptions[T],\n): void;\n// eslint-disable-next-line no-redeclare\nfunction setAdditionalOptions<T extends keyof Record<string, unknown>>(\n  _forgeType: ForgeType,\n  key: string,\n  value: T,\n): void {\n  forge.value = {\n    ...forge.value,\n    additional_options: {\n      ...forge.value?.additional_options,\n      [key]: value,\n    },\n  };\n}\n\nconst replaceRegex = /\\/$/;\n\nconst oauthAppForgeUrl = computed(() => {\n  if (!forge.value || !forge.value.type || !forge.value.url) {\n    return '';\n  }\n\n  const forgeUrl = `${forge.value.url.startsWith('http') ? '' : 'https://'}${forge.value.url.replace(replaceRegex, '')}`;\n\n  switch (forge.value.type) {\n    case 'github':\n      return `${forgeUrl}/settings/applications/new`;\n    case 'gitlab':\n      return `${forgeUrl}/-/user_settings/applications`;\n    case 'gitea':\n    case 'forgejo':\n      return `${forgeUrl}/user/settings/applications`;\n    case 'bitbucket':\n    case 'bitbucket-dc':\n      return `${forgeUrl}/account/settings/app-passwords`;\n    default:\n      return '';\n  }\n});\n\nconst forgeType = computed({\n  get: () => forge.value?.type ?? forgeTypeOptions[0].value,\n  set: (value) => {\n    const newUrl = getDefaultUrl(value as ForgeType);\n\n    // Only update URL if it hasn't been customized or is empty\n    if (!forge.value?.url || forge.value.url === getDefaultUrl(forge.value.type as ForgeType)) {\n      forge.value = { ...forge.value, url: newUrl, type: value as ForgeType };\n    } else {\n      forge.value = { ...forge.value, type: value as ForgeType };\n    }\n  },\n});\n\nconst redirectUri = computed(() => [window.location.origin, config.rootPath, 'authorize'].filter((a) => !!a).join('/'));\n\nasync function submit() {\n  if (!forge.value.url?.startsWith('http')) {\n    forge.value.url = `https://${forge.value.url}`;\n  }\n\n  if (forge.value.oauth_host === forge.value.url) {\n    forge.value.oauth_host = '';\n  }\n\n  if (forge.value.oauth_host && !forge.value.oauth_host.startsWith('http')) {\n    forge.value.oauth_host = `https://${forge.value.oauth_host}`;\n  }\n\n  emit('submit');\n}\n</script>\n"
  },
  {
    "path": "web/src/components/admin/settings/queue/AdminQueueStats.vue",
    "content": "<template>\n  <div v-if=\"stats\" class=\"flex justify-center\">\n    <div\n      class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-200 dark:bg-wp-background-200 text-wp-text-100 w-full rounded-md border px-5 py-5\"\n    >\n      <div class=\"flex w-full\">\n        <h3 class=\"flex-1 text-lg leading-tight font-semibold uppercase\">\n          {{ $t('admin.settings.queue.stats.completed_count') }}\n        </h3>\n      </div>\n      <div class=\"relative overflow-hidden transition-all duration-500\">\n        <div>\n          <div class=\"pb-4 lg:pb-6\">\n            <h4 class=\"inline-block text-2xl leading-tight font-semibold lg:text-3xl\">\n              {{ stats.completed_count }}\n            </h4>\n          </div>\n          <div v-if=\"total > 0\" class=\"pb-4 lg:pb-6\">\n            <div class=\"flex h-3 overflow-hidden rounded-full transition-all duration-500\">\n              <div\n                v-for=\"item in data\"\n                :key=\"item.key\"\n                class=\"h-full\"\n                :class=\"`${item.color}`\"\n                :style=\"{ width: `${item.percentage}%` }\"\n              >\n                &nbsp;\n              </div>\n            </div>\n          </div>\n          <div class=\"-mx-4 flex sm:flex-wrap\">\n            <div\n              v-for=\"(item, index) in data\"\n              :key=\"item.key\"\n              class=\"px-4 sm:w-full md:w-1/4\"\n              :class=\"{ 'border-gray-300 md:border-l dark:border-gray-600': index !== 0 }\"\n            >\n              <div class=\"overflow-hidden text-sm text-ellipsis whitespace-nowrap\">\n                <span class=\"mr-1 inline-block h-2 w-2 rounded-full align-middle\" :class=\"`${item.color}`\">&nbsp;</span>\n                <span class=\"align-middle\">{{ item.label }}</span>\n              </div>\n              <div class=\"text-lg font-medium\">\n                {{ item.value }}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport type { QueueStats } from '~/lib/api/types/queue';\n\nconst props = defineProps<{\n  stats?: QueueStats;\n}>();\n\nconst { t } = useI18n();\n\nconst total = computed(() => {\n  if (!props.stats) {\n    return 0;\n  }\n\n  return (\n    props.stats.worker_count + props.stats.running_count + props.stats.pending_count + props.stats.waiting_on_deps_count\n  );\n});\n\nconst data = computed(() => {\n  if (!props.stats) {\n    return [];\n  }\n\n  return [\n    {\n      key: 'worker_count',\n      label: t('admin.settings.queue.stats.worker_count'),\n      value: props.stats.worker_count,\n      percentage: total.value > 0 ? (props.stats.worker_count / total.value) * 100 : 0,\n      color: 'bg-wp-state-ok-100',\n    },\n    {\n      key: 'running_count',\n      label: t('admin.settings.queue.stats.running_count'),\n      value: props.stats.running_count,\n      percentage: total.value > 0 ? (props.stats.running_count / total.value) * 100 : 100,\n      color: 'bg-wp-state-info-100',\n    },\n    {\n      key: 'pending_count',\n      label: t('admin.settings.queue.stats.pending_count'),\n      value: props.stats.pending_count,\n      percentage: total.value > 0 ? (props.stats.pending_count / total.value) * 100 : 0,\n      color: 'bg-wp-state-neutral-100',\n    },\n    {\n      key: 'waiting_on_deps_count',\n      label: t('admin.settings.queue.stats.waiting_on_deps_count'),\n      value: props.stats.waiting_on_deps_count,\n      percentage: total.value > 0 ? (props.stats.waiting_on_deps_count / total.value) * 100 : 0,\n      color: 'bg-wp-error-100 dark:bg-wp-error-200',\n    },\n  ];\n});\n</script>\n"
  },
  {
    "path": "web/src/components/agent/AgentForm.vue",
    "content": "<template>\n  <form @submit.prevent=\"$emit('save')\">\n    <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.name.name')\">\n      <TextField :id=\"id\" v-model=\"agent.name\" :placeholder=\"$t('admin.settings.agents.name.placeholder')\" required />\n    </InputField>\n\n    <InputField :label=\"$t('admin.settings.agents.no_schedule.name')\">\n      <Checkbox\n        :model-value=\"agent.no_schedule || false\"\n        :label=\"$t('admin.settings.agents.no_schedule.placeholder')\"\n        @update:model-value=\"updateAgent({ no_schedule: $event })\"\n      />\n    </InputField>\n\n    <template v-if=\"isEditingAgent\">\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.token')\">\n        <TextField :id=\"id\" v-model=\"agent.token\" :placeholder=\"$t('admin.settings.agents.token')\" disabled />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.id')\">\n        <TextField :id=\"id\" :model-value=\"agent.id?.toString()\" disabled />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.backend.backend')\" :docs-url=\"backendDocsUrl\">\n        <TextField :id=\"id\" v-model=\"agent.backend\" disabled />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.platform.platform')\">\n        <TextField :id=\"id\" v-model=\"agent.platform\" disabled />\n      </InputField>\n\n      <InputField\n        v-if=\"agent.custom_labels && Object.keys(agent.custom_labels).length > 0\"\n        v-slot=\"{ id }\"\n        :label=\"$t('admin.settings.agents.custom_labels.custom_labels')\"\n      >\n        <span class=\"text-wp-text-alt-100\">{{ $t('admin.settings.agents.custom_labels.desc') }}</span>\n        <TextField :id=\"id\" :model-value=\"formatCustomLabels(agent.custom_labels)\" disabled />\n      </InputField>\n\n      <InputField\n        v-slot=\"{ id }\"\n        :label=\"$t('admin.settings.agents.capacity.capacity')\"\n        docs-url=\"docs/administration/configuration/agent#max_workflows\"\n      >\n        <span class=\"text-wp-text-alt-100\">{{ $t('admin.settings.agents.capacity.desc') }}</span>\n        <TextField :id=\"id\" :model-value=\"agent.capacity?.toString()\" disabled />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.version')\">\n        <TextField :id=\"id\" :model-value=\"agent.version\" disabled />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.agents.last_contact.last_contact')\">\n        <TextField\n          :id=\"id\"\n          :model-value=\"\n            agent.last_contact ? date.timeAgo(agent.last_contact * 1000) : $t('admin.settings.agents.never')\n          \"\n          disabled\n        />\n      </InputField>\n    </template>\n\n    <div class=\"flex gap-2\">\n      <Button type=\"button\" color=\"gray\" :text=\"$t('cancel')\" @click=\"$emit('cancel')\" />\n      <Button\n        :is-loading=\"isSaving\"\n        type=\"submit\"\n        color=\"green\"\n        :text=\"isEditingAgent ? $t('admin.settings.agents.save') : $t('admin.settings.agents.add')\"\n      />\n    </div>\n  </form>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport { useDate } from '~/compositions/useDate';\nimport type { Agent } from '~/lib/api/types';\n\nconst props = defineProps<{\n  modelValue: Partial<Agent>;\n  isEditingAgent: boolean;\n  isSaving: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: Partial<Agent>): void;\n  (e: 'save'): void;\n  (e: 'cancel'): void;\n}>();\n\nconst date = useDate();\n\nconst agent = computed({\n  get: () => props.modelValue,\n  set: (value) => emit('update:modelValue', value),\n});\n\nconst baseDocsUrl = 'https://woodpecker-ci.org/docs/administration/configuration/backends/';\n\nconst backendDocsUrl = computed(() => {\n  let backendUrlSuffix = agent.value.backend?.toLowerCase();\n  if (backendUrlSuffix === 'custom') {\n    backendUrlSuffix = 'custom-backends';\n  }\n  return `${baseDocsUrl}${backendUrlSuffix === '' ? 'docker' : backendUrlSuffix}`;\n});\n\nfunction updateAgent(newValues: Partial<Agent>) {\n  emit('update:modelValue', { ...agent.value, ...newValues });\n}\n\nfunction formatCustomLabels(labels: Record<string, string>): string {\n  return Object.entries(labels)\n    .map(([key, value]) => `${key}=${value}`)\n    .join(', ');\n}\n</script>\n"
  },
  {
    "path": "web/src/components/agent/AgentList.vue",
    "content": "<template>\n  <div class=\"text-wp-text-100 space-y-4\">\n    <ListItem v-for=\"agent in agents\" :key=\"agent.id\" class=\"items-center gap-2\">\n      <span>{{ agent.name || `Agent ${agent.id}` }}</span>\n      <span class=\"ml-auto flex gap-2\">\n        <Badge v-if=\"agent.no_schedule\" :value=\"$t('disabled')\" />\n        <Badge\n          v-if=\"isAdmin === true && agent.org_id !== -1\"\n          :label=\"$t('admin.settings.agents.org.badge')\"\n          :value=\"agent.org_id\"\n        />\n        <Badge v-if=\"agent.platform\" :label=\"$t('admin.settings.agents.platform.badge')\" :value=\"agent.platform\" />\n        <Badge v-if=\"agent.backend\" :label=\"$t('admin.settings.agents.backend.badge')\" :value=\"agent.backend\" />\n        <Badge v-if=\"agent.capacity\" :label=\"$t('admin.settings.agents.capacity.badge')\" :value=\"agent.capacity\" />\n        <Badge\n          :label=\"$t('admin.settings.agents.last_contact.badge')\"\n          :value=\"agent.last_contact ? date.timeAgo(agent.last_contact * 1000) : $t('admin.settings.agents.never')\"\n        />\n      </span>\n      <div class=\"ml-2 flex items-center gap-2\">\n        <IconButton\n          icon=\"edit\"\n          :title=\"$t('admin.settings.agents.edit_agent')\"\n          class=\"h-8 w-8\"\n          @click=\"$emit('edit', agent)\"\n        />\n        <IconButton\n          icon=\"trash\"\n          :title=\"$t('admin.settings.agents.delete_agent')\"\n          class=\"hover:text-wp-error-100 h-8 w-8\"\n          :is-loading=\"isDeleting\"\n          @click=\"$emit('delete', agent)\"\n        />\n      </div>\n    </ListItem>\n\n    <div v-if=\"loading\" class=\"flex justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n    <div v-else-if=\"agents?.length === 0\" class=\"ml-2\">{{ $t('admin.settings.agents.none') }}</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport Badge from '~/components/atomic/Badge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport { useDate } from '~/compositions/useDate';\nimport type { Agent } from '~/lib/api/types';\n\ndefineProps<{\n  agents: Agent[];\n  isDeleting: boolean;\n  loading: boolean;\n  isAdmin?: boolean;\n}>();\n\ndefineEmits<{\n  (e: 'edit', agent: Agent): void;\n  (e: 'delete', agent: Agent): void;\n}>();\n\nconst date = useDate();\n</script>\n"
  },
  {
    "path": "web/src/components/agent/AgentManager.vue",
    "content": "<template>\n  <Settings :title=\"$t('admin.settings.agents.agents')\" :description>\n    <template #headerActions>\n      <Button\n        v-if=\"selectedAgent\"\n        :text=\"$t('admin.settings.agents.show')\"\n        start-icon=\"back\"\n        @click=\"selectedAgent = undefined\"\n      />\n      <Button v-else :text=\"$t('admin.settings.agents.add')\" start-icon=\"plus\" @click=\"showAddAgent\" />\n    </template>\n\n    <AgentList\n      v-if=\"!selectedAgent\"\n      :loading=\"loading\"\n      :agents=\"agents\"\n      :is-deleting=\"isDeleting\"\n      :is-admin=\"isAdmin\"\n      @edit=\"editAgent\"\n      @delete=\"deleteAgent\"\n    />\n    <AgentForm\n      v-else\n      v-model=\"selectedAgent\"\n      :is-editing-agent=\"isEditingAgent\"\n      :is-saving=\"isSaving\"\n      @save=\"saveAgent\"\n      @cancel=\"selectedAgent = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport type { Agent } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nimport AgentForm from './AgentForm.vue';\nimport AgentList from './AgentList.vue';\n\nconst props = defineProps<{\n  description: string;\n  loadAgents: (page: number) => Promise<Agent[] | null>;\n  createAgent: (agent: Partial<Agent>) => Promise<Agent>;\n  updateAgent: (agent: Agent) => Promise<Agent | void>;\n  deleteAgent: (agent: Agent) => Promise<unknown>;\n  isAdmin?: boolean;\n}>();\n\nconst notifications = useNotifications();\nconst { t } = useI18n();\n\nconst selectedAgent = ref<Partial<Agent>>();\nconst isEditingAgent = computed(() => !!selectedAgent.value?.id);\n\nconst { resetPage, data: agents, loading } = usePagination(props.loadAgents);\n\nconst { doSubmit: saveAgent, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedAgent.value) {\n    throw new Error(\"Unexpected: Can't get agent\");\n  }\n\n  if (isEditingAgent.value) {\n    await props.updateAgent(selectedAgent.value as Agent);\n    selectedAgent.value = undefined;\n  } else {\n    selectedAgent.value = await props.createAgent(selectedAgent.value);\n  }\n  notifications.notify({\n    title: isEditingAgent.value ? t('admin.settings.agents.saved') : t('admin.settings.agents.created'),\n    type: 'success',\n  });\n  await resetPage();\n});\n\nconst { doSubmit: deleteAgent, isLoading: isDeleting } = useAsyncAction(async (_agent: Agent) => {\n  // eslint-disable-next-line no-alert\n  if (!confirm(t('admin.settings.agents.delete_confirm'))) {\n    return;\n  }\n\n  await props.deleteAgent(_agent);\n  notifications.notify({ title: t('admin.settings.agents.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editAgent(agent: Agent) {\n  selectedAgent.value = deepClone(agent);\n}\n\nfunction showAddAgent() {\n  selectedAgent.value = { name: '' };\n}\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/Badge.vue",
    "content": "<template>\n  <span class=\"inline-flex items-center text-xs font-medium\">\n    <span\n      v-if=\"label\"\n      class=\"border-wp-state-neutral-100 bg-wp-state-neutral-100 rounded-l-full border-1 py-0.5 pr-1 pl-2 whitespace-nowrap text-white\"\n      :class=\"{\n        'rounded-r-full pr-2': !value,\n      }\"\n    >\n      {{ label }}\n    </span>\n    <span\n      v-if=\"value\"\n      class=\"border-wp-state-neutral-100 rounded-r-full border-1 py-0.5 pr-2 pl-1 whitespace-nowrap\"\n      :class=\"{\n        'rounded-l-full pl-2': !label,\n      }\"\n    >\n      {{ value }}\n    </span>\n  </span>\n</template>\n\n<script lang=\"ts\" setup>\ndefineProps<{\n  label?: string;\n  value?: string | number;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/Button.vue",
    "content": "<template>\n  <component\n    :is=\"to === undefined ? 'button' : httpLink ? 'a' : 'router-link'\"\n    v-bind=\"btnAttrs\"\n    class=\"border-wp-control-neutral-200 relative flex shrink-0 cursor-pointer items-center overflow-hidden rounded-md border px-2 py-1 whitespace-nowrap transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-50\"\n    :class=\"{\n      'border-wp-control-neutral-200 bg-wp-control-neutral-100 text-wp-text-100 hover:bg-wp-control-neutral-200':\n        color === 'gray',\n      'border-wp-control-ok-300 bg-wp-control-ok-100 hover:bg-wp-control-ok-200 text-white': color === 'green',\n      'border-wp-control-info-300 bg-wp-control-info-100 hover:bg-wp-control-info-200 text-white': color === 'blue',\n      'border-wp-error-300 bg-wp-error-100 hover:bg-wp-error-200 text-white': color === 'red',\n      ...passedClasses,\n    }\"\n    :title=\"title\"\n    :disabled=\"disabled\"\n  >\n    <slot>\n      <Icon v-if=\"startIcon\" :name=\"startIcon\" class=\"h-5! w-5!\" :class=\"{ invisible: isLoading, 'mr-1': text }\" />\n      <span :class=\"{ invisible: isLoading }\">{{ text }}</span>\n      <Icon v-if=\"endIcon\" :name=\"endIcon\" class=\"ml-2 h-6 w-6\" :class=\"{ invisible: isLoading }\" />\n      <div\n        v-if=\"isLoading\"\n        class=\"absolute top-0 right-0 bottom-0 left-0 flex items-center justify-center\"\n        :class=\"{\n          'bg-wp-control-neutral-200': color === 'gray',\n          'bg-wp-control-ok-200': color === 'green',\n          'bg-wp-control-info-200': color === 'blue',\n          'bg-wp-error-200': color === 'red',\n        }\"\n      >\n        <Icon name=\"spinner\" />\n      </div>\n    </slot>\n  </component>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, useAttrs } from 'vue';\nimport type { RouteLocationRaw } from 'vue-router';\n\nimport type { IconNames } from '~/components/atomic/Icon.vue';\nimport Icon from '~/components/atomic/Icon.vue';\n\nconst props = withDefaults(\n  defineProps<{\n    text?: string;\n    title?: string;\n    disabled?: boolean;\n    to?: RouteLocationRaw;\n    color?: 'blue' | 'green' | 'red' | 'gray';\n    startIcon?: IconNames;\n    endIcon?: IconNames;\n    isLoading?: boolean;\n  }>(),\n  {\n    text: undefined,\n    title: undefined,\n    to: undefined,\n    color: 'gray',\n    startIcon: undefined,\n    endIcon: undefined,\n  },\n);\n\nconst httpLink = computed(() => typeof props.to === 'string' && props.to.startsWith('http'));\n\nconst btnAttrs = computed(() => {\n  if (props.to === null) {\n    return { type: 'button' };\n  }\n\n  if (httpLink.value) {\n    return { href: props.to };\n  }\n\n  return { to: props.to };\n});\n\nconst attrs = useAttrs();\nconst passedClasses = computed(() => {\n  const classes: Record<string, boolean> = {};\n  const origClass = (attrs.class as string) || '';\n  origClass.split(' ').forEach((c) => {\n    classes[c] = true;\n  });\n  return classes;\n});\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/CountBadge.vue",
    "content": "<template>\n  <span\n    class=\"bg-wp-control-neutral-200 text-wp-text-100 dark:bg-wp-background-100 inline-block min-w-5 rounded-full px-1.5 py-0.5 text-center text-xs leading-4 font-bold\"\n  >\n    {{ value }}\n  </span>\n</template>\n\n<script lang=\"ts\" setup>\ndefineProps<{\n  value?: string | number;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/DocsLink.vue",
    "content": "<template>\n  <a\n    :href=\"`${docsUrl}`\"\n    :title=\"$t('documentation_for', { topic })\"\n    target=\"_blank\"\n    class=\"text-wp-link-100 hover:text-wp-link-200 cursor-pointer\"\n  >\n    <Icon name=\"question\" class=\"h-5! w-5!\" />\n  </a>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport Icon from '~/components/atomic/Icon.vue';\n\nconst props = defineProps<{\n  url: string;\n  topic: string;\n}>();\n\nconst url = toRef(props, 'url');\nconst topic = toRef(props, 'topic');\nconst docsUrl = computed(() => (url.value.startsWith('http') ? url.value : `https://woodpecker-ci.org/${url.value}`));\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/Error.vue",
    "content": "<template>\n  <div\n    class=\"border-wp-error-200 bg-wp-error-100 flex items-center gap-2 rounded-md border border-l-4 border-solid p-2 text-white\"\n  >\n    <Icon v-if=\"!textOnly\" name=\"alert\" />\n    <slot>\n      <span class=\"whitespace-pre\">{{ text }}</span>\n    </slot>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport Icon from './Icon.vue';\n\ndefineProps<{\n  textOnly?: boolean;\n  text?: string;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/Icon.vue",
    "content": "<!-- cSpell:ignore radiobox timelapse -->\n<template>\n  <SvgIcon v-if=\"name === 'duration'\" :bg-circle=\"bgCircle\" :path=\"mdiTimerOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'since'\" :bg-circle=\"bgCircle\" :path=\"mdiClockTimeEightOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'branch'\" :bg-circle=\"bgCircle\" :path=\"mdiSourceBranch\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'pull-request'\" :bg-circle=\"bgCircle\" :path=\"mdiSourcePull\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'pull-request-closed'\" :bg-circle=\"bgCircle\" :path=\"mdiSourceMerge\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'pull-request-metadata'\" :bg-circle=\"bgCircle\" :path=\"mdiPencilOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'manual-pipeline'\" :bg-circle=\"bgCircle\" :path=\"mdiGestureTap\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'tag'\" :bg-circle=\"bgCircle\" :path=\"mdiTagOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'deployment'\" :bg-circle=\"bgCircle\" :path=\"mdiPackageVariant\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'commit'\" :bg-circle=\"bgCircle\" :path=\"mdiSourceCommit\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'back'\" :bg-circle=\"bgCircle\" :path=\"mdiArrowLeft\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'github'\" :bg-circle=\"bgCircle\" :path=\"mdiGithub\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'repo'\" :bg-circle=\"bgCircle\" :path=\"mdiGit\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'settings'\" :bg-circle=\"bgCircle\" :path=\"mdiCog\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'settings-outline'\" :bg-circle=\"bgCircle\" :path=\"mdiCogOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'trash'\" :bg-circle=\"bgCircle\" :path=\"mdiTrashCanOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'status-blocked'\" :bg-circle=\"bgCircle\" :path=\"mdiPlayCircle\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'status-declined'\" :bg-circle=\"bgCircle\" :path=\"mdiStopCircle\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'list-group'\" :bg-circle=\"bgCircle\" :path=\"mdiFormatListGroup\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'secret'\" :bg-circle=\"bgCircle\" :path=\"mdiShieldKeyOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'console'\" :bg-circle=\"bgCircle\" :path=\"mdiConsole\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'agent'\" :bg-circle=\"bgCircle\" :path=\"mdiPlayNetworkOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'info'\" :bg-circle=\"bgCircle\" :path=\"mdiInformationSlabCircleOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'user'\" :bg-circle=\"bgCircle\" :path=\"mdiAccountOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'org'\" :bg-circle=\"bgCircle\" :path=\"mdiAccountGroupOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'cron'\" :bg-circle=\"bgCircle\" :path=\"mdiCalendarClockOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'toolbox'\" :bg-circle=\"bgCircle\" :path=\"mdiToolboxOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'puzzle'\" :bg-circle=\"bgCircle\" :path=\"mdiPuzzleOutline\" size=\"1.3rem\" />\n  <SvgIcon\n    v-else-if=\"name === 'status-failure' || name === 'status-error' || name === 'status-killed'\"\n    type=\"mdi\"\n    :bg-circle=\"bgCircle\"\n    :path=\"mdiCloseCircle\"\n    size=\"1.3rem\"\n  />\n  <SvgIcon\n    v-else-if=\"name === 'status-pending' || name === 'status-created'\"\n    :bg-circle=\"bgCircle\"\n    :path=\"mdiRadioboxBlank\"\n    size=\"1.3rem\"\n  />\n  <SvgIcon\n    v-else-if=\"name === 'status-running' || name === 'status-started'\"\n    type=\"mdi\"\n    :bg-circle=\"bgCircle\"\n    :path=\"mdiRadioboxIndeterminateVariant\"\n    size=\"1.3rem\"\n  />\n  <SvgIcon\n    v-else-if=\"name === 'status-skipped' || name === 'status-canceled'\"\n    :bg-circle=\"bgCircle\"\n    :path=\"mdiMinusCircle\"\n    size=\"1.3rem\"\n  />\n  <SvgIcon v-else-if=\"name === 'status-success'\" :bg-circle=\"bgCircle\" :path=\"mdiCheckCircle\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'alert'\" :bg-circle=\"bgCircle\" :path=\"mdiAlertCircle\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'question'\" :bg-circle=\"bgCircle\" :path=\"mdiHelpCircle\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'plus'\" :bg-circle=\"bgCircle\" :path=\"mdiPlus\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'list'\" :bg-circle=\"bgCircle\" :path=\"mdiFormatListBulleted\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'heal'\" :bg-circle=\"bgCircle\" :path=\"mdiWrenchCogOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'turn-off'\" :bg-circle=\"bgCircle\" :path=\"mdiPower\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'chevron-right'\" :bg-circle=\"bgCircle\" :path=\"mdiChevronRight\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'close'\" :bg-circle=\"bgCircle\" :path=\"mdiClose\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'edit'\" :bg-circle=\"bgCircle\" :path=\"mdiPencilOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'download'\" :bg-circle=\"bgCircle\" :path=\"mdiDownloadOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'stopwatch'\" :bg-circle=\"bgCircle\" :path=\"mdiAlarm\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'auto-scroll'\" :bg-circle=\"bgCircle\" :path=\"mdiEyeOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'auto-scroll-off'\" :bg-circle=\"bgCircle\" :path=\"mdiEyeOffOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'pause'\" :bg-circle=\"bgCircle\" :path=\"mdiPause\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'play'\" :bg-circle=\"bgCircle\" :path=\"mdiPlay\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'play-outline'\" :bg-circle=\"bgCircle\" :path=\"mdiPlayOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'dots'\" :bg-circle=\"bgCircle\" :path=\"mdiDotsVertical\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'tray-full'\" :bg-circle=\"bgCircle\" :path=\"mdiTrayFull\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'file-cog-outline'\" :bg-circle=\"bgCircle\" :path=\"mdiFileCogOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'file-edit-outline'\" :bg-circle=\"bgCircle\" :path=\"mdiFileEditOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'folder'\" :bg-circle=\"bgCircle\" :path=\"mdiFolderOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'folder-open'\" :bg-circle=\"bgCircle\" :path=\"mdiFolderOpenOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'file'\" :bg-circle=\"bgCircle\" :path=\"mdiFileOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'bug-outline'\" :bg-circle=\"bgCircle\" :path=\"mdiBugOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'docker'\" :bg-circle=\"bgCircle\" :path=\"mdiDocker\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'forge'\" :bg-circle=\"bgCircle\" :path=\"mdiCodeBraces\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'fullscreen'\" :bg-circle=\"bgCircle\" :path=\"mdiFullscreen\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'exit-fullscreen'\" :bg-circle=\"bgCircle\" :path=\"mdiFullscreenExit\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'expand-all'\" :bg-circle=\"bgCircle\" :path=\"mdiUnfoldMoreHorizontal\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'collapse-all'\" :bg-circle=\"bgCircle\" :path=\"mdiUnfoldLessHorizontal\" size=\"1.3rem\" />\n\n  <SvgIcon v-else-if=\"name === 'visibility-private'\" :bg-circle=\"bgCircle\" :path=\"mdiLockOutline\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'visibility-internal'\" :bg-circle=\"bgCircle\" :path=\"mdiLockOpenOutline\" size=\"1.3rem\" />\n\n  <SvgIcon v-else-if=\"name === 'forgejo'\" :bg-circle=\"bgCircle\" :path=\"siForgejo.path\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'gitea'\" :bg-circle=\"bgCircle\" :path=\"siGitea.path\" size=\"1.3rem\" />\n  <SvgIcon v-else-if=\"name === 'gitlab'\" :bg-circle=\"bgCircle\" :path=\"mdiGitlab\" size=\"1.3rem\" />\n  <SvgIcon\n    v-else-if=\"name === 'bitbucket' || name === 'bitbucket-dc'\"\n    :bg-circle=\"bgCircle\"\n    :path=\"mdiBitbucket\"\n    size=\"1.3rem\"\n  />\n\n  <svg v-else-if=\"name === 'spinner'\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path\n      fill=\"currentColor\"\n      d=\"M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z\"\n      opacity=\".25\"\n    />\n    <path\n      fill=\"currentColor\"\n      d=\"M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z\"\n    >\n      <animateTransform\n        attributeName=\"transform\"\n        type=\"rotate\"\n        dur=\"0.75s\"\n        values=\"0 12 12;360 12 12\"\n        repeatCount=\"indefinite\"\n      />\n    </path>\n  </svg>\n\n  <div v-else-if=\"name === 'blank'\" class=\"h-6 w-6\" />\n\n  <div v-else>{{ throwNotFound() }}</div>\n</template>\n\n<script lang=\"ts\" setup>\nimport {\n  mdiAccountGroupOutline,\n  mdiAccountOutline,\n  mdiAlarm,\n  mdiAlertCircle,\n  mdiArrowLeft,\n  mdiBitbucket,\n  mdiBugOutline,\n  mdiCalendarClockOutline,\n  mdiCheckCircle,\n  mdiChevronRight,\n  mdiClockTimeEightOutline,\n  mdiClose,\n  mdiCloseCircle,\n  mdiCodeBraces,\n  mdiCog,\n  mdiCogOutline,\n  mdiConsole,\n  mdiDocker,\n  mdiDotsVertical,\n  mdiDownloadOutline,\n  mdiEyeOffOutline,\n  mdiEyeOutline,\n  mdiFileCogOutline,\n  mdiFileEditOutline,\n  mdiFileOutline,\n  mdiFolderOpenOutline,\n  mdiFolderOutline,\n  mdiFormatListBulleted,\n  mdiFormatListGroup,\n  mdiFullscreen,\n  mdiFullscreenExit,\n  mdiGestureTap,\n  mdiGit,\n  mdiGithub,\n  mdiGitlab,\n  mdiHelpCircle,\n  mdiInformationSlabCircleOutline,\n  mdiLockOpenOutline,\n  mdiLockOutline,\n  mdiMinusCircle,\n  mdiPackageVariant,\n  mdiPause,\n  mdiPencilOutline,\n  mdiPlay,\n  mdiPlayCircle,\n  mdiPlayNetworkOutline,\n  mdiPlayOutline,\n  mdiPlus,\n  mdiPower,\n  mdiPuzzleOutline,\n  mdiRadioboxBlank,\n  mdiRadioboxIndeterminateVariant,\n  mdiShieldKeyOutline,\n  mdiSourceBranch,\n  mdiSourceCommit,\n  mdiSourceMerge,\n  mdiSourcePull,\n  mdiStopCircle,\n  mdiTagOutline,\n  mdiTimerOutline,\n  mdiToolboxOutline,\n  mdiTrashCanOutline,\n  mdiTrayFull,\n  mdiUnfoldLessHorizontal,\n  mdiUnfoldMoreHorizontal,\n  mdiWrenchCogOutline,\n} from '@mdi/js';\nimport { siForgejo, siGitea } from 'simple-icons';\n\nimport SvgIcon from './SvgIcon.vue';\n\nexport type IconNames =\n  | 'duration'\n  | 'since'\n  | 'branch'\n  | 'pull-request'\n  | 'pull-request-closed'\n  | 'pull-request-metadata'\n  | 'manual-pipeline'\n  | 'tag'\n  | 'deployment'\n  | 'commit'\n  | 'back'\n  | 'github'\n  | 'repo'\n  | 'settings'\n  | 'settings-outline'\n  | 'trash'\n  | 'status-blocked'\n  | 'status-declined'\n  | 'status-error'\n  | 'status-failure'\n  | 'status-killed'\n  | 'status-pending'\n  | 'status-created'\n  | 'status-running'\n  | 'status-skipped'\n  | 'status-started'\n  | 'status-success'\n  | 'status-canceled'\n  | 'gitea'\n  | 'gitlab'\n  | 'bitbucket'\n  | 'bitbucket-dc'\n  | 'forgejo'\n  | 'question'\n  | 'list'\n  | 'plus'\n  | 'blank'\n  | 'heal'\n  | 'chevron-right'\n  | 'turn-off'\n  | 'close'\n  | 'edit'\n  | 'stopwatch'\n  | 'download'\n  | 'auto-scroll'\n  | 'auto-scroll-off'\n  | 'play'\n  | 'play-outline'\n  | 'pause'\n  | 'alert'\n  | 'spinner'\n  | 'visibility-private'\n  | 'visibility-internal'\n  | 'dots'\n  | 'tray-full'\n  | 'file-cog-outline'\n  | 'file-edit-outline'\n  | 'bug-outline'\n  | 'list-group'\n  | 'secret'\n  | 'docker'\n  | 'console'\n  | 'agent'\n  | 'info'\n  | 'repo'\n  | 'user'\n  | 'org'\n  | 'cron'\n  | 'toolbox'\n  | 'puzzle'\n  | 'forge'\n  | 'fullscreen'\n  | 'exit-fullscreen'\n  | 'expand-all'\n  | 'collapse-all'\n  | 'folder'\n  | 'folder-open'\n  | 'file';\n\nconst props = defineProps<{\n  name: IconNames;\n  bgCircle?: boolean;\n}>();\n\nfunction throwNotFound() {\n  throw new Error(`Icon \"${props.name}\" not found`);\n}\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/IconButton.vue",
    "content": "<template>\n  <router-link v-if=\"to\" :to=\"to\" :title=\"title\" :aria-label=\"title\" class=\"icon-button h-8 w-8\">\n    <slot>\n      <Icon v-if=\"icon\" :name=\"icon\" />\n    </slot>\n  </router-link>\n  <a\n    v-else-if=\"href\"\n    :href=\"href\"\n    :title=\"title\"\n    :aria-label=\"title\"\n    class=\"icon-button\"\n    target=\"_blank\"\n    rel=\"noopener noreferrer\"\n  >\n    <slot>\n      <Icon v-if=\"icon\" :name=\"icon\" />\n    </slot>\n  </a>\n  <button v-else :disabled=\"disabled\" class=\"icon-button\" type=\"button\" :title=\"title\" :aria-label=\"title\">\n    <slot>\n      <Icon v-if=\"icon\" :name=\"icon\" />\n    </slot>\n    <div v-if=\"isLoading\" class=\"absolute top-0 right-0 bottom-0 left-0 flex items-center justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n  </button>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { RouteLocationRaw } from 'vue-router';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport type { IconNames } from '~/components/atomic/Icon.vue';\n\ndefineProps<{\n  icon?: IconNames;\n  disabled?: boolean;\n  to?: RouteLocationRaw;\n  isLoading?: boolean;\n  title?: string;\n  href?: string;\n}>();\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n.icon-button {\n  @apply hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-300 relative flex shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md bg-transparent px-1 py-1 disabled:cursor-not-allowed disabled:opacity-50;\n  @apply in-[.border-wp-background-400]:hover:bg-wp-control-neutral-200;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/atomic/ListItem.vue",
    "content": "<template>\n  <component\n    :is=\"to ? 'router-link' : clickable ? 'button' : 'div'\"\n    :to=\"to\"\n    class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-200 dark:bg-wp-background-200 flex w-full overflow-hidden rounded-md border p-4\"\n    :class=\"{\n      'hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 cursor-pointer': clickable || to,\n    }\"\n  >\n    <slot />\n  </component>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { RouteLocationRaw } from 'vue-router';\n\ndefineProps<{\n  clickable?: boolean;\n  to?: RouteLocationRaw;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/RenderMarkdown.vue",
    "content": "<template>\n  <span v-html=\"contentHTML\" />\n</template>\n\n<script setup lang=\"ts\">\nimport DOMPurify from 'dompurify';\nimport { marked } from 'marked';\nimport { computed } from 'vue';\n\nconst props = defineProps<{\n  content: string;\n  inline?: boolean;\n}>();\n\nconst contentHTML = computed<string>(() => {\n  const dirtyHTML = props.inline ? marked.parseInline(props.content) : marked.parse(props.content);\n  return DOMPurify.sanitize(dirtyHTML as string, { USE_PROFILES: { html: true } });\n});\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/SvgIcon.vue",
    "content": "<template>\n  <svg fill=\"currentColor\" :width=\"size\" :height=\"size\" viewBox=\"0 0 24 24\">\n    <circle v-if=\"bgCircle\" cx=\"12\" cy=\"12\" :r=\"9\" class=\"fill-transparent dark:fill-gray-300\" />\n    <path :d=\"path\" />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ndefineProps<{\n  path: string;\n  size: number | string;\n  bgCircle: boolean;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/atomic/SyntaxHighlight.ts",
    "content": "import '~/style/prism.css';\n\nimport Prism from 'prismjs';\nimport { computed, defineComponent, h, toRef } from 'vue';\nimport type { VNode } from 'vue';\n\ndeclare type Data = Record<string, unknown>;\n\nexport default defineComponent({\n  name: 'SyntaxHighlight',\n\n  props: {\n    code: {\n      type: String,\n      default: '',\n    },\n\n    language: {\n      type: String,\n      default: 'yaml',\n    },\n  },\n\n  setup(props, { attrs }: { attrs: Data }) {\n    const code = toRef(props, 'code');\n    const language = toRef(props, 'language');\n    const prismLanguage = computed(() => Prism.languages[language.value]);\n    const className = computed(() => `language-${language.value}`);\n\n    return (): VNode =>\n      h('pre', { ...attrs, class: [attrs.class, className] }, [\n        h('code', {\n          class: className,\n          innerHTML: Prism.highlight(code.value, prismLanguage.value, language.value),\n        }),\n      ]);\n  },\n});\n"
  },
  {
    "path": "web/src/components/atomic/Warning.vue",
    "content": "<template>\n  <div\n    class=\"border-wp-hint-warn-200 bg-wp-hint-warn-100 flex items-center gap-4 rounded-md border border-l-4 border-solid p-4 font-bold text-gray-700\"\n  >\n    <Icon v-if=\"!textOnly\" name=\"alert\" class=\"shrink-0\" />\n    <slot>\n      <span class=\"whitespace-pre-wrap\">{{ text }}</span>\n    </slot>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport Icon from './Icon.vue';\n\ndefineProps<{\n  textOnly?: boolean;\n  text?: string;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/form/Checkbox.vue",
    "content": "<template>\n  <div class=\"mb-2 flex items-center\">\n    <input\n      :id=\"`checkbox-${id}`\"\n      type=\"checkbox\"\n      class=\"checkbox border-wp-control-neutral-200 disabled:border-wp-control-neutral-200 disabled:bg-wp-control-neutral-100 dark:disabled:bg-wp-control-neutral-200 checked:border-wp-control-ok-200 checked:bg-wp-control-ok-200 focus-visible:border-wp-control-neutral-300 checked:focus-visible:border-wp-control-ok-300 relative h-5 w-5 shrink-0 cursor-pointer rounded-md border transition-colors duration-150\"\n      :checked=\"innerValue\"\n      :disabled=\"disabled || false\"\n      @click=\"innerValue = !innerValue\"\n    />\n    <div class=\"ml-4 flex flex-col\">\n      <label class=\"text-wp-text-100 cursor-pointer\" :for=\"`checkbox-${id}`\">{{ label }}</label>\n      <span v-if=\"description\" class=\"text-wp-text-alt-100 text-sm\">{{ description }}</span>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nconst props = defineProps<{\n  modelValue: boolean;\n  label: string;\n  description?: string;\n  disabled?: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: boolean): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\n\nconst id = (Math.random() + 1).toString(36).substring(7);\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n.checkbox {\n  width: 1.3rem;\n  height: 1.3rem;\n  appearance: none;\n  outline: 0;\n  cursor: pointer;\n  transition: background 175ms cubic-bezier(0.1, 0.1, 0.25, 1);\n}\n\n.checkbox::before {\n  position: absolute;\n  content: '';\n  display: block;\n  top: 50%;\n  left: 50%;\n  width: 0.5rem;\n  height: 1rem;\n  border-style: solid;\n  border-color: white;\n  border-width: 0 2px 2px 0;\n  transform: translate(-50%, -60%) rotate(45deg);\n  opacity: 0;\n}\n\n.checkbox:disabled::before {\n  border-color: var(--wp-text-alt-100);\n}\n\n.checkbox:checked::before {\n  opacity: 1;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/form/CheckboxesField.vue",
    "content": "<template>\n  <Checkbox\n    v-for=\"option in options\"\n    :key=\"option.value\"\n    :model-value=\"innerValue.includes(option.value)\"\n    :label=\"option.text\"\n    :disabled=\"disabled ? disabled(option) : false\"\n    :description=\"option.description\"\n    class=\"mb-2\"\n    @update:model-value=\"clickOption(option)\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport Checkbox from './Checkbox.vue';\nimport type { CheckboxOption } from './form.types';\n\nconst props = defineProps<{\n  modelValue?: CheckboxOption['value'][];\n  options?: CheckboxOption[];\n  disabled?: (option: CheckboxOption) => boolean;\n}>();\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: CheckboxOption['value'][]): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value || [],\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\n\nfunction clickOption(option: CheckboxOption) {\n  if (innerValue.value.includes(option.value)) {\n    innerValue.value = innerValue.value.filter((o) => o !== option.value);\n  } else {\n    innerValue.value.push(option.value);\n  }\n}\n</script>\n"
  },
  {
    "path": "web/src/components/form/InputField.vue",
    "content": "<template>\n  <div class=\"mt-2 mb-4 flex flex-col\">\n    <div class=\"mb-2 flex items-center\">\n      <label class=\"text-wp-text-100 font-bold\" :for=\"id\" v-bind=\"$attrs\">{{ label }}</label>\n      <DocsLink v-if=\"docsUrl\" :topic=\"label\" :url=\"docsUrl\" class=\"ml-2\" />\n      <slot v-else-if=\"$slots.titleActions\" name=\"titleActions\" />\n    </div>\n    <div v-if=\"$slots.description\" class=\"text-wp-text-alt-100 mb-2 text-sm\">\n      <slot name=\"description\" />\n    </div>\n    <slot :id=\"id\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport DocsLink from '~/components/atomic/DocsLink.vue';\n\ndefineProps<{\n  label: string;\n  docsUrl?: string;\n}>();\n\nconst id = (Math.random() + 1).toString(36).substring(7);\n</script>\n\n<script lang=\"ts\">\nexport default {\n  inheritAttrs: false,\n};\n</script>\n"
  },
  {
    "path": "web/src/components/form/KeyValueEditor.vue",
    "content": "<template>\n  <div class=\"flex flex-col gap-2\">\n    <div v-for=\"(item, index) in displayItems\" :key=\"index\" class=\"flex gap-4\">\n      <TextField\n        :id=\"`${id}-key-${index}`\"\n        :model-value=\"item.key\"\n        :placeholder=\"keyPlaceholder\"\n        :class=\"{\n          'bg-red-100 dark:bg-red-900':\n            isDuplicateKey(item.key, index) || (item.key === '' && index !== displayItems.length - 1),\n        }\"\n        @update:model-value=\"updateItem(index, 'key', $event)\"\n      />\n      <TextField\n        :id=\"`${id}-value-${index}`\"\n        :model-value=\"item.value\"\n        :placeholder=\"valuePlaceholder\"\n        @update:model-value=\"updateItem(index, 'value', $event)\"\n      />\n      <div class=\"w-10 shrink-0\">\n        <Button\n          v-if=\"index !== displayItems.length - 1\"\n          type=\"button\"\n          color=\"red\"\n          class=\"ml-auto\"\n          :title=\"deleteTitle\"\n          @click=\"deleteItem(index)\"\n        >\n          <Icon name=\"close\" />\n        </Button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport TextField from '~/components/form/TextField.vue';\n\nconst props = defineProps<{\n  modelValue: Record<string, string>;\n  id?: string;\n  keyPlaceholder?: string;\n  valuePlaceholder?: string;\n  deleteTitle?: string;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', value: Record<string, string>): void;\n  (e: 'update:isValid', value: boolean): void;\n}>();\n\nconst items = ref(Object.entries(props.modelValue).map(([key, value]) => ({ key, value })));\n\nconst displayItems = computed(() => {\n  if (items.value.length === 0 || items.value.at(-1)?.key !== '') {\n    return [...items.value, { key: '', value: '' }];\n  }\n  return items.value;\n});\n\nfunction isDuplicateKey(key: string, index: number): boolean {\n  return items.value.some((item, i) => item.key === key && i !== index && key !== '');\n}\n\nfunction checkValidity() {\n  const isValid = items.value.every(\n    (item, idx) => !isDuplicateKey(item.key, idx) && (item.key !== '' || idx === items.value.length - 1),\n  );\n  emit('update:isValid', isValid);\n}\n\nfunction updateItem(index: number, field: 'key' | 'value', value: string) {\n  const newItems = [...items.value];\n  if (index === newItems.length) {\n    newItems.push({ key: '', value: '' });\n  }\n  newItems[index][field] = value;\n\n  items.value = newItems;\n\n  const newValue = Object.fromEntries(\n    newItems\n      .filter((item) => item.key !== '' && !isDuplicateKey(item.key, newItems.indexOf(item)))\n      .map((item) => [item.key, item.value]),\n  );\n\n  emit('update:modelValue', newValue);\n  checkValidity();\n}\n\nfunction deleteItem(index: number) {\n  items.value = items.value.filter((_, i) => i !== index);\n\n  const newValue = Object.fromEntries(\n    items.value\n      .filter((item) => item.key !== '' && !isDuplicateKey(item.key, items.value.indexOf(item)))\n      .map((item) => [item.key, item.value]),\n  );\n\n  emit('update:modelValue', newValue);\n  checkValidity();\n}\n</script>\n"
  },
  {
    "path": "web/src/components/form/NumberField.vue",
    "content": "<template>\n  <TextField v-model=\"innerValue\" :placeholder=\"placeholder\" type=\"number\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport TextField from '~/components/form/TextField.vue';\n\nconst props = defineProps<{\n  modelValue: number;\n  placeholder?: string;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: number): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value.toString(),\n  set: (value) => {\n    emit('update:modelValue', Number.parseFloat(value));\n  },\n});\n</script>\n"
  },
  {
    "path": "web/src/components/form/RadioField.vue",
    "content": "<template>\n  <div v-for=\"option in options\" :key=\"option.value\" class=\"mb-2 flex items-center\">\n    <input\n      :id=\"`radio-${id}-${option.value}`\"\n      type=\"radio\"\n      class=\"radio border-wp-control-neutral-200 disabled:border-wp-control-neutral-200 disabled:bg-wp-control-neutral-100 dark:disabled:bg-wp-control-neutral-200 checked:border-wp-control-ok-200 checked:bg-wp-control-ok-200 focus-visible:border-wp-control-neutral-300 checked:focus-visible:border-wp-control-ok-300 relative h-5 w-5 shrink-0 cursor-pointer rounded-full border\"\n      :value=\"option.value\"\n      :checked=\"innerValue?.includes(option.value)\"\n      :disabled=\"disabled || false\"\n      @click=\"innerValue = option.value\"\n    />\n    <div class=\"ml-4 flex flex-col\">\n      <label class=\"text-wp-text-100 cursor-pointer\" :for=\"`radio-${id}-${option.value}`\">{{ option.text }}</label>\n      <span v-if=\"option.description\" class=\"text-wp-text-alt-100 text-sm\">{{ option.description }}</span>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport type { RadioOption } from './form.types';\n\nconst props = defineProps<{\n  modelValue: string;\n  options: RadioOption[];\n  disabled?: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: string): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\n\nconst id = (Math.random() + 1).toString(36).substring(7);\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n.radio {\n  width: 1.3rem;\n  height: 1.3rem;\n  appearance: none;\n  outline: 0;\n  cursor: pointer;\n  transition: background 175ms cubic-bezier(0.1, 0.1, 0.25, 1);\n}\n\n.radio::before {\n  position: absolute;\n  content: '';\n  display: block;\n  top: 50%;\n  left: 50%;\n  width: 0.5rem;\n  height: 0.5rem;\n  border-radius: 50%;\n  background: white;\n  transform: translate(-50%, -50%);\n  opacity: 0;\n}\n\n.radio:disabled::before {\n  border-color: var(--wp-text-alt-100);\n}\n\n.radio:checked::before {\n  opacity: 1;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/form/SelectField.vue",
    "content": "<template>\n  <select\n    v-model=\"innerValue\"\n    class=\"border-wp-control-neutral-200 bg-wp-control-neutral-100 text-wp-text-100 w-full rounded-md border px-2 py-1\"\n  >\n    <option v-if=\"placeholder\" value=\"\" class=\"hidden\">{{ placeholder }}</option>\n    <option v-for=\"option in options\" :key=\"option.value\" :value=\"option.value\" class=\"text-wp-text-100\">\n      {{ option.text }}\n    </option>\n  </select>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport type { SelectOption } from './form.types';\n\nconst props = defineProps<{\n  modelValue: string;\n  placeholder?: string;\n  options: SelectOption[];\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: string): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (selectedValue) => {\n    emit('update:modelValue', selectedValue);\n  },\n});\n</script>\n"
  },
  {
    "path": "web/src/components/form/TextField.vue",
    "content": "<template>\n  <input\n    v-if=\"lines === 1\"\n    v-model=\"innerValue\"\n    class=\"border-wp-control-neutral-200 focus-visible:border-wp-control-neutral-300 w-full rounded-md border px-2 py-1 focus-visible:outline-hidden\"\n    :class=\"{ 'opacity-50': disabled }\"\n    :disabled=\"disabled\"\n    :type=\"type\"\n    :placeholder=\"placeholder\"\n  />\n  <textarea\n    v-else\n    v-model=\"innerValue\"\n    class=\"border-wp-control-neutral-200 dark:border-wp-control-neutral-100 focus-visible:border-wp-control-neutral-300 w-full rounded-md border px-2 py-1 focus-visible:outline-hidden\"\n    :class=\"{ 'opacity-50': disabled }\"\n    :disabled=\"disabled\"\n    :placeholder=\"placeholder\"\n    :rows=\"lines\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue?: string;\n    placeholder?: string;\n    type?: string;\n    lines?: number;\n    disabled?: boolean;\n  }>(),\n  {\n    modelValue: '',\n    placeholder: '',\n    type: 'text',\n    lines: 1,\n  },\n);\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: string): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\n</script>\n"
  },
  {
    "path": "web/src/components/form/form.types.ts",
    "content": "export interface SelectOption {\n  value: string;\n  text: string;\n  description?: string;\n}\n\nexport type RadioOption = SelectOption;\n\nexport type CheckboxOption = SelectOption;\n"
  },
  {
    "path": "web/src/components/layout/Container.vue",
    "content": "<template>\n  <div class=\"mx-auto w-full p-4\" :class=\"{ 'max-w-5xl': !fullWidth && !fillWidth, 'md:px-0': fullWidth }\">\n    <slot />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\ndefineProps<{\n  fullWidth?: boolean;\n  fillWidth?: boolean;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/layout/Panel.vue",
    "content": "<template>\n  <div class=\"border-wp-background-400 dark:border-wp-background-100 w-full overflow-hidden rounded-md border\">\n    <component\n      :is=\"collapsable ? 'button' : 'div'\"\n      v-if=\"title\"\n      type=\"button\"\n      class=\"bg-wp-control-neutral-100 text-wp-text-100 flex w-full gap-2 px-4 py-2 font-bold\"\n      :class=\"{\n        'cursor-pointer': collapsable,\n      }\"\n      @click=\"_collapsed = !_collapsed\"\n    >\n      <Icon\n        v-if=\"collapsable\"\n        name=\"chevron-right\"\n        class=\"h-6 min-w-6 transition-transform duration-150\"\n        :class=\"{ 'rotate-90 transform': !collapsed }\"\n      />\n      {{ title }}\n    </component>\n    <div\n      :class=\"{\n        'max-h-auto': !collapsed,\n        'max-h-0': collapsed,\n      }\"\n      class=\"transition-height overflow-hidden duration-150\"\n    >\n      <div class=\"text-wp-text-100 w-full p-4\">\n        <slot />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\n\nimport Icon from '~/components/atomic/Icon.vue';\n\nconst props = defineProps<{\n  title?: string;\n  collapsable?: boolean;\n  collapsedByDefault?: boolean;\n}>();\n\n/**\n * _collapsed is used to store the internal state of the panel, but is\n * ignored if the panel is not collapsable.\n */\nconst _collapsed = ref(props.collapsedByDefault || false);\n\nconst collapsed = computed(() => props.collapsable && _collapsed.value);\n</script>\n"
  },
  {
    "path": "web/src/components/layout/Popup.vue",
    "content": "<template>\n  <!-- overlay -->\n  <div\n    v-if=\"open\"\n    class=\"fixed top-0 right-0 bottom-0 left-0 z-40 bg-gray-900 opacity-80 print:hidden\"\n    @click=\"$emit('close')\"\n  />\n  <!-- overlay end -->\n  <div v-if=\"open\" class=\"fixed inset-0 z-50 m-auto flex max-w-2xl print:hidden\">\n    <div class=\"shadow-all m-auto flex h-auto flex-col p-2\">\n      <slot />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { onKeyStroke } from '@vueuse/core';\nimport { toRef } from 'vue';\n\nconst props = defineProps<{\n  open: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'close'): void;\n}>();\n\nconst open = toRef(props, 'open');\n\nonKeyStroke('Escape', (e) => {\n  e.preventDefault();\n  if (open.value) {\n    emit('close');\n  }\n});\n</script>\n"
  },
  {
    "path": "web/src/components/layout/Settings.vue",
    "content": "<template>\n  <Panel>\n    <div class=\"border-wp-background-400 dark:border-wp-background-100 mb-4 flex flex-col justify-center border-b pb-4\">\n      <div class=\"flex items-center justify-between\">\n        <h1 class=\"text-wp-text-100 flex items-center gap-1 text-xl\">\n          {{ title }}\n          <DocsLink v-if=\"docsUrl\" :topic=\"title\" :url=\"docsUrl\" />\n        </h1>\n        <slot v-if=\"$slots.titleActions\" name=\"titleActions\" />\n      </div>\n\n      <div class=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2\">\n        <p v-if=\"description\" class=\"text-wp-text-alt-100 text-sm\">{{ description }}</p>\n        <div v-if=\"$slots.headerActions\">\n          <slot name=\"headerActions\" />\n        </div>\n      </div>\n      <slot name=\"headerEnd\" />\n    </div>\n\n    <slot />\n  </Panel>\n</template>\n\n<script setup lang=\"ts\">\nimport DocsLink from '~/components/atomic/DocsLink.vue';\nimport Panel from '~/components/layout/Panel.vue';\n\ndefineProps<{\n  title: string;\n  description?: string;\n  docsUrl?: string;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/layout/header/ActivePipelines.vue",
    "content": "<template>\n  <IconButton\n    :title=\"pipelineCount > 0 ? `${$t('pipeline_feed')} (${pipelineCount})` : $t('pipeline_feed')\"\n    class=\"active-pipelines-toggle relative p-1.5! text-current\"\n    @click=\"toggle\"\n  >\n    <div v-if=\"pipelineCount > 0\" class=\"spinner\" />\n    <div class=\"z-0 flex h-full w-full items-center justify-center rounded-md bg-white/15 font-bold dark:bg-black/10\">\n      <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n      {{ pipelineCount > 9 ? '9+' : pipelineCount }}\n    </div>\n  </IconButton>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, toRef } from 'vue';\n\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport usePipelineFeed from '~/compositions/usePipelineFeed';\n\nconst pipelineFeed = usePipelineFeed();\nconst activePipelines = toRef(pipelineFeed, 'activePipelines');\nconst { toggle } = pipelineFeed;\nconst pipelineCount = computed(() => activePipelines.value.length || 0);\n\nonMounted(async () => {\n  await pipelineFeed.load();\n});\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n@keyframes rotate {\n  100% {\n    transform: rotate(1turn);\n  }\n}\n.spinner {\n  @apply absolute inset-1.5 rounded-md;\n  overflow: hidden;\n}\n.spinner::before {\n  @apply bg-wp-primary-200 absolute;\n  content: '';\n  left: -50%;\n  top: -50%;\n  width: 200%;\n  height: 200%;\n  background-repeat: no-repeat;\n  background-size:\n    50% 50%,\n    50% 50%;\n  background-image: linear-gradient(#fff, #fff);\n  animation: rotate 1.5s linear infinite;\n}\n.spinner::after {\n  @apply bg-wp-primary-200 absolute inset-0.5;\n  /*\n  The nested border radius needs to be calculated correctly to look right:\n  https://www.30secondsofcode.org/css/s/nested-border-radius/\n  */\n  border-radius: calc(0.375rem - 0.125rem);\n  content: '';\n}\n\n:root[data-theme='dark'] .spinner::before,\n:root[data-theme='dark'] .spinner::after {\n  @apply bg-wp-primary-300;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/layout/header/Navbar.vue",
    "content": "<template>\n  <nav\n    class=\"text-neutral-content border-wp-background-400 dark:border-wp-background-100 bg-wp-primary-200 text-wp-primary-text-100 dark:bg-wp-primary-300 flex border-b p-4 font-bold\"\n  >\n    <div class=\"flex items-center space-x-2\">\n      <router-link :to=\"{ name: 'home' }\" class=\"-my-2 flex flex-col px-2\">\n        <WoodpeckerLogo class=\"h-8 w-8\" />\n        <span class=\"text-xs\" :title=\"version?.current\">{{ version?.currentShort }}</span>\n      </router-link>\n      <router-link v-if=\"user\" :to=\"{ name: 'repos' }\" class=\"navbar-clickable navbar-link\">\n        <span class=\"flex md:hidden\">{{ $t('repos') }}</span>\n        <span class=\"hidden md:flex\">{{ $t('repositories.title') }}</span>\n      </router-link>\n      <a href=\"https://woodpecker-ci.org/\" target=\"_blank\" class=\"navbar-clickable navbar-link hidden md:flex\">{{\n        $t('docs')\n      }}</a>\n      <a v-if=\"enableSwagger\" :href=\"apiUrl\" target=\"_blank\" class=\"navbar-clickable navbar-link hidden md:flex\">{{\n        $t('api')\n      }}</a>\n    </div>\n    <div class=\"-m-1.5 ml-auto flex items-center space-x-2\">\n      <IconButton\n        v-if=\"user?.admin\"\n        class=\"navbar-icon relative\"\n        :title=\"$t('settings')\"\n        :to=\"{ name: 'admin-settings' }\"\n      >\n        <Icon name=\"settings\" />\n        <div v-if=\"version?.needsUpdate\" class=\"bg-wp-error-100 absolute top-2 right-2 h-3 w-3 rounded-full\" />\n      </IconButton>\n\n      <ActivePipelines v-if=\"user\" class=\"navbar-icon p-1.5!\" />\n      <IconButton v-if=\"user\" :to=\"{ name: 'user' }\" :title=\"$t('user.settings.settings')\" class=\"navbar-icon p-1.5!\">\n        <img v-if=\"user && user.avatar_url\" class=\"rounded-md\" :src=\"`${user.avatar_url}`\" />\n      </IconButton>\n      <Button\n        v-else\n        :text=\"$t('login')\"\n        :to=\"{ name: 'login' }\"\n        class=\"navbar-link !text-wp-primary-text-100 bg-wp-primary-200 dark:bg-wp-primary-300 !border-transparent\"\n        @click=\"saveRedirect\"\n      />\n    </div>\n  </nav>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useRoute } from 'vue-router';\n\nimport WoodpeckerLogo from '~/assets/logo.svg?component';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useConfig from '~/compositions/useConfig';\nimport useUserConfig from '~/compositions/useUserConfig';\nimport { useVersion } from '~/compositions/useVersion';\n\nimport ActivePipelines from './ActivePipelines.vue';\n\nconst version = useVersion();\nconst config = useConfig();\nconst userConfig = useUserConfig();\nconst route = useRoute();\nconst authentication = useAuthentication();\nconst { user } = authentication;\nconst apiUrl = `${config.rootPath ?? ''}/swagger/index.html`;\n\nconst { enableSwagger } = config;\n\nfunction saveRedirect() {\n  userConfig.setUserConfig('redirectUrl', route.fullPath);\n}\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n.navbar-icon {\n  @apply h-11 w-11 rounded-md p-2.5 hover:bg-black/20 dark:hover:bg-white/5;\n}\n\n.navbar-icon :deep(svg) {\n  @apply h-full w-full;\n}\n\n.navbar-link {\n  @apply -my-1 rounded-md px-3 py-2 hover:bg-black/20 dark:hover:bg-white/5;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/layout/popups/DeployPipelinePopup.vue",
    "content": "<template>\n  <Popup :open=\"open\" @close=\"$emit('close')\">\n    <Panel v-if=\"!loading\" class=\"bg-wp-background-100 dark:bg-wp-background-300\">\n      <form @submit.prevent=\"triggerDeployPipeline\">\n        <span class=\"text-wp-text-100 text-xl\">{{\n          $t('repo.deploy_pipeline.title', { pipelineId: pipelineNumber })\n        }}</span>\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.deploy_pipeline.enter_target')\">\n          <TextField :id=\"id\" v-model=\"payload.environment\" required />\n        </InputField>\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.deploy_pipeline.enter_task')\">\n          <TextField :id=\"id\" v-model=\"payload.task\" />\n        </InputField>\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.deploy_pipeline.variables.title')\">\n          <span class=\"text-wp-text-alt-100 mb-2 text-sm\">{{ $t('repo.deploy_pipeline.variables.desc') }}</span>\n          <KeyValueEditor\n            :id=\"id\"\n            v-model=\"payload.variables\"\n            :key-placeholder=\"$t('repo.deploy_pipeline.variables.name')\"\n            :value-placeholder=\"$t('repo.deploy_pipeline.variables.value')\"\n            :delete-title=\"$t('repo.deploy_pipeline.variables.delete')\"\n            @update:is-valid=\"isVariablesValid = $event\"\n          />\n        </InputField>\n        <Button type=\"submit\" :text=\"$t('repo.deploy_pipeline.trigger')\" :disabled=\"!isFormValid\" />\n      </form>\n    </Panel>\n  </Popup>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref, toRef } from 'vue';\nimport { useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport KeyValueEditor from '~/components/form/KeyValueEditor.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport Popup from '~/components/layout/Popup.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\n\nconst props = defineProps<{\n  open: boolean;\n  pipelineNumber: string;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'close'): void;\n}>();\n\nconst apiClient = useApiClient();\nconst repo = requiredInject('repo');\nconst router = useRouter();\n\nconst payload = ref<{\n  id: string;\n  environment: string;\n  task: string;\n  variables: Record<string, string>;\n}>({\n  id: '',\n  environment: '',\n  task: '',\n  variables: {},\n});\n\nconst isVariablesValid = ref(true);\n\nconst isFormValid = computed(() => {\n  return payload.value.environment !== '' && isVariablesValid.value;\n});\n\nconst pipelineOptions = computed(() => ({\n  ...payload.value,\n  variables: payload.value.variables,\n}));\n\nconst loading = ref(true);\nonMounted(async () => {\n  loading.value = false;\n});\n\nconst pipelineNumber = toRef(props, 'pipelineNumber');\nasync function triggerDeployPipeline() {\n  loading.value = true;\n  const newPipeline = await apiClient.deployPipeline(repo.value.id, pipelineNumber.value, pipelineOptions.value);\n\n  emit('close');\n\n  await router.push({\n    name: 'repo-pipeline',\n    params: {\n      pipelineId: newPipeline.number,\n    },\n  });\n\n  loading.value = false;\n}\n</script>\n"
  },
  {
    "path": "web/src/components/layout/scaffold/Header.vue",
    "content": "<template>\n  <header class=\"text-wp-text-100\" :class=\"{ 'md:px-4': fullWidth }\">\n    <Container :full-width=\"fullWidth\" class=\"relative py-0!\">\n      <div class=\"border-wp-background-400 dark:border-wp-background-100 border-b\">\n        <div class=\"flex w-full flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between md:gap-10\">\n          <div\n            class=\"flex min-h-10 content-start items-center\"\n            :class=\"{\n              'md:flex-1': searchBoxPresent,\n            }\"\n          >\n            <IconButton\n              v-if=\"goBack\"\n              icon=\"back\"\n              :title=\"$t('back')\"\n              class=\"md:display-unset mr-2 hidden h-8 w-8 shrink-0 md:justify-between\"\n              @click=\"goBack\"\n            />\n            <h1 class=\"text-wp-text-100 flex min-w-0 items-center gap-x-2 text-xl\">\n              <slot name=\"title\" />\n            </h1>\n          </div>\n          <TextField\n            v-if=\"searchBoxPresent\"\n            class=\"order-3 w-full grow md:order-none md:w-auto\"\n            :aria-label=\"$t('search')\"\n            :placeholder=\"$t('search')\"\n            :model-value=\"search\"\n            @update:model-value=\"(value: string) => $emit('update:search', value)\"\n          />\n          <div\n            v-if=\"$slots.headerActions\"\n            class=\"flex min-w-0 items-center gap-x-2 md:justify-end\"\n            :class=\"{\n              'md:flex-1': searchBoxPresent,\n            }\"\n          >\n            <slot name=\"headerActions\" />\n          </div>\n        </div>\n\n        <div v-if=\"enableTabs\" class=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:py-0\">\n          <Tabs class=\"order-2 md:order-none\" />\n          <div v-if=\"$slots.headerActions\" class=\"flex flex-wrap content-start md:justify-end\">\n            <slot name=\"tabActions\" />\n          </div>\n        </div>\n      </div>\n    </Container>\n  </header>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue';\n\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Container from '~/components/layout/Container.vue';\n\nimport Tabs from './Tabs.vue';\n\nconst props = defineProps<{\n  goBack?: () => void;\n  enableTabs?: boolean;\n  search?: string;\n  fullWidth?: boolean;\n}>();\n\ndefineEmits<{\n  (event: 'update:search', query: string): void;\n}>();\n\nconst searchBoxPresent = computed(() => props.search !== undefined);\n</script>\n"
  },
  {
    "path": "web/src/components/layout/scaffold/Scaffold.vue",
    "content": "<template>\n  <Header\n    :go-back=\"goBack\"\n    :enable-tabs=\"enableTabs\"\n    :search=\"search\"\n    :full-width=\"fullWidthHeader\"\n    @update:search=\"(value) => $emit('update:search', value)\"\n  >\n    <template #title><slot name=\"title\" /></template>\n    <template v-if=\"$slots.headerActions\" #headerActions><slot name=\"headerActions\" /></template>\n    <template v-if=\"$slots.tabActions\" #tabActions><slot name=\"tabActions\" /></template>\n  </Header>\n\n  <slot v-if=\"fluidContent\" />\n  <Container v-else>\n    <slot />\n  </Container>\n</template>\n\n<script setup lang=\"ts\">\nimport Container from '~/components/layout/Container.vue';\nimport { useTabsProvider } from '~/compositions/useTabs';\n\nimport Header from './Header.vue';\n\nconst props = defineProps<{\n  // Header\n  goBack?: () => void;\n  search?: string;\n  fullWidthHeader?: boolean;\n\n  // Tabs\n  enableTabs?: boolean;\n\n  // Content\n  fluidContent?: boolean;\n}>();\n\ndefineEmits<{\n  (event: 'update:search', value: string): void;\n}>();\n\nif (props.enableTabs) {\n  useTabsProvider();\n}\n</script>\n"
  },
  {
    "path": "web/src/components/layout/scaffold/Tab.vue",
    "content": "<template><span /></template>\n\n<script setup lang=\"ts\">\nimport { onMounted } from 'vue';\nimport type { RouteLocationRaw } from 'vue-router';\n\nimport type { IconNames } from '~/components/atomic/Icon.vue';\nimport { useTabsClient } from '~/compositions/useTabs';\n\nconst props = defineProps<{\n  to: RouteLocationRaw;\n  title: string;\n  count?: number;\n  icon?: IconNames;\n  iconClass?: string;\n  matchChildren?: boolean;\n}>();\n\nconst { tabs } = useTabsClient();\n\n// TODO: find a better way to compare routes like\n// https://github.com/vuejs/router/blob/0eaaeb9697acd40ad524d913d0348748e9797acb/packages/router/src/utils/index.ts#L17\nfunction isSameRoute(a: RouteLocationRaw, b: RouteLocationRaw): boolean {\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nonMounted(() => {\n  // don't add tab if tab id is already present\n  if (tabs.value.some(({ to }) => isSameRoute(to, props.to))) {\n    return;\n  }\n\n  tabs.value.push({\n    to: props.to,\n    title: props.title,\n    count: props.count,\n    icon: props.icon,\n    iconClass: props.iconClass,\n    matchChildren: props.matchChildren,\n  });\n});\n</script>\n"
  },
  {
    "path": "web/src/components/layout/scaffold/Tabs.vue",
    "content": "<template>\n  <!-- Main tabs container -->\n  <div ref=\"tabsRef\" class=\"flex min-w-0 flex-auto gap-4\">\n    <router-link\n      v-for=\"tab in visibleTabs\"\n      :key=\"tab.title\"\n      :to=\"tab.to\"\n      class=\"text-wp-text-100 flex cursor-pointer items-center border-b-2 border-transparent py-1 whitespace-nowrap\"\n      :active-class=\"tab.matchChildren ? 'border-wp-text-100!' : ''\"\n      :exact-active-class=\"tab.matchChildren ? '' : 'border-wp-text-100!'\"\n    >\n      <span\n        class=\"hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 flex w-full min-w-20 flex-row items-center justify-center gap-2 rounded-md px-2 py-1\"\n      >\n        <Icon v-if=\"tab.icon\" :name=\"tab.icon\" :class=\"tab.iconClass\" class=\"shrink-0\" />\n        <span>{{ tab.title }}</span>\n        <CountBadge v-if=\"tab.count\" :value=\"tab.count\" />\n      </span>\n    </router-link>\n\n    <!-- Overflow dropdown -->\n    <div v-if=\"hiddenTabs.length\" class=\"relative border-b-2 border-transparent py-1\">\n      <IconButton icon=\"dots\" class=\"tabs-more-button h-8 w-8\" @click=\"toggleDropdown\" />\n\n      <div\n        v-if=\"isDropdownOpen\"\n        class=\"tabs-dropdown border-wp-background-400 dark:border-wp-background-100 bg-wp-background-100 dark:bg-wp-background-200 dark:shadow-wp-background-500 absolute z-20 mt-1 rounded-md border shadow-lg\"\n        :class=\"[visibleTabs.length === 0 ? 'left-0' : 'right-0']\"\n      >\n        <router-link\n          v-for=\"tab in hiddenTabs\"\n          :key=\"tab.title\"\n          :to=\"tab.to\"\n          class=\"block w-full p-1 text-left whitespace-nowrap\"\n          @click=\"isDropdownOpen = false\"\n        >\n          <span\n            class=\"hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 flex w-full min-w-20 flex-row gap-2 rounded-md px-2 py-1\"\n          >\n            <Icon v-if=\"tab.icon\" :name=\"tab.icon\" :class=\"tab.iconClass\" class=\"shrink-0\" />\n            <span>{{ tab.title }}</span>\n          </span>\n        </router-link>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';\n\nimport CountBadge from '~/components/atomic/CountBadge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport { useTabsClient } from '~/compositions/useTabs';\n\nconst { tabs } = useTabsClient();\nconst tabsRef = ref<HTMLElement | null>(null);\nconst isDropdownOpen = ref(false);\nconst visibleCount = ref(tabs.value.length);\n\nconst visibleTabs = computed(() => tabs.value.slice(0, visibleCount.value));\nconst hiddenTabs = computed(() => tabs.value.slice(visibleCount.value));\n\nconst toggleDropdown = () => {\n  isDropdownOpen.value = !isDropdownOpen.value;\n};\n\nconst closeDropdown = (event: MouseEvent) => {\n  const dropdown = tabsRef.value?.querySelector('.tabs-dropdown');\n  const moreButton = tabsRef.value?.querySelector('.tabs-more-button');\n  const target = event.target as HTMLElement;\n\n  if (moreButton?.contains(target)) {\n    return;\n  }\n\n  if (dropdown && !dropdown.contains(target)) {\n    isDropdownOpen.value = false;\n  }\n};\n\nwatch(isDropdownOpen, (isOpen) => {\n  if (isOpen) {\n    window.addEventListener('click', closeDropdown);\n  } else {\n    window.removeEventListener('click', closeDropdown);\n  }\n});\n\nconst updateVisibleItems = () => {\n  visibleCount.value = tabs.value.length;\n\n  nextTick(() => {\n    const availableWidth = tabsRef.value!.clientWidth || 0;\n    const moreButtonWidth = 64; // This need to match 2x the width of the IconButton (w-8)\n    const gapWidth = 16; // This need to match the gap between the tabs (gap-4)\n    let totalWidth = 0;\n\n    const items = Array.from(tabsRef.value!.children);\n\n    for (let i = 0; i < items.length; i++) {\n      const itemWidth = items[i].getBoundingClientRect().width;\n      totalWidth += itemWidth;\n      if (i > 0) totalWidth += gapWidth;\n\n      if (totalWidth > availableWidth - (moreButtonWidth + gapWidth)) {\n        visibleCount.value = i;\n        return;\n      }\n    }\n\n    visibleCount.value = tabs.value.length;\n  });\n};\n\nonMounted(() => {\n  const resizeObserver = new ResizeObserver(() => {\n    requestAnimationFrame(updateVisibleItems);\n  });\n\n  if (tabsRef.value!) {\n    resizeObserver.observe(tabsRef.value);\n  }\n\n  window.addEventListener('resize', updateVisibleItems);\n\n  nextTick(updateVisibleItems);\n\n  onUnmounted(() => {\n    resizeObserver.disconnect();\n    window.removeEventListener('resize', updateVisibleItems);\n    window.removeEventListener('click', closeDropdown);\n  });\n});\n</script>\n"
  },
  {
    "path": "web/src/components/pipeline-feed/PipelineFeedItem.vue",
    "content": "<template>\n  <div v-if=\"pipeline\" class=\"text-wp-text-100 flex w-full\">\n    <PipelineStatusIcon :status=\"pipeline.status\" class=\"flex items-center\" />\n    <div class=\"ml-4 flex min-w-0 flex-col\">\n      <router-link\n        :to=\"{\n          name: 'repo',\n          params: { repoId: pipeline.repo_id },\n        }\"\n        class=\"underline\"\n      >\n        <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n        {{ repo?.owner }} / {{ repo?.name }}\n      </router-link>\n      <RenderMarkdown\n        class=\"overflow-hidden text-ellipsis whitespace-nowrap\"\n        :title=\"message\"\n        :content=\"shortMessage\"\n        inline\n      />\n      <div class=\"mt-2 flex flex-col\">\n        <div class=\"flex items-center space-x-2\" :title=\"created\">\n          <Icon name=\"since\" />\n          <span>{{ since }}</span>\n        </div>\n        <div class=\"flex items-center space-x-2\">\n          <Icon name=\"duration\" />\n          <span>{{ duration }}</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport usePipeline from '~/compositions/usePipeline';\nimport type { PipelineFeed } from '~/lib/api/types';\nimport { useRepoStore } from '~/store/repos';\n\nconst props = defineProps<{\n  pipeline: PipelineFeed;\n}>();\n\nconst repoStore = useRepoStore();\n\nconst pipeline = toRef(props, 'pipeline');\nconst repo = repoStore.getRepo(computed(() => pipeline.value.repo_id));\n\nconst { since, duration, shortMessage, message, created } = usePipeline(pipeline);\n</script>\n"
  },
  {
    "path": "web/src/components/pipeline-feed/PipelineFeedSidebar.vue",
    "content": "<template>\n  <aside\n    v-if=\"isOpen\"\n    ref=\"target\"\n    class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-100 dark:bg-wp-background-300 z-50 flex flex-col items-center overflow-y-auto\"\n    :aria-label=\"$t('pipeline_feed')\"\n  >\n    <router-link\n      v-for=\"pipeline in sortedPipelines\"\n      :key=\"pipeline.id\"\n      :to=\"{\n        name: 'repo-pipeline',\n        params: { repoId: pipeline.repo_id, pipelineId: pipeline.number },\n      }\"\n      class=\"border-wp-background-400 dark:border-wp-background-100 hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 flex w-full border-b px-2 py-4\"\n    >\n      <PipelineFeedItem :pipeline=\"pipeline\" />\n    </router-link>\n\n    <span v-if=\"sortedPipelines.length === 0\" class=\"text-wp-text-100 m-4\">{{ $t('repo.pipeline.no_pipelines') }}</span>\n  </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { onClickOutside } from '@vueuse/core';\nimport { ref } from 'vue';\n\nimport PipelineFeedItem from '~/components/pipeline-feed/PipelineFeedItem.vue';\nimport usePipelineFeed from '~/compositions/usePipelineFeed';\n\nconst pipelineFeed = usePipelineFeed();\nconst { close, isOpen, sortedPipelines } = pipelineFeed;\n\nconst target = ref<HTMLElement>();\nonClickOutside(target, close, { ignore: ['.active-pipelines-toggle'] });\n</script>\n"
  },
  {
    "path": "web/src/components/registry/RegistryEdit.vue",
    "content": "<template>\n  <div v-if=\"innerValue\" class=\"space-y-4\">\n    <form @submit.prevent=\"save\">\n      <InputField v-slot=\"{ id }\" :label=\"$t('registries.address.address')\">\n        <!-- TODO: check input field Address is a valid address -->\n        <TextField\n          :id=\"id\"\n          v-model=\"innerValue.address\"\n          :placeholder=\"$t('registries.address.desc')\"\n          required\n          :disabled=\"isEditing || isReadOnly\"\n        />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('username')\">\n        <TextField\n          :id=\"id\"\n          v-model=\"innerValue.username\"\n          :placeholder=\"$t('username')\"\n          required\n          :disabled=\"isReadOnly\"\n        />\n      </InputField>\n\n      <InputField v-if=\"!isReadOnly\" v-slot=\"{ id }\" :label=\"$t('password')\">\n        <TextField :id=\"id\" v-model=\"innerValue.password\" :placeholder=\"$t('password')\" :required=\"!isEditing\" />\n      </InputField>\n\n      <div v-if=\"!isReadOnly\" class=\"flex gap-2\">\n        <Button type=\"button\" color=\"gray\" :text=\"$t('cancel')\" @click=\"$emit('cancel')\" />\n        <Button\n          type=\"submit\"\n          color=\"green\"\n          :is-loading=\"isSaving\"\n          :text=\"isEditing ? $t('registries.save') : $t('registries.add')\"\n        />\n      </div>\n    </form>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport Button from '~/components/atomic/Button.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport type { Registry } from '~/lib/api/types';\n\nconst props = defineProps<{\n  modelValue: Partial<Registry>;\n  isSaving: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: Partial<Registry> | undefined): void;\n  (event: 'save', value: Partial<Registry>): void;\n  (event: 'cancel'): void;\n}>();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\nconst isEditing = computed(() => !!innerValue.value?.id);\nconst isReadOnly = computed(() => !!innerValue.value?.readonly);\n\nfunction save() {\n  if (!innerValue.value) {\n    return;\n  }\n\n  emit('save', innerValue.value);\n}\n</script>\n"
  },
  {
    "path": "web/src/components/registry/RegistryList.vue",
    "content": "<template>\n  <div class=\"text-wp-text-100 space-y-4\">\n    <ListItem\n      v-for=\"registry in registries\"\n      :key=\"registry.id\"\n      class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center\"\n    >\n      <span>{{ registry.address }}</span>\n      <Badge\n        v-if=\"registry.edit === false\"\n        class=\"ml-2\"\n        :value=\"registry.org_id === 0 ? $t('global_level_registry') : $t('org_level_registry')\"\n      />\n      <div v-else class=\"ml-auto flex items-center gap-2\">\n        <IconButton\n          :icon=\"registry.readonly ? 'chevron-right' : 'edit'\"\n          class=\"h-8 w-8\"\n          :title=\"registry.readonly ? $t('registries.view') : $t('registries.edit')\"\n          @click=\"editRegistry(registry)\"\n        />\n        <IconButton\n          v-if=\"!registry.readonly\"\n          icon=\"trash\"\n          class=\"hover:text-wp-error-100 h-8 w-8\"\n          :is-loading=\"isDeleting\"\n          :title=\"$t('registries.delete')\"\n          @click=\"deleteRegistry(registry)\"\n        />\n      </div>\n    </ListItem>\n\n    <div v-if=\"loading\" class=\"flex justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n    <div v-else-if=\"registries?.length === 0\" class=\"ml-2\">{{ $t('registries.none') }}</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport type { Registry } from '~/lib/api/types';\n\nconst props = defineProps<{\n  modelValue: (Registry & { edit?: boolean })[];\n  isDeleting: boolean;\n  loading: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'edit', registry: Registry): void;\n  (event: 'delete', registry: Registry): void;\n}>();\n\nconst i18n = useI18n();\n\nconst registries = toRef(props, 'modelValue');\n\nfunction editRegistry(registry: Registry) {\n  emit('edit', registry);\n}\n\nfunction deleteRegistry(registry: Registry) {\n  // TODO: use proper dialog\n  // eslint-disable-next-line no-alert\n  if (!confirm(i18n.t('registries.delete_confirm'))) {\n    return;\n  }\n  emit('delete', registry);\n}\n</script>\n"
  },
  {
    "path": "web/src/components/repo/RepoItem.vue",
    "content": "<template>\n  <router-link\n    v-if=\"repo\"\n    :to=\"{ name: 'repo', params: { repoId: repo.id } }\"\n    class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-200 dark:bg-wp-background-200 hover:bg-wp-control-neutral-100 dark:hover:bg-wp-control-neutral-200 flex cursor-pointer flex-col overflow-hidden rounded-md border p-4\"\n  >\n    <div class=\"grid grid-cols-[auto_1fr] items-center gap-y-4\">\n      <div class=\"text-wp-text-100 text-lg\">{{ `${repo.owner} / ${repo.name}` }}</div>\n      <div class=\"text-wp-text-100 ml-auto\">\n        <div\n          v-if=\"repo.visibility === RepoVisibility.Private\"\n          :title=\"`${$t('repo.visibility.visibility')}: ${$t(`repo.visibility.private.private`)}`\"\n        >\n          <Icon name=\"visibility-private\" />\n        </div>\n        <div\n          v-else-if=\"repo.visibility === RepoVisibility.Internal\"\n          :title=\"`${$t('repo.visibility.visibility')}: ${$t(`repo.visibility.internal.internal`)}`\"\n        >\n          <Icon name=\"visibility-internal\" />\n        </div>\n      </div>\n\n      <div class=\"text-wp-text-100 col-span-2 flex w-full gap-x-4\">\n        <template v-if=\"lastPipeline\">\n          <div class=\"flex min-w-0 flex-1 items-center gap-x-1\">\n            <PipelineStatusIcon v-if=\"lastPipeline\" :status=\"lastPipeline.status\" />\n            <RenderMarkdown\n              class=\"overflow-hidden pl-1 text-ellipsis whitespace-nowrap\"\n              :title=\"message\"\n              :content=\"shortMessage\"\n              inline\n            />\n          </div>\n\n          <div class=\"ml-auto flex shrink-0 items-center gap-x-1\">\n            <Icon name=\"since\" />\n            <span>{{ since }}</span>\n          </div>\n        </template>\n\n        <div v-else class=\"flex gap-x-2\">\n          <span>{{ $t('repo.pipeline.no_pipelines') }}</span>\n        </div>\n      </div>\n    </div>\n  </router-link>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport usePipeline from '~/compositions/usePipeline';\nimport type { Repo } from '~/lib/api/types';\nimport { RepoVisibility } from '~/lib/api/types';\n\nconst props = defineProps<{\n  repo: Repo;\n}>();\n\nconst lastPipeline = computed(() => props.repo.last_pipeline);\nconst { since, shortMessage, message } = usePipeline(lastPipeline);\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineItem.vue",
    "content": "<template>\n  <ListItem v-if=\"pipeline\" class=\"w-full p-0!\">\n    <div class=\"flex w-11 items-center\">\n      <div\n        class=\"h-full w-3\"\n        :class=\"{\n          'bg-wp-state-warn-100': pipeline.status === 'pending',\n          'bg-wp-error-100 dark:bg-wp-error-200': pipelineStatusColors[pipeline.status] === 'red',\n          'bg-wp-state-neutral-100': pipelineStatusColors[pipeline.status] === 'gray',\n          'bg-wp-state-ok-100': pipelineStatusColors[pipeline.status] === 'green',\n          'bg-wp-state-info-100': pipelineStatusColors[pipeline.status] === 'blue',\n        }\"\n      />\n      <div class=\"flex h-full w-6 flex-wrap items-center justify-between\">\n        <PipelineRunningIcon v-if=\"pipeline.status === 'started' || pipeline.status === 'running'\" />\n        <PipelineStatusIcon v-else class=\"mx-2 md:mx-3\" :status=\"pipeline.status\" />\n      </div>\n    </div>\n\n    <div class=\"flex min-w-0 grow flex-wrap px-4 py-2 md:flex-nowrap\">\n      <div class=\"hidden shrink-0 items-center md:flex\">\n        <Icon v-if=\"pipeline.event === 'cron'\" name=\"stopwatch\" class=\"text-wp-text-100\" />\n        <img v-else class=\"w-6 rounded-md\" :src=\"pipeline.author_avatar\" />\n      </div>\n\n      <div class=\"flex w-full min-w-0 items-center md:mx-4 md:w-auto\">\n        <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n        <span class=\"md:display-unset text-wp-text-alt-100 hidden\">#{{ pipeline.number }}</span>\n        <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n        <span class=\"md:display-unset text-wp-text-alt-100 mx-2 hidden\">-</span>\n        <RenderMarkdown\n          class=\"text-wp-text-100 overflow-hidden text-ellipsis whitespace-nowrap underline md:no-underline\"\n          :title=\"message\"\n          :content=\"shortMessage\"\n          inline\n        />\n      </div>\n\n      <div\n        class=\"text-wp-text-100 grid w-full shrink-0 grid-flow-col grid-cols-2 grid-rows-2 gap-x-4 gap-y-2 py-2 md:ml-auto md:w-96\"\n      >\n        <div class=\"flex min-w-0 items-center space-x-2\">\n          <span :title=\"pipelineEventTitle\">\n            <Icon v-if=\"pipeline.event === 'pull_request'\" name=\"pull-request\" />\n            <Icon v-else-if=\"pipeline.event === 'pull_request_closed'\" name=\"pull-request-closed\" />\n            <Icon v-else-if=\"pipeline.event === 'pull_request_metadata'\" name=\"pull-request-metadata\" />\n            <Icon v-else-if=\"pipeline.event === 'deployment'\" name=\"deployment\" />\n            <Icon v-else-if=\"pipeline.event === 'tag' || pipeline.event === 'release'\" name=\"tag\" />\n            <Icon v-else-if=\"pipeline.event === 'cron'\" name=\"branch\" />\n            <Icon v-else-if=\"pipeline.event === 'manual'\" name=\"manual-pipeline\" />\n            <Icon v-else name=\"branch\" />\n          </span>\n          <span class=\"truncate\">{{ prettyRef }}</span>\n        </div>\n\n        <div class=\"flex min-w-0 items-center space-x-2\">\n          <Icon name=\"commit\" />\n          <span class=\"truncate\">{{ pipeline.commit.slice(0, 10) }}</span>\n        </div>\n\n        <div\n          class=\"flex min-w-0 items-center space-x-2\"\n          :title=\"\n            durationElapsed > 0 ? $t('repo.pipeline.duration', { duration: durationAsNumber(durationElapsed) }) : ''\n          \"\n        >\n          <Icon name=\"duration\" />\n          <span class=\"truncate\">{{ duration }}</span>\n        </div>\n\n        <div class=\"flex min-w-0 items-center space-x-2\" :title=\"$t('repo.pipeline.created', { created })\">\n          <Icon name=\"since\" />\n          <span class=\"truncate\">{{ since }}</span>\n        </div>\n      </div>\n    </div>\n  </ListItem>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';\nimport { pipelineStatusColors } from '~/components/repo/pipeline/pipeline-status';\nimport PipelineRunningIcon from '~/components/repo/pipeline/PipelineRunningIcon.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport { useDate } from '~/compositions/useDate';\nimport usePipeline from '~/compositions/usePipeline';\nimport type { Pipeline } from '~/lib/api/types';\n\nconst props = defineProps<{\n  pipeline: Pipeline;\n}>();\n\nconst { t } = useI18n();\nconst { durationAsNumber } = useDate();\n\nconst pipeline = toRef(props, 'pipeline');\nconst { since, duration, durationElapsed, message, shortMessage, prettyRef, created } = usePipeline(pipeline);\n\nconst pipelineEventTitle = computed(() => {\n  switch (pipeline.value.event) {\n    case 'pull_request':\n      return t('repo.pipeline.event.pr');\n    case 'pull_request_closed':\n      return t('repo.pipeline.event.pr_closed');\n    case 'pull_request_metadata':\n      return t('repo.pipeline.event.pr_metadata');\n    case 'deployment':\n      return t('repo.pipeline.event.deploy');\n    case 'tag':\n      return t('repo.pipeline.event.tag');\n    case 'release':\n      return t('repo.pipeline.event.release');\n    case 'cron':\n      return t('repo.pipeline.event.cron');\n    case 'manual':\n      return t('repo.pipeline.event.manual');\n    default:\n      return t('repo.pipeline.event.push');\n  }\n});\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineList.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <PipelineItem\n      v-for=\"pipeline in pipelines\"\n      :key=\"pipeline.id\"\n      :to=\"{\n        name: 'repo-pipeline',\n        params: { pipelineId: pipeline.number },\n      }\"\n      :pipeline=\"pipeline\"\n    />\n    <div v-if=\"loading\" class=\"flex justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n    <Panel v-else-if=\"pipelines?.length === 0\">\n      <span class=\"text-wp-text-100\">{{ $t('repo.pipeline.no_pipelines') }}</span>\n    </Panel>\n    <div v-if=\"hasMore && !loading\" class=\"flex justify-center\">\n      <Button :text=\"$t('repo.pipeline.load_more')\" @click=\"$emit('loadMore')\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport PipelineItem from '~/components/repo/pipeline/PipelineItem.vue';\nimport type { Pipeline } from '~/lib/api/types';\n\ndefineProps<{\n  pipelines: Pipeline[] | undefined;\n  loading?: boolean;\n  hasMore?: boolean;\n}>();\n\ndefineEmits<{\n  (event: 'loadMore'): void;\n}>();\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineLog.vue",
    "content": "<template>\n  <div\n    v-if=\"pipeline\"\n    class=\"fixed top-0 left-0 flex h-full w-full flex-col pt-10 md:pt-0\"\n    :class=\"{\n      'md:absolute': !fullscreen,\n    }\"\n  >\n    <div\n      class=\"code-box-log flex grow flex-col overflow-hidden p-0! md:mt-0\"\n      :class=\"{\n        'md:rounded-md!': !fullscreen,\n      }\"\n      @mouseover=\"showActions = true\"\n      @mouseleave=\"showActions = false\"\n    >\n      <div\n        class=\"bg-wp-code-100 fixed top-0 left-0 flex w-full flex-row items-center px-4 py-2 md:relative md:top-auto md:left-auto\"\n      >\n        <span class=\"text-wp-code-text-alt-100 text-base font-bold\">\n          <span class=\"md:display-unset hidden\">{{ $t('repo.pipeline.log_title') }}</span>\n          <span class=\"md:hidden\">{{ step?.name }}</span>\n        </span>\n\n        <div class=\"ml-auto flex flex-row items-center gap-x-2\">\n          <IconButton\n            :title=\"fullscreen ? $t('exit_fullscreen') : $t('fullscreen')\"\n            class=\"hidden! hover:bg-white/10! md:flex!\"\n            :icon=\"fullscreen ? 'exit-fullscreen' : 'fullscreen'\"\n            @click=\"fullscreen = !fullscreen\"\n          />\n          <IconButton\n            v-if=\"step?.finished !== undefined && hasLogs\"\n            :is-loading=\"downloadInProgress\"\n            :title=\"$t('repo.pipeline.actions.log_download')\"\n            class=\"hover:bg-white/10!\"\n            icon=\"download\"\n            @click=\"download\"\n          />\n          <IconButton\n            v-if=\"step?.finished !== undefined && hasLogs && hasPushPermission\"\n            :title=\"$t('repo.pipeline.actions.log_delete')\"\n            class=\"hover:bg-white/10!\"\n            icon=\"trash\"\n            @click=\"deleteLogs\"\n          />\n          <IconButton\n            v-if=\"step?.finished === undefined\"\n            :title=\"\n              autoScroll ? $t('repo.pipeline.actions.log_auto_scroll_off') : $t('repo.pipeline.actions.log_auto_scroll')\n            \"\n            class=\"hover:bg-white/10!\"\n            :icon=\"autoScroll ? 'auto-scroll' : 'auto-scroll-off'\"\n            @click=\"autoScroll = !autoScroll\"\n          />\n          <template v-if=\"hasGroupedLogs\">\n            <div class=\"border-wp-background-400 dark:border-wp-background-100 mx-1 h-5 border-l\" />\n            <IconButton\n              :title=\"$t('repo.pipeline.actions.expand_all')\"\n              class=\"hover:bg-white/10!\"\n              icon=\"expand-all\"\n              @click=\"expandAll\"\n            />\n            <IconButton\n              :title=\"$t('repo.pipeline.actions.collapse_all')\"\n              class=\"hover:bg-white/10!\"\n              icon=\"collapse-all\"\n              @click=\"collapseAll\"\n            />\n            <IconButton class=\"hover:bg-white/10! md:hidden!\" icon=\"close\" @click=\"$emit('update:step-id', null)\" />\n          </template>\n        </div>\n      </div>\n\n      <div\n        v-show=\"hasLogs && loadedLogs && (log?.length || 0) > 0\"\n        ref=\"consoleElement\"\n        class=\"grid w-full max-w-full grow scroll-pt-8 auto-rows-min grid-cols-[min-content_minmax(0,1fr)_min-content] overflow-x-hidden overflow-y-auto p-4 text-xs md:text-sm\"\n      >\n        <div v-for=\"group in groupedLogs\" :key=\"group.id\" class=\"contents\">\n          <div\n            v-if=\"group.isActualCommand\"\n            class=\"sticky -top-4 z-10 col-span-3 my-1 flex cursor-pointer items-center rounded-sm px-2 py-1 font-mono text-sm shadow-xs\"\n            :class=\"[group.command && isSelected(group.command) ? 'bg-blue-900' : 'bg-wp-code-100']\"\n            @click=\"toggleGroup(group.id)\"\n          >\n            <Icon\n              name=\"chevron-right\"\n              class=\"mr-2 transition-transform\"\n              :class=\"{\n                'rotate-90': !collapsedCommands.has(group.id),\n                invisible: group.lines.length === 0,\n              }\"\n            />\n            <!-- eslint-disable vue/no-v-html -->\n            <span\n              v-if=\"group.command\"\n              :id=\"`L${group.command?.number}`\"\n              class=\"flex-1 truncate\"\n              v-html=\"group.command.text?.substring(2)\"\n            />\n          </div>\n\n          <template v-if=\"!collapsedCommands.has(group.id)\">\n            <div v-for=\"line in group.lines\" :key=\"line.index\" class=\"contents font-mono\">\n              <a\n                :id=\"`L${line.number}`\"\n                :href=\"`#L${line.number}`\"\n                class=\"text-wp-code-text-alt-100 pr-6 pl-2 text-right whitespace-nowrap select-none\"\n                :class=\"{\n                  'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',\n                  'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',\n                  'bg-blue-600/30': isSelected(line),\n                  underline: isSelected(line),\n                }\"\n              >\n                {{ line.number }}\n              </a>\n              <!-- eslint-disable vue/no-v-html -->\n              <span\n                class=\"wrap-break-words align-top whitespace-pre-wrap\"\n                :class=\"{\n                  'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',\n                  'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',\n                  'bg-blue-600/30': isSelected(line),\n                }\"\n                v-html=\"line.text\"\n              />\n              <!-- eslint-enable vue/no-v-html -->\n              <span\n                class=\"text-wp-code-text-alt-100 pr-1 text-right whitespace-nowrap select-none\"\n                :class=\"{\n                  'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',\n                  'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',\n                  'bg-blue-600/30': isSelected(line),\n                }\"\n              >\n                {{ formatTime(line.time) }}\n              </span>\n            </div>\n          </template>\n        </div>\n      </div>\n\n      <div class=\"text-wp-text-alt-100 m-auto text-xl\">\n        <span v-if=\"step?.state === 'canceled'\">{{ $t('repo.pipeline.actions.canceled') }}</span>\n        <span v-else-if=\"step?.state === 'skipped'\">{{ $t('repo.pipeline.actions.skipped') }}</span>\n        <span v-else-if=\"!step?.started\">{{ $t('repo.pipeline.step_not_started') }}</span>\n        <div v-else-if=\"!loadedLogs\">{{ $t('repo.pipeline.loading') }}</div>\n        <div v-else-if=\"log?.length === 0\">{{ $t('repo.pipeline.no_logs') }}</div>\n      </div>\n\n      <div\n        v-if=\"step?.finished !== undefined\"\n        class=\"text-md bg-wp-code-100 text-wp-code-text-alt-100 flex w-full items-center p-4 font-bold\"\n      >\n        <PipelineStatusIcon :status=\"step.state\" class=\"h-4! w-4!\" />\n        <span v-if=\"step?.error\" class=\"px-2\">{{ step.error }}</span>\n        <span v-else class=\"px-2\">{{ $t('repo.pipeline.exit_code', { exitCode: step.exit_code }) }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport '~/style/console.css';\n\nimport { useStorage } from '@vueuse/core';\nimport { AnsiUp } from 'ansi_up';\nimport { decode } from 'js-base64';\nimport { computed, nextTick, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute } from 'vue-router';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useConfig from '~/compositions/useConfig';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport type { Pipeline, PipelineConfig, PipelineStep, PipelineWorkflow } from '~/lib/api/types';\nimport { debounce } from '~/lib/utils';\n\ninterface LogLine {\n  index: number;\n  number: number;\n  text?: string;\n  rawText?: string;\n  time?: number;\n  type: 'error' | 'warning' | null;\n}\n\ninterface LogBlock {\n  command: LogLine | null;\n  lines: LogLine[];\n  id: number;\n  isActualCommand: boolean;\n}\n\nconst props = defineProps<{\n  pipeline: Pipeline;\n  stepId: number;\n}>();\n\ndefineEmits<{\n  (event: 'update:step-id', stepId: number | null): true;\n}>();\n\nconst notifications = useNotifications();\nconst i18n = useI18n();\nconst pipeline = toRef(props, 'pipeline');\nconst stepId = toRef(props, 'stepId');\nconst repo = requiredInject('repo');\nconst repoPermissions = requiredInject('repo-permissions');\nconst pipelineConfigs = requiredInject('pipeline-configs');\nconst apiClient = useApiClient();\nconst route = useRoute();\n\nconst loadedStepSlug = ref<string>();\nconst stepSlug = computed(() => `${repo?.value.owner} - ${repo?.value.name} - ${pipeline.value.id} - ${stepId.value}`);\nconst step = computed(() => pipeline.value && findStep(pipeline.value.workflows || [], stepId.value));\nconst stream = ref<EventSource>();\nconst log = ref<LogLine[]>();\nconst consoleElement = ref<Element>();\nconst fullscreen = ref(false);\n\nconst loadedLogs = computed(() => !!log.value);\nconst hasLogs = computed(\n  () =>\n    // we do not have logs for skipped/canceled steps\n    repo?.value && pipeline.value && step.value && step.value.state !== 'skipped' && step.value.state !== 'canceled',\n);\nconst autoScroll = useStorage('woodpecker:log-auto-scroll', true);\nconst showActions = ref(false);\nconst downloadInProgress = ref(false);\nconst ansiUp = ref(new AnsiUp());\nansiUp.value.use_classes = true;\nconst logBuffer = ref<LogLine[]>([]);\n\nconst config = useConfig();\n\nconst maxLineCount = config.maxPipelineLogLineCount; // TODO(2653): implement lazy-loading support\nconst hasPushPermission = computed(() => repoPermissions?.value?.push);\n\nconst collapsedCommands = ref(new Set<number>());\n\nconst commandRegex = /^\\s*-\\s(.+)$/gm;\nconst specialCharsRegex = /[.*+?^${}()|[\\]\\\\]/g;\nconst matrixVariableRegex = /\\\\\\$(\\\\\\{\\w+\\\\\\})/g;\n\nconst knownCommandMatchers = computed(() => {\n  if (!pipelineConfigs.value) return [];\n  const patterns: RegExp[] = [];\n  pipelineConfigs.value.forEach((config: PipelineConfig) => {\n    const decoded = decode(config.data);\n    const matches = decoded.matchAll(commandRegex);\n    for (const match of matches) {\n      const rawCommand = match[1].trim();\n      // Replace matrix variable ${VAR} with a wildcard match (non-greedy)\n      const patternString = rawCommand\n        .replace(specialCharsRegex, '\\\\$&') // escape all\n        .replace(matrixVariableRegex, '.*'); // match ${VAR}\n\n      patterns.push(new RegExp(`^${patternString}$`));\n    }\n  });\n  return patterns;\n});\n\nconst groupedLogs = computed(() => {\n  if (!log.value) return [];\n\n  if (!pipelineConfigs.value || pipelineConfigs.value.length === 0) {\n    return [\n      {\n        id: 0,\n        command: null,\n        lines: log.value,\n        isActualCommand: false,\n      },\n    ];\n  }\n\n  const blocks: LogBlock[] = [];\n  let currentBlock: LogBlock | null = null;\n\n  log.value.forEach((line) => {\n    const trimmedText = (line.rawText || '').trim();\n\n    let isCommand = false;\n    if (trimmedText.startsWith('+ ')) {\n      const cmdPart = trimmedText.slice(2).trim();\n      isCommand = knownCommandMatchers.value.some((matcher) => matcher.test(cmdPart));\n    }\n\n    if (isCommand) {\n      currentBlock = {\n        command: line,\n        lines: [line],\n        id: line.number,\n        isActualCommand: true,\n      };\n      blocks.push(currentBlock);\n    } else {\n      if (!currentBlock) {\n        currentBlock = {\n          command: { number: 0, text: 'Initialization', type: null, index: -1 } as LogLine,\n          lines: [],\n          id: 0,\n          isActualCommand: false,\n        };\n        blocks.push(currentBlock);\n      }\n      currentBlock.lines.push(line);\n    }\n  });\n\n  return blocks;\n});\n\nconst hasGroupedLogs = computed(() => {\n  return groupedLogs.value.find((g) => g.isActualCommand);\n});\n\nconst urlRegex = /https?:\\/\\/\\S+/g;\n\nfunction isScrolledToBottom(): boolean {\n  if (!consoleElement.value) {\n    return false;\n  }\n  // we use 5 as threshold\n  return consoleElement.value.scrollHeight - consoleElement.value.scrollTop - consoleElement.value.clientHeight < 5;\n}\n\nfunction isSelected(line: LogLine): boolean {\n  return route.hash === `#L${line.number}`;\n}\n\nfunction formatTime(time?: number): string {\n  return time === undefined ? '' : `${time}s`;\n}\n\nfunction toggleGroup(id: number) {\n  if (collapsedCommands.value.has(id)) {\n    collapsedCommands.value.delete(id);\n  } else {\n    collapsedCommands.value.add(id);\n  }\n}\n\nfunction expandAll() {\n  collapsedCommands.value.clear();\n}\n\nfunction collapseAll() {\n  const newSet = new Set<number>();\n  groupedLogs.value.forEach((group) => {\n    if (group.isActualCommand) {\n      newSet.add(group.id);\n    }\n  });\n  collapsedCommands.value = newSet;\n}\n\nfunction processText(text: string): string {\n  let txt = ansiUp.value.ansi_to_html(`${decode(text)}\\n`);\n  txt = txt.replace(\n    urlRegex,\n    (url) => `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline\">${url}</a>`,\n  );\n  return txt;\n}\n\nfunction writeLog(line: Partial<LogLine>) {\n  const rawText = decode(line.text ?? '');\n  logBuffer.value.push({\n    index: line.index ?? 0,\n    number: (line.index ?? 0) + 1,\n    text: processText(line.text ?? ''),\n    rawText,\n    time: line.time ?? 0,\n    type: null, // TODO: implement way to detect errors and warnings\n  });\n}\n\nfunction scrollDown() {\n  nextTick(() => {\n    if (!consoleElement.value) {\n      return;\n    }\n    consoleElement.value.scrollTop = consoleElement.value.scrollHeight;\n  });\n}\n\nconst flushLogs = debounce((scroll: boolean) => {\n  let buffer = logBuffer.value.slice(-maxLineCount);\n  logBuffer.value = [];\n\n  if (buffer.length === 0) {\n    if (!log.value) {\n      log.value = [];\n    }\n    return;\n  }\n\n  // append old logs lines\n  if (buffer.length < maxLineCount && log.value) {\n    buffer = [...log.value.slice(-(maxLineCount - buffer.length)), ...buffer];\n  }\n\n  // deduplicate repeating times\n  buffer = buffer.reduce(\n    (acc, line) => ({\n      lastTime: line.time ?? 0,\n      lines: [\n        ...acc.lines,\n        {\n          ...line,\n          time: acc.lastTime === line.time ? undefined : line.time,\n        },\n      ],\n    }),\n    { lastTime: -1, lines: [] as LogLine[] },\n  ).lines;\n\n  log.value = buffer;\n\n  if (route.hash.length > 0) {\n    nextTick(() => document.getElementById(route.hash.substring(1))?.scrollIntoView());\n  } else if (scroll && autoScroll.value && isScrolledToBottom()) {\n    scrollDown();\n  }\n}, 500);\n\nasync function download() {\n  if (!repo?.value || !pipeline.value || !step.value) {\n    throw new Error('The repository, pipeline or step was undefined');\n  }\n  let logs;\n  try {\n    downloadInProgress.value = true;\n    logs = await apiClient.getLogs(repo.value.id, pipeline.value.number, step.value.id);\n  } catch (e) {\n    notifications.notifyError(e as Error, i18n.t('repo.pipeline.log_download_error'));\n    return;\n  } finally {\n    downloadInProgress.value = false;\n  }\n  const fileURL = window.URL.createObjectURL(\n    new Blob([logs.map((line) => decode(line.data ?? '')).join('\\n')], {\n      type: 'text/plain',\n    }),\n  );\n  const fileLink = document.createElement('a');\n\n  fileLink.href = fileURL;\n  fileLink.setAttribute(\n    'download',\n    `${repo.value.owner}-${repo.value.name}-${pipeline.value.number}-${step.value.name}.log`,\n  );\n  document.body.appendChild(fileLink);\n\n  fileLink.click();\n  document.body.removeChild(fileLink);\n  window.URL.revokeObjectURL(fileURL);\n}\n\nasync function loadLogs() {\n  if (loadedStepSlug.value === stepSlug.value) {\n    return;\n  }\n\n  log.value = undefined;\n  logBuffer.value = [];\n  ansiUp.value = new AnsiUp();\n  ansiUp.value.use_classes = true;\n\n  stream.value?.close();\n\n  if (!hasLogs.value || !step.value) {\n    return;\n  }\n\n  if (step.value.state !== 'running' && step.value.state !== 'pending') {\n    loadedStepSlug.value = stepSlug.value;\n    const logs = await apiClient.getLogs(repo.value.id, pipeline.value.number, step.value.id);\n    logs?.forEach((line) => writeLog({ index: line.line, text: line.data, time: line.time }));\n    flushLogs(false);\n  } else {\n    loadedStepSlug.value = stepSlug.value;\n    stream.value = apiClient.streamLogs(repo.value.id, pipeline.value.number, step.value.id, (line) => {\n      writeLog({ index: line.line, text: line.data, time: line.time });\n      flushLogs(true);\n    });\n  }\n}\n\nasync function deleteLogs() {\n  if (!repo?.value || !pipeline.value || !step.value) {\n    throw new Error('The repository, pipeline or step was undefined');\n  }\n\n  // TODO: use proper dialog (copy-pasted from web/src/components/secrets/SecretList.vue:deleteSecret)\n  // eslint-disable-next-line no-alert\n  if (!confirm(i18n.t('repo.pipeline.log_delete_confirm'))) {\n    return;\n  }\n\n  try {\n    await apiClient.deleteLogs(repo.value.id, pipeline.value.number, step.value.id);\n    log.value = [];\n  } catch (e) {\n    notifications.notifyError(e as Error, i18n.t('repo.pipeline.log_delete_error'));\n  }\n}\n\nfunction findStep(workflows: PipelineWorkflow[], pid: number): PipelineStep | undefined {\n  return workflows.reduce(\n    (prev, workflow) => {\n      const result = workflow.children.reduce(\n        (prevChild, step) => {\n          if (step.pid === pid) {\n            return step;\n          }\n\n          return prevChild;\n        },\n        undefined as PipelineStep | undefined,\n      );\n      if (result) {\n        return result;\n      }\n\n      return prev;\n    },\n    undefined as PipelineStep | undefined,\n  );\n}\n\nonMounted(async () => {\n  await loadLogs();\n});\n\nonBeforeUnmount(() => {\n  stream.value?.close();\n});\n\nwatch(stepSlug, async () => {\n  await loadLogs();\n});\n\nwatch(step, async (newStep, oldStep) => {\n  if (oldStep?.name === newStep?.name) {\n    if (oldStep?.finished !== newStep?.finished && autoScroll.value && isScrolledToBottom()) {\n      scrollDown();\n    }\n\n    if (oldStep?.state !== newStep?.state) {\n      await loadLogs();\n    }\n  }\n});\n\nconst expandLogGroupWithPageHash = (hash: string) => {\n  if (hash.startsWith('#L')) {\n    const lineNum = Number.parseInt(hash.substring(2));\n    const parentGroup = groupedLogs.value.find((g) => lineNum === g.id || g.lines.some((l) => l.number === lineNum));\n    if (parentGroup && collapsedCommands.value.has(parentGroup.id)) {\n      collapsedCommands.value.delete(parentGroup.id);\n    }\n  }\n};\n\n// When user click on a step, if the step has already finished running, show user the\n// only the outline by collapse all log groups\nwatch(loadedLogs, async (isLoaded, wasLoaded) => {\n  // Only trigger when transitioning from unloaded to loaded state\n  if (isLoaded && !wasLoaded) {\n    const isFinished = step.value && !['running', 'pending', 'started'].includes(step.value.state);\n    if (isFinished) {\n      // Wait for groupedLogs computed property to update\n      await nextTick();\n      collapseAll();\n      expandLogGroupWithPageHash(route.hash);\n    }\n  }\n});\n\n// If route hash contain line that is in a collapsed log group, expand it\nwatch(\n  () => route.hash,\n  (newHash) => {\n    expandLogGroupWithPageHash(newHash);\n  },\n  { immediate: true },\n);\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineRunningIcon.vue",
    "content": "<template>\n  <WoodpeckerIcon class=\"woodpecker h-15\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport WoodpeckerIcon from '~/assets/woodpecker.svg?component';\n</script>\n\n<style scoped>\n@reference '~/tailwind.css';\n\n@keyframes peck {\n  0% {\n    transform: rotate(5deg) translateX(5%);\n  }\n  10% {\n    transform: rotate(-5deg) translateX(-15%);\n  }\n  20% {\n    transform: rotate(5deg) translateX(5%);\n  }\n  30% {\n    transform: rotate(-5deg) translateX(-15%);\n  }\n  100% {\n    transform: rotate(5deg) translateX(5%);\n  }\n}\n\n.woodpecker :deep(path) {\n  animation: peck 1s ease infinite;\n  @apply fill-wp-text-100;\n}\n</style>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineStatusIcon.vue",
    "content": "<template>\n  <div\n    class=\"flex items-center justify-center\"\n    :title=\"$t('repo.pipeline.status.status', { status: statusDescriptions[status] })\"\n  >\n    <Icon\n      :name=\"service ? 'settings' : `status-${status}`\"\n      :bg-circle=\"shouldShowBgCircle\"\n      size=\"1.5rem\"\n      :class=\"{\n        'text-wp-error-100': pipelineStatusColors[status] === 'red',\n        'text-wp-state-neutral-100': pipelineStatusColors[status] === 'gray',\n        'text-wp-state-ok-100': pipelineStatusColors[status] === 'green',\n        'text-wp-state-info-100': pipelineStatusColors[status] === 'blue',\n        'text-wp-state-warn-100': pipelineStatusColors[status] === 'orange',\n        'animate-spin': service && pipelineStatusColors[status] === 'blue',\n      }\"\n    />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport type { PipelineStatus } from '~/lib/api/types';\n\nimport { pipelineStatusColors } from './pipeline-status';\n\nconst { status, service } = defineProps<{\n  status: PipelineStatus;\n  service?: boolean;\n}>();\n\nconst { t } = useI18n();\n\nconst statusDescriptions = {\n  blocked: t('repo.pipeline.status.blocked'),\n  declined: t('repo.pipeline.status.declined'),\n  error: t('repo.pipeline.status.error'),\n  failure: t('repo.pipeline.status.failure'),\n  killed: t('repo.pipeline.status.killed'),\n  pending: t('repo.pipeline.status.pending'),\n  running: t('repo.pipeline.status.running'),\n  skipped: t('repo.pipeline.status.skipped'),\n  canceled: t('repo.pipeline.status.canceled'),\n  started: t('repo.pipeline.status.started'),\n  success: t('repo.pipeline.status.success'),\n} satisfies {\n  // eslint-disable-next-line no-unused-vars\n  [_ in PipelineStatus]: string;\n};\n\nconst shouldShowBgCircle = computed(() => {\n  return service\n    ? false\n    : ['blocked', 'declined', 'error', 'failure', 'killed', 'skipped', 'canceled', 'success'].includes(status);\n});\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineStepDuration.vue",
    "content": "<template>\n  <span v-if=\"started\" class=\"ml-auto text-sm\">{{ duration }}</span>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\n\nimport { useDate } from '~/compositions/useDate';\nimport { useElapsedTime } from '~/compositions/useElapsedTime';\nimport type { PipelineStep, PipelineWorkflow } from '~/lib/api/types';\n\nconst props = defineProps<{\n  step?: PipelineStep;\n  workflow?: PipelineWorkflow;\n}>();\n\nconst step = toRef(props, 'step');\nconst workflow = toRef(props, 'workflow');\nconst { durationAsNumber } = useDate();\n\nconst durationRaw = computed(() => {\n  const start = (step.value ? step.value?.started : workflow.value?.started) || 0;\n  const end = (step.value ? step.value?.finished : workflow.value?.finished) || 0;\n\n  if (end === 0 && start === 0) {\n    return undefined;\n  }\n\n  if (end === 0) {\n    return Date.now() - start * 1000;\n  }\n\n  return (end - start) * 1000;\n});\n\nconst running = computed(() => (step.value ? step.value?.state : workflow.value?.state) === 'running');\nconst { time: durationElapsed } = useElapsedTime(running, durationRaw);\n\nconst duration = computed(() => {\n  if (durationElapsed.value === undefined) {\n    return '-';\n  }\n\n  return durationAsNumber(durationElapsed.value || 0);\n});\nconst started = computed(() => (step.value ? step.value?.started : workflow.value?.started) !== undefined);\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/PipelineStepList.vue",
    "content": "<template>\n  <div class=\"text-wp-text-100 flex w-full flex-col gap-2 pb-2 md:w-3/12 md:max-w-md md:min-w-xs\">\n    <div\n      class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-200 flex shrink-0 flex-wrap justify-between gap-1 rounded-md border p-4\"\n    >\n      <div class=\"flex shrink-0 items-center space-x-1\">\n        <div class=\"flex items-center\">\n          <Icon v-if=\"pipeline.event === 'cron'\" name=\"stopwatch\" />\n          <img v-else class=\"w-6 rounded-md\" :src=\"pipeline.author_avatar\" />\n        </div>\n        <span>{{ pipeline.author }}</span>\n      </div>\n      <a\n        v-if=\"\n          // eslint-disable vue/html-indent\n          pipeline.event === 'pull_request' ||\n          pipeline.event === 'pull_request_closed' ||\n          pipeline.event === 'pull_request_metadata'\n          // eslint-enable vue/html-indent\n        \"\n        class=\"text-wp-link-100 hover:text-wp-link-200 flex min-w-0 items-center space-x-1\"\n        :href=\"pipeline.forge_url\"\n      >\n        <Icon name=\"pull-request\" />\n        <span class=\"truncate\">{{ prettyRef }}</span>\n      </a>\n      <router-link\n        v-else-if=\"pipeline.event === 'push' || pipeline.event === 'manual' || pipeline.event === 'deployment'\"\n        class=\"text-wp-link-100 hover:text-wp-link-200 flex min-w-0 items-center space-x-1\"\n        :to=\"{ name: 'repo-branch', params: { branch: prettyRef } }\"\n      >\n        <Icon v-if=\"pipeline.event === 'manual'\" name=\"manual-pipeline\" />\n        <Icon v-else-if=\"pipeline.event === 'push'\" name=\"branch\" />\n        <Icon v-else-if=\"pipeline.event === 'deployment'\" name=\"deployment\" />\n        <span class=\"truncate\">{{ prettyRef }}</span>\n      </router-link>\n      <div v-else class=\"flex min-w-0 items-center space-x-1\">\n        <Icon v-if=\"pipeline.event === 'tag' || pipeline.event === 'release'\" name=\"tag\" />\n\n        <span class=\"truncate\">{{ prettyRef }}</span>\n      </div>\n      <div class=\"flex shrink-0 items-center\">\n        <template v-if=\"pipeline.event === 'pull_request'\">\n          <Icon name=\"commit\" />\n          <span>{{ pipeline.commit.slice(0, 10) }}</span>\n        </template>\n        <a\n          v-else\n          class=\"text-wp-link-100 hover:text-wp-link-200 flex items-center\"\n          :href=\"pipeline.forge_url\"\n          target=\"_blank\"\n        >\n          <Icon name=\"commit\" />\n          <span>{{ pipeline.commit.slice(0, 10) }}</span>\n        </a>\n      </div>\n    </div>\n\n    <Panel v-if=\"pipeline.workflows === undefined || pipeline.workflows.length === 0\">\n      <span>{{ $t('repo.pipeline.no_pipeline_steps') }}</span>\n    </Panel>\n\n    <div class=\"relative min-h-0 w-full grow\">\n      <div class=\"absolute top-0 right-0 left-0 flex h-full flex-col gap-y-2 md:overflow-y-auto\">\n        <div\n          v-for=\"workflow in pipeline.workflows\"\n          :key=\"workflow.id\"\n          class=\"border-wp-background-400 dark:border-wp-background-100 bg-wp-background-200 rounded-md border p-2\"\n        >\n          <div class=\"flex flex-col gap-2\">\n            <div v-if=\"workflow.environ\" class=\"flex flex-wrap justify-end gap-x-1 gap-y-2 pt-1 pr-1 text-xs\">\n              <div v-for=\"(value, key) in workflow.environ\" :key=\"key\">\n                <Badge :label=\"key\" :value=\"value\" />\n              </div>\n            </div>\n            <button\n              v-if=\"!singleConfig\"\n              type=\"button\"\n              :title=\"workflow.name\"\n              class=\"hover:bg-wp-control-neutral-200 flex cursor-pointer items-center gap-2 rounded-md px-1 py-2\"\n              @click=\"workflowsCollapsed[workflow.id] = !workflowsCollapsed[workflow.id]\"\n            >\n              <Icon\n                name=\"chevron-right\"\n                class=\"h-6 min-w-6 transition-transform duration-150\"\n                :class=\"{ 'rotate-90 transform': !workflowsCollapsed[workflow.id] }\"\n              />\n              <PipelineStatusIcon :status=\"workflow.state\" class=\"h-4! w-4!\" />\n              <span class=\"truncate\">{{ workflow.name }}</span>\n              <PipelineStepDuration\n                v-if=\"workflow.started !== workflow.finished\"\n                :workflow=\"workflow\"\n                class=\"pr-2px mr-1\"\n              />\n            </button>\n          </div>\n          <div\n            class=\"transition-height overflow-hidden duration-150\"\n            :class=\"{ 'max-h-0': workflowsCollapsed[workflow.id], 'ml-[1.6rem]': !singleConfig }\"\n          >\n            <button\n              v-for=\"step in workflow.children\"\n              ref=\"steps\"\n              :key=\"step.pid\"\n              :data-step-id=\"step.pid\"\n              type=\"button\"\n              :title=\"step.name\"\n              class=\"hover:bg-wp-control-neutral-200 flex w-full cursor-pointer items-center gap-2 rounded-md border-2 border-transparent p-2\"\n              :class=\"{\n                'bg-wp-control-neutral-200': selectedStepId && selectedStepId === step.pid,\n                'mt-1': !singleConfig || (workflow.children && step.pid !== workflow.children[0].pid),\n              }\"\n              @click=\"$emit('update:selected-step-id', step.pid)\"\n            >\n              <PipelineStatusIcon :service=\"step.type === StepType.Service\" :status=\"step.state\" class=\"h-4! w-4!\" />\n              <span class=\"truncate\">{{ step.name }}</span>\n              <PipelineStepDuration :step=\"step\" />\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, nextTick, ref, toRef, useTemplateRef, watch } from 'vue';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport PipelineStepDuration from '~/components/repo/pipeline/PipelineStepDuration.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport usePipeline from '~/compositions/usePipeline';\nimport { StepType } from '~/lib/api/types';\nimport type { Pipeline, PipelineStep } from '~/lib/api/types';\n\nconst props = defineProps<{\n  pipeline: Pipeline;\n  selectedStepId?: number | null;\n}>();\n\ndefineEmits<{\n  (event: 'update:selected-step-id', selectedStepId: number): void;\n}>();\n\nconst pipeline = toRef(props, 'pipeline');\nconst selectedStepId = toRef(props, 'selectedStepId');\nconst { prettyRef } = usePipeline(pipeline);\nconst pipelineConfigs = requiredInject('pipeline-configs');\n\nconst workflowsCollapsed = ref<Record<PipelineStep['id'], boolean>>(\n  pipeline.value.workflows && pipeline.value.workflows.length > 1\n    ? (pipeline.value.workflows || []).reduce(\n        (collapsed, workflow) => ({\n          ...collapsed,\n          [workflow.id]:\n            ['success', 'skipped', 'blocked'].includes(workflow.state) &&\n            !workflow.children.some((child) => child.pid === selectedStepId.value),\n        }),\n        {},\n      )\n    : {},\n);\n\nconst singleConfig = computed(\n  () => pipelineConfigs?.value?.length === 1 && pipeline.value.workflows && pipeline.value.workflows.length === 1,\n);\n\nconst steps = useTemplateRef('steps');\nwatch(selectedStepId, async (newSelectedStepId, oldSelectedStepId) => {\n  if (!oldSelectedStepId && newSelectedStepId) {\n    await nextTick();\n    const step = steps.value?.find((s) => s.dataset.stepId === newSelectedStepId.toString());\n    if (step) {\n      step.scrollIntoView({\n        behavior: 'auto',\n        block: 'start',\n      });\n    }\n  }\n});\n</script>\n"
  },
  {
    "path": "web/src/components/repo/pipeline/pipeline-status.ts",
    "content": "import type { PipelineStatus } from '~/lib/api/types';\n\nexport const pipelineStatusColors: Record<PipelineStatus, 'green' | 'gray' | 'red' | 'blue' | 'orange'> = {\n  blocked: 'gray',\n  declined: 'red',\n  error: 'red',\n  failure: 'red',\n  killed: 'gray',\n  pending: 'orange',\n  skipped: 'gray',\n  canceled: 'gray',\n  running: 'blue',\n  started: 'blue',\n  success: 'green',\n};\n"
  },
  {
    "path": "web/src/components/secrets/SecretEdit.vue",
    "content": "<template>\n  <div v-if=\"innerValue\" class=\"space-y-4\">\n    <form @submit.prevent=\"save\">\n      <InputField v-slot=\"{ id }\" :label=\"$t('secrets.name')\">\n        <TextField\n          :id=\"id\"\n          v-model=\"innerValue.name\"\n          :placeholder=\"$t('secrets.name')\"\n          required\n          :disabled=\"isEditingSecret\"\n        />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('secrets.value')\">\n        <TextField\n          :id=\"id\"\n          v-model=\"innerValue.value\"\n          :placeholder=\"$t('secrets.value')\"\n          :lines=\"5\"\n          :required=\"!isEditingSecret\"\n        />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('secrets.plugins.images')\">\n        <span class=\"text-wp-text-alt-100 mb-2 ml-1\">{{ $t('secrets.plugins.desc') }}</span>\n\n        <div class=\"flex flex-col gap-2\">\n          <div v-for=\"image in innerValue.images\" :key=\"image\" class=\"flex gap-2\">\n            <TextField :id=\"id\" :model-value=\"image\" disabled />\n            <Button type=\"button\" color=\"gray\" start-icon=\"trash\" @click=\"removeImage(image)\" />\n          </div>\n          <div class=\"flex gap-2\">\n            <TextField :id=\"id\" v-model=\"newImage\" @keydown.enter.prevent=\"addNewImage\" />\n            <Button type=\"button\" color=\"gray\" start-icon=\"plus\" @click=\"addNewImage\" />\n          </div>\n        </div>\n      </InputField>\n\n      <InputField :label=\"$t('secrets.events.events')\">\n        <Warning class=\"mb-4 text-sm\" :text=\"$t('secrets.events.warning')\" />\n        <CheckboxesField v-model=\"innerValue.events\" :options=\"secretEventsOptions\" />\n      </InputField>\n\n      <InputField v-slot=\"{ id }\" :label=\"$t('secrets.note')\">\n        <TextField :id=\"id\" v-model=\"innerValue.note\" :placeholder=\"$t('secrets.note')\" :lines=\"3\" />\n      </InputField>\n\n      <div class=\"flex gap-2\">\n        <Button type=\"button\" color=\"gray\" :text=\"$t('cancel')\" @click=\"$emit('cancel')\" />\n        <Button\n          type=\"submit\"\n          color=\"green\"\n          :is-loading=\"isSaving\"\n          :text=\"isEditingSecret ? $t('secrets.save') : $t('secrets.add')\"\n        />\n      </div>\n    </form>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Warning from '~/components/atomic/Warning.vue';\nimport CheckboxesField from '~/components/form/CheckboxesField.vue';\nimport type { CheckboxOption } from '~/components/form/form.types';\nimport InputField from '~/components/form/InputField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport { WebhookEvents } from '~/lib/api/types';\nimport type { Secret } from '~/lib/api/types';\n\nconst props = defineProps<{\n  modelValue: Partial<Secret>;\n  isSaving: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'update:modelValue', value: Partial<Secret> | undefined): void;\n  (event: 'save', value: Partial<Secret>): void;\n  (event: 'cancel'): void;\n}>();\n\nconst i18n = useI18n();\n\nconst modelValue = toRef(props, 'modelValue');\nconst innerValue = computed({\n  get: () => modelValue.value,\n  set: (value) => {\n    emit('update:modelValue', value);\n  },\n});\nconst isEditingSecret = computed(() => !!innerValue.value?.id);\n\nconst newImage = ref('');\nfunction addNewImage() {\n  if (!newImage.value) {\n    return;\n  }\n  innerValue.value.images?.push(newImage.value);\n  newImage.value = '';\n}\nfunction removeImage(image: string) {\n  innerValue.value.images = innerValue.value.images?.filter((i) => i !== image);\n}\n\nconst secretEventsOptions: CheckboxOption[] = [\n  { value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },\n  { value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },\n  { value: WebhookEvents.Release, text: i18n.t('repo.pipeline.event.release') },\n  { value: WebhookEvents.PullRequest, text: i18n.t('repo.pipeline.event.pr') },\n  { value: WebhookEvents.Deploy, text: i18n.t('repo.pipeline.event.deploy') },\n  { value: WebhookEvents.Cron, text: i18n.t('repo.pipeline.event.cron') },\n  { value: WebhookEvents.Manual, text: i18n.t('repo.pipeline.event.manual') },\n];\n\nfunction save() {\n  if (!innerValue.value) {\n    return;\n  }\n\n  if (newImage.value) {\n    innerValue.value.images?.push(newImage.value);\n  }\n\n  emit('save', innerValue.value);\n}\n</script>\n"
  },
  {
    "path": "web/src/components/secrets/SecretList.vue",
    "content": "<template>\n  <div class=\"text-wp-text-100 space-y-4\">\n    <ListItem\n      v-for=\"secret in secrets\"\n      :key=\"secret.id\"\n      class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center\"\n    >\n      <span :title=\"secret.note\">{{ secret.name }}</span>\n      <Badge\n        v-if=\"secret.edit === false\"\n        class=\"ml-2\"\n        :value=\"secret.org_id === 0 ? $t('global_level_secret') : $t('org_level_secret')\"\n      />\n      <div class=\"md:display-unset ml-auto hidden space-x-2\">\n        <Badge v-for=\"event in secret.events\" :key=\"event\" :value=\"event\" />\n      </div>\n      <div v-if=\"secret.edit !== false\" class=\"flex items-center gap-2\">\n        <IconButton icon=\"edit\" class=\"h-8 w-8 md:ml-2\" :title=\"$t('secrets.edit')\" @click=\"editSecret(secret)\" />\n        <IconButton\n          icon=\"trash\"\n          class=\"hover:text-wp-error-100 h-8 w-8\"\n          :is-loading=\"isDeleting\"\n          :title=\"$t('secrets.delete')\"\n          @click=\"deleteSecret(secret)\"\n        />\n      </div>\n    </ListItem>\n\n    <div v-if=\"loading\" class=\"flex justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n    <div v-else-if=\"secrets?.length === 0\" class=\"ml-2\">{{ $t('secrets.none') }}</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport type { Secret } from '~/lib/api/types';\n\nconst props = defineProps<{\n  modelValue: (Secret & { edit?: boolean })[];\n  isDeleting: boolean;\n  loading: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'edit', secret: Secret): void;\n  (event: 'delete', secret: Secret): void;\n}>();\n\nconst i18n = useI18n();\n\nconst secrets = toRef(props, 'modelValue');\n\nfunction editSecret(secret: Secret) {\n  emit('edit', secret);\n}\n\nfunction deleteSecret(secret: Secret) {\n  // TODO: use proper dialog\n  // eslint-disable-next-line no-alert\n  if (!confirm(i18n.t('secrets.delete_confirm'))) {\n    return;\n  }\n  emit('delete', secret);\n}\n</script>\n"
  },
  {
    "path": "web/src/compositions/useApiClient.ts",
    "content": "import WoodpeckerClient from '~/lib/api';\n\nimport useConfig from './useConfig';\n\nlet apiClient: WoodpeckerClient | undefined;\n\nexport default (): WoodpeckerClient => {\n  if (!apiClient) {\n    const config = useConfig();\n    const server = config.rootPath;\n    const token = null;\n    const csrf = config.csrf ?? null;\n\n    apiClient = new WoodpeckerClient(server, token, csrf);\n  }\n\n  return apiClient;\n};\n"
  },
  {
    "path": "web/src/compositions/useAsyncAction.ts",
    "content": "import { computed, ref } from 'vue';\n\nexport function useAsyncAction<T extends unknown[]>(\n  action: (...a: T) => void | Promise<void>,\n  onerror: ((error: any) => void) | undefined = undefined,\n) {\n  const isLoading = ref(false);\n\n  async function doSubmit(...a: T) {\n    if (isLoading.value) {\n      return;\n    }\n\n    isLoading.value = true;\n    try {\n      await action(...a);\n    } catch (error) {\n      console.error(error);\n      if (onerror) {\n        onerror(error);\n      }\n    }\n    isLoading.value = false;\n  }\n\n  return {\n    doSubmit,\n    isLoading: computed(() => isLoading.value),\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useAuthentication.ts",
    "content": "import useConfig from '~/compositions/useConfig';\n\nexport default () =>\n  ({\n    isAuthenticated: !!useConfig().user,\n\n    user: useConfig().user,\n\n    authenticate(forgeId?: number) {\n      window.location.href = `${useConfig().rootPath}/authorize?${forgeId !== undefined ? `forge_id=${forgeId}` : ''}`;\n    },\n  }) as const;\n"
  },
  {
    "path": "web/src/compositions/useConfig.ts",
    "content": "import type { User } from '~/lib/api/types';\n\ndeclare global {\n  interface Window {\n    WOODPECKER_USER: User | undefined;\n    WOODPECKER_VERSION: string | undefined;\n    WOODPECKER_SKIP_VERSION_CHECK: boolean | undefined;\n    WOODPECKER_CSRF: string | undefined;\n    WOODPECKER_ROOT_PATH: string | undefined;\n    WOODPECKER_ENABLE_SWAGGER: boolean | undefined;\n    WOODPECKER_USER_REGISTERED_AGENTS: boolean | undefined;\n    WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT: number | undefined;\n  }\n}\n\nexport default () => ({\n  user: window.WOODPECKER_USER ?? null,\n  version: window.WOODPECKER_VERSION,\n  skipVersionCheck: window.WOODPECKER_SKIP_VERSION_CHECK === true || false,\n  csrf: window.WOODPECKER_CSRF ?? null,\n  rootPath: window.WOODPECKER_ROOT_PATH ?? '',\n  enableSwagger: window.WOODPECKER_ENABLE_SWAGGER === true || false,\n  userRegisteredAgents: window.WOODPECKER_USER_REGISTERED_AGENTS || false,\n  maxPipelineLogLineCount: window.WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT ?? 5000,\n});\n"
  },
  {
    "path": "web/src/compositions/useDate.ts",
    "content": "import { useI18n } from 'vue-i18n';\n\nlet currentLocale = 'en';\n\nfunction splitDuration(durationMs: number) {\n  const totalSeconds = durationMs / 1000;\n  const totalMinutes = totalSeconds / 60;\n  const totalHours = totalMinutes / 60;\n\n  const seconds = Math.floor(totalSeconds) % 60;\n  const minutes = Math.floor(totalMinutes) % 60;\n  const hours = Math.floor(totalHours) % 24;\n\n  return {\n    seconds,\n    minutes,\n    hours,\n    totalHours,\n    totalMinutes,\n    totalSeconds,\n  };\n}\n\nfunction toLocaleString(date: Date) {\n  return date.toLocaleString(currentLocale, {\n    dateStyle: 'short',\n    timeStyle: 'short',\n  });\n}\n\nfunction timeAgo(date: number) {\n  const seconds = Math.floor((Date.now() - date) / 1000);\n\n  const formatter = new Intl.RelativeTimeFormat(currentLocale);\n\n  let interval = seconds / 31536000;\n  if (interval > 1) {\n    return formatter.format(-Math.round(interval), 'year');\n  }\n  interval = seconds / 2592000;\n  if (interval > 1) {\n    return formatter.format(-Math.round(interval), 'month');\n  }\n  interval = seconds / 86400;\n  if (interval > 1) {\n    return formatter.format(-Math.round(interval), 'day');\n  }\n  interval = seconds / 3600;\n  if (interval > 1) {\n    return formatter.format(-Math.round(interval), 'hour');\n  }\n  interval = seconds / 60;\n  if (interval > 0.5) {\n    return formatter.format(-Math.round(interval), 'minute');\n  }\n  return useI18n().t('time.just_now');\n}\n\nfunction prettyDuration(durationMs: number) {\n  const t = splitDuration(durationMs);\n\n  if (t.totalHours > 1) {\n    return Intl.NumberFormat(currentLocale, { style: 'unit', unit: 'hour', unitDisplay: 'long' }).format(\n      Math.round(t.totalHours),\n    );\n  }\n  if (t.totalMinutes > 1) {\n    return Intl.NumberFormat(currentLocale, { style: 'unit', unit: 'minute', unitDisplay: 'long' }).format(\n      Math.round(t.totalMinutes),\n    );\n  }\n  return Intl.NumberFormat(currentLocale, { style: 'unit', unit: 'second', unitDisplay: 'long' }).format(\n    Math.round(t.totalSeconds),\n  );\n}\n\nfunction durationAsNumber(durationMs: number): string {\n  const { seconds, minutes, hours } = splitDuration(durationMs);\n\n  const minSecFormat = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n\n  if (hours > 0) {\n    return `${hours.toString().padStart(2, '0')}:${minSecFormat}`;\n  }\n\n  return minSecFormat;\n}\n\nexport function useDate() {\n  async function setDateLocale(locale: string) {\n    currentLocale = locale;\n  }\n\n  return {\n    toLocaleString,\n    timeAgo,\n    prettyDuration,\n    setDateLocale,\n    durationAsNumber,\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useElapsedTime.ts",
    "content": "import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';\nimport type { Ref } from 'vue';\n\nexport function useElapsedTime(running: Ref<boolean>, startTime: Ref<number | undefined>) {\n  const time = ref<number | undefined>(startTime.value);\n  const timer = ref<ReturnType<typeof setInterval>>();\n\n  function stopTimer() {\n    if (timer.value !== undefined) {\n      clearInterval(timer.value);\n      timer.value = undefined;\n    }\n  }\n\n  function startTimer() {\n    stopTimer();\n\n    if (time.value === undefined || !running.value) {\n      return;\n    }\n\n    timer.value = setInterval(() => {\n      if (time.value !== undefined) {\n        time.value += 1000;\n      }\n    }, 1000);\n  }\n\n  watch([running, startTime], () => {\n    time.value = startTime.value;\n\n    // should run, has a start-time and is not running atm\n    if (running.value && time.value !== undefined && timer.value === undefined) {\n      startTimer();\n    }\n\n    // should not run or has no start-time and is running atm\n    if ((!running.value || time.value === undefined) && timer.value !== undefined) {\n      stopTimer();\n    }\n  });\n\n  onMounted(startTimer);\n\n  onBeforeUnmount(stopTimer);\n\n  return {\n    time: computed(() => (time.value === undefined ? 0 : time.value)),\n    running,\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useEvents.ts",
    "content": "import { usePipelineStore } from '~/store/pipelines';\nimport { useRepoStore } from '~/store/repos';\n\nimport useApiClient from './useApiClient';\n\nconst apiClient = useApiClient();\nlet initialized = false;\n\nexport default () => {\n  if (initialized) {\n    return;\n  }\n  const repoStore = useRepoStore();\n  const pipelineStore = usePipelineStore();\n\n  initialized = true;\n\n  apiClient.on((data) => {\n    // contains repo update\n    if (!data.repo) {\n      return;\n    }\n    const { repo } = data;\n    repoStore.setRepo(repo);\n\n    // contains pipeline update\n    if (!data.pipeline) {\n      return;\n    }\n    const { pipeline } = data;\n    pipelineStore.setPipeline(repo.id, pipeline);\n  });\n};\n"
  },
  {
    "path": "web/src/compositions/useFavicon.ts",
    "content": "import { computed, ref, watch } from 'vue';\n\nimport useConfig from '~/compositions/useConfig';\nimport { useTheme } from '~/compositions/useTheme';\nimport type { PipelineStatus } from '~/lib/api/types';\n\nconst { theme } = useTheme();\nconst darkMode = computed(() => theme.value);\n\ntype Status = 'default' | 'success' | 'pending' | 'error';\nconst faviconStatus = ref<Status>('default');\n\nwatch(\n  [darkMode, faviconStatus],\n  () => {\n    const faviconPNG = document.getElementById('favicon-png');\n    if (faviconPNG) {\n      (faviconPNG as HTMLLinkElement).href = `${useConfig().rootPath}/favicons/favicon-${darkMode.value}-${\n        faviconStatus.value\n      }.png`;\n    }\n\n    const faviconSVG = document.getElementById('favicon-svg');\n    if (faviconSVG) {\n      (faviconSVG as HTMLLinkElement).href = `${useConfig().rootPath}/favicons/favicon-${darkMode.value}-${\n        faviconStatus.value\n      }.svg`;\n    }\n  },\n  { immediate: true },\n);\n\nfunction convertStatus(status: PipelineStatus): Status {\n  if (['declined', 'error', 'failure', 'killed'].includes(status)) {\n    return 'error';\n  }\n\n  if (['blocked', 'started', 'running', 'pending'].includes(status)) {\n    return 'pending';\n  }\n\n  if (status === 'success') {\n    return 'success';\n  }\n\n  // skipped\n  return 'default';\n}\n\nexport function useFavicon() {\n  return {\n    updateStatus(status?: PipelineStatus | 'default') {\n      if (status === undefined || status === 'default') {\n        faviconStatus.value = 'default';\n      } else {\n        faviconStatus.value = convertStatus(status);\n      }\n    },\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useForgeStore.ts",
    "content": "import { defineStore } from 'pinia';\nimport { computed, reactive } from 'vue';\nimport type { Ref } from 'vue';\n\nimport useApiClient from '~/compositions/useApiClient';\nimport type { Forge } from '~/lib/api/types';\n\nexport const useForgeStore = defineStore('forges', () => {\n  const apiClient = useApiClient();\n\n  const forges = reactive<Map<number, Forge>>(new Map());\n\n  async function loadForge(forgeId: number): Promise<Forge> {\n    const forge = await apiClient.getForge(forgeId);\n    forges.set(forge.id, forge);\n    return forge;\n  }\n\n  async function getForge(forgeId: number): Promise<Ref<Forge | undefined>> {\n    if (!forges.has(forgeId)) {\n      await loadForge(forgeId);\n    }\n\n    return computed(() => forges.get(forgeId));\n  }\n\n  return {\n    getForge,\n    loadForge,\n  };\n});\n"
  },
  {
    "path": "web/src/compositions/useI18n.ts",
    "content": "import { useStorage } from '@vueuse/core';\nimport { SUPPORTED_LOCALES } from 'virtual:vue-i18n-supported-locales';\nimport { nextTick } from 'vue';\nimport { createI18n } from 'vue-i18n';\n\nimport { useDate } from './useDate';\n\nexport function getUserLanguage(): string {\n  let browserLocale = navigator.language;\n  if (!SUPPORTED_LOCALES.includes(browserLocale)) {\n    browserLocale = browserLocale.split('-')[0];\n  }\n  const selectedLocale = useStorage('woodpecker:locale', browserLocale).value;\n\n  return selectedLocale;\n}\n\nconst { setDateLocale } = useDate();\nconst userLanguage = getUserLanguage();\nconst fallbackLocale = 'en';\nexport const i18n = createI18n({\n  locale: userLanguage,\n  legacy: false,\n  globalInjection: true,\n  fallbackLocale,\n});\n\nconst loadLocaleMessages = async (locale: string) => {\n  const messages = (await import(`~/assets/locales/${locale}.json`)) as { default: any };\n\n  i18n.global.setLocaleMessage(locale, messages.default);\n\n  return nextTick();\n};\n\nexport const setI18nLanguage = async (lang: string): Promise<void> => {\n  if (!i18n.global.availableLocales.includes(lang)) {\n    await loadLocaleMessages(lang);\n  }\n  i18n.global.locale.value = lang;\n  await setDateLocale(lang);\n};\n\nloadLocaleMessages(fallbackLocale).catch(console.error);\nloadLocaleMessages(userLanguage).catch(console.error);\nsetDateLocale(userLanguage).catch(console.error);\n"
  },
  {
    "path": "web/src/compositions/useInjectProvide.ts",
    "content": "import type { InjectionKey, Ref } from 'vue';\nimport { inject as vueInject, provide as vueProvide } from 'vue';\n\nimport type { Org, OrgPermissions, Pipeline, PipelineConfig, Repo, RepoPermissions } from '~/lib/api/types';\n\nimport type { Tab } from './useTabs';\n\nexport interface InjectKeys {\n  repo: Ref<Repo>;\n  'repo-permissions': Ref<RepoPermissions>;\n  org: Ref<Org>;\n  'org-permissions': Ref<OrgPermissions>;\n  pipeline: Ref<Pipeline>;\n  'pipeline-configs': Ref<PipelineConfig[] | undefined>;\n  tabs: Ref<Tab[]>;\n  pipelines: Ref<Pipeline[]>;\n}\n\nexport function requiredInject<T extends keyof InjectKeys>(key: T): InjectKeys[T] {\n  const value = vueInject<InjectKeys[T]>(key);\n  if (value === undefined) {\n    throw new Error(`Unexpected: ${key} should be provided at this place`);\n  }\n  return value;\n}\n\nexport function provide<T extends keyof InjectKeys>(key: T, value: InjectKeys[T]): void {\n  return vueProvide(key, value as T extends InjectionKey<infer V> ? V : InjectKeys[T]);\n}\n"
  },
  {
    "path": "web/src/compositions/useInterval.ts",
    "content": "import { onBeforeUnmount, onMounted, ref } from 'vue';\n\nexport function useInterval(fn: () => void | Promise<void>, ms: number): void {\n  const id = ref<number>();\n\n  onMounted(async () => {\n    await fn(); // run once immediately\n    id.value = window.setInterval(() => {\n      void fn();\n    }, ms);\n  });\n\n  onBeforeUnmount(() => {\n    if (id.value != null) {\n      window.clearInterval(id.value);\n    }\n  });\n}\n"
  },
  {
    "path": "web/src/compositions/useNotifications.ts",
    "content": "import Notifications, { notify } from '@kyvg/vue3-notification';\nimport type { NotificationsOptions } from '@kyvg/vue3-notification';\n\nexport const notifications = Notifications;\n\nfunction notifyError(err: Error, args: NotificationsOptions | string = {}): void {\n  console.error(err);\n\n  const mArgs = typeof args === 'string' ? { title: args } : args;\n  const title = mArgs?.title ?? err?.message ?? err?.toString();\n\n  notify({ type: 'error', ...mArgs, title });\n}\n\nexport default () => ({ notify, notifyError });\n"
  },
  {
    "path": "web/src/compositions/usePaginate.test.ts",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport { describe, expect, it } from 'vitest';\nimport { watch } from 'vue';\nimport type { Ref } from 'vue';\n\nimport { usePagination } from './usePaginate';\n\nasync function waitForState<T>(ref: Ref<T>, expected: T): Promise<void> {\n  await new Promise<void>((resolve) => {\n    watch(\n      ref,\n      (value) => {\n        if (value === expected) {\n          resolve();\n        }\n      },\n      { immediate: true },\n    );\n  });\n}\n\n// eslint-disable-next-line promise/prefer-await-to-callbacks\nexport const mountComposition = (cb: () => void) => {\n  const wrapper = shallowMount({\n    setup() {\n      // eslint-disable-next-line promise/prefer-await-to-callbacks\n      cb();\n      return {};\n    },\n    template: '<div />',\n  });\n\n  return wrapper;\n};\n\ndescribe('usePaginate', () => {\n  const repoSecrets = [\n    [{ name: 'repo1' }, { name: 'repo2' }, { name: 'repo3' }],\n    [{ name: 'repo4' }, { name: 'repo5' }, { name: 'repo6' }],\n  ];\n  const orgSecrets = [\n    [{ name: 'org1' }, { name: 'org2' }, { name: 'org3' }],\n    [{ name: 'org4' }, { name: 'org5' }, { name: 'org6' }],\n  ];\n\n  it('should get first page', async () => {\n    let usePaginationComposition = null as unknown as ReturnType<typeof usePagination>;\n    mountComposition(() => {\n      usePaginationComposition = usePagination<{ name: string }>(\n        async (page) => repoSecrets[page - 1],\n        () => true,\n        { pageSize: 3 },\n      );\n    });\n    await waitForState(usePaginationComposition.loading, true);\n    await waitForState(usePaginationComposition.loading, false);\n\n    expect(usePaginationComposition.data.value.length).toBe(3);\n    expect(usePaginationComposition.data.value[0]).toStrictEqual(repoSecrets[0][0]);\n  });\n\n  it('should get first & second page', async () => {\n    let usePaginationComposition = null as unknown as ReturnType<typeof usePagination>;\n    mountComposition(() => {\n      usePaginationComposition = usePagination<{ name: string }>(\n        async (page) => repoSecrets[page - 1],\n        () => true,\n        { pageSize: 3 },\n      );\n    });\n    await waitForState(usePaginationComposition.loading, true);\n    await waitForState(usePaginationComposition.loading, false);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    expect(usePaginationComposition.data.value.length).toBe(6);\n    expect(usePaginationComposition.data.value.at(-1)).toStrictEqual(repoSecrets[1][2]);\n  });\n\n  it('should get first page for each category', async () => {\n    let usePaginationComposition = null as unknown as ReturnType<typeof usePagination>;\n    mountComposition(() => {\n      usePaginationComposition = usePagination<{ name: string }>(\n        async (page, level) => {\n          if (level === 'repo') {\n            return repoSecrets[page - 1];\n          }\n          return orgSecrets[page - 1];\n        },\n        () => true,\n        { each: ['repo', 'org'], pageSize: 3 },\n      );\n    });\n    await waitForState(usePaginationComposition.loading, true);\n    await waitForState(usePaginationComposition.loading, false);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    expect(usePaginationComposition.data.value.length).toBe(9);\n    expect(usePaginationComposition.data.value.at(-1)).toStrictEqual(orgSecrets[0][2]);\n  });\n\n  it('should reset page and get first page again', async () => {\n    let usePaginationComposition = null as unknown as ReturnType<typeof usePagination>;\n    mountComposition(() => {\n      usePaginationComposition = usePagination<{ name: string }>(\n        async (page) => repoSecrets[page - 1],\n        () => true,\n        { pageSize: 3 },\n      );\n    });\n    await waitForState(usePaginationComposition.loading, true);\n    await waitForState(usePaginationComposition.loading, false);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    void usePaginationComposition.resetPage();\n    await waitForState(usePaginationComposition.loading, false);\n\n    expect(usePaginationComposition.data.value.length).toBe(3);\n    expect(usePaginationComposition.data.value[0]).toStrictEqual(repoSecrets[0][0]);\n  });\n\n  it('should not hasMore when no data is left', async () => {\n    let usePaginationComposition = null as unknown as ReturnType<typeof usePagination>;\n    mountComposition(() => {\n      usePaginationComposition = usePagination<{ name: string }>(\n        async (page) => repoSecrets[page - 1],\n        () => true,\n        { pageSize: 3 },\n      );\n    });\n    await waitForState(usePaginationComposition.loading, true);\n    await waitForState(usePaginationComposition.loading, false);\n\n    expect(usePaginationComposition.hasMore.value).toBe(true);\n    expect(usePaginationComposition.data.value.length).toBe(3);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n    expect(usePaginationComposition.hasMore.value).toBe(true);\n    expect(usePaginationComposition.data.value.length).toBe(6);\n\n    usePaginationComposition.nextPage();\n    await waitForState(usePaginationComposition.loading, false);\n    expect(usePaginationComposition.hasMore.value).toBe(false);\n    expect(usePaginationComposition.data.value.length).toBe(6);\n  });\n});\n"
  },
  {
    "path": "web/src/compositions/usePaginate.ts",
    "content": "import { useInfiniteScroll } from '@vueuse/core';\nimport { onMounted, ref, watch } from 'vue';\nimport type { Ref, UnwrapRef } from 'vue';\n\nconst defaultPageSize = 50;\n\n// usePaginate loads all pages\nexport async function usePaginate<T>(\n  getSingle: (page: number) => Promise<T[]>,\n  pageSize: number = defaultPageSize,\n): Promise<T[]> {\n  let hasMore = true;\n  let page = 1;\n  const result: T[] = [];\n  while (hasMore) {\n    const singleRes = await getSingle(page);\n    result.push(...singleRes);\n    hasMore = singleRes.length >= pageSize;\n    page += 1;\n  }\n  return result;\n}\n\n// usePagination loads pages on demand\nexport function usePagination<T, S = unknown>(\n  _loadData: (page: number, arg: S) => Promise<T[] | null>,\n  isActive: () => boolean = () => true,\n  {\n    scrollElement: _scrollElement,\n    each: _each,\n    pageSize: _pageSize,\n  }: { scrollElement?: Ref<HTMLElement | null> | null; each?: S[]; pageSize?: number } = {},\n) {\n  const scrollElement = _scrollElement === null ? null : ref(document.getElementById('scroll-component'));\n  const page = ref(1);\n  const pageSize = ref(_pageSize ?? defaultPageSize);\n  const hasMore = ref(true);\n  const data = ref<T[]>([]) as Ref<T[]>;\n  const loading = ref(false);\n  const each = ref([...(_each ?? [])]);\n\n  async function loadData() {\n    if (loading.value === true || hasMore.value === false) {\n      return;\n    }\n\n    loading.value = true;\n    const newData = (await _loadData(page.value, each.value?.[0] as S)) ?? [];\n    hasMore.value = newData.length >= pageSize.value && newData.length > 0;\n    if (newData.length > 0) {\n      data.value.push(...newData);\n    }\n\n    // last page and each has more\n    if (!hasMore.value && each.value.length > 0) {\n      // use next each element\n      each.value.shift();\n      page.value = 1;\n      hasMore.value = each.value.length > 0;\n      if (hasMore.value) {\n        loading.value = false;\n        await loadData();\n      }\n    }\n    loading.value = false;\n  }\n\n  onMounted(loadData);\n  watch(page, loadData);\n\n  function nextPage() {\n    if (isActive() && !loading.value && hasMore.value) {\n      page.value += 1;\n    }\n  }\n\n  if (scrollElement !== null) {\n    useInfiniteScroll(scrollElement, nextPage, { distance: 10 });\n  }\n\n  async function resetPage() {\n    const _page = page.value;\n\n    page.value = 1;\n    hasMore.value = true;\n    data.value = [];\n    loading.value = false;\n    each.value = [...(_each ?? [])] as UnwrapRef<S[]>;\n\n    if (_page === 1) {\n      // we need to reload manually as the page is already 1, so changing won't trigger watcher\n      await loadData();\n    }\n  }\n\n  return { resetPage, nextPage, data, hasMore, loading };\n}\n"
  },
  {
    "path": "web/src/compositions/usePipeline.ts",
    "content": "import { emojify } from 'node-emoji';\nimport { computed } from 'vue';\nimport type { Ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport { useDate } from '~/compositions/useDate';\nimport { useElapsedTime } from '~/compositions/useElapsedTime';\nimport type { Pipeline } from '~/lib/api/types';\nimport { escapeHtml } from '~/lib/utils';\n\nconst { toLocaleString, timeAgo, prettyDuration } = useDate();\n\nexport default (pipeline: Ref<Pipeline | undefined>) => {\n  const sinceRaw = computed(() => {\n    if (!pipeline.value) {\n      return undefined;\n    }\n\n    const start = pipeline.value.created || 0;\n\n    return start * 1000;\n  });\n\n  const sinceUnderOneHour = computed(\n    () => sinceRaw.value !== undefined && sinceRaw.value > 0 && sinceRaw.value <= 1000 * 60 * 60,\n  );\n  const { time: sinceElapsed } = useElapsedTime(sinceUnderOneHour, sinceRaw);\n\n  const i18n = useI18n();\n  const since = computed(() => {\n    if (sinceRaw.value === 0) {\n      // return i18n.t('time.not_started');\n      return '-';\n    }\n\n    if (sinceElapsed.value === undefined) {\n      return null;\n    }\n\n    // TODO: check whether elapsed works\n    return timeAgo(sinceElapsed.value);\n  });\n\n  const durationRaw = computed(() => {\n    if (!pipeline.value) {\n      return undefined;\n    }\n\n    const start = pipeline.value.started || 0;\n    const end = pipeline.value.finished || pipeline.value.updated || 0;\n\n    if (start === 0 || end === 0) {\n      return 0;\n    }\n\n    // only calculate time based no now() for running pipelines\n    if (pipeline.value.status === 'running') {\n      return Date.now() - start * 1000;\n    }\n\n    return (end - start) * 1000;\n  });\n\n  const running = computed(() => pipeline.value !== undefined && pipeline.value.status === 'running');\n  const { time: durationElapsed } = useElapsedTime(running, durationRaw);\n\n  const duration = computed(() => {\n    if (durationElapsed.value === undefined) {\n      return null;\n    }\n\n    if (durationRaw.value === 0) {\n      return i18n.t('time.not_started');\n    }\n\n    return prettyDuration(durationElapsed.value);\n  });\n\n  const message = computed(() => emojify(escapeHtml(pipeline.value?.message ?? '')));\n  const shortMessage = computed(() => message.value.split('\\n')[0]);\n\n  const prTitleWithDescription = computed(() => emojify(escapeHtml(pipeline.value?.title ?? '')));\n  const prTitle = computed(() => prTitleWithDescription.value.split('\\n')[0]);\n\n  const prettyRef = computed(() => {\n    if (pipeline.value?.event === 'push' || pipeline.value?.event === 'deployment') {\n      return pipeline.value.branch;\n    }\n\n    if (pipeline.value?.event === 'cron') {\n      return pipeline.value.ref.replaceAll('refs/heads/', '');\n    }\n\n    if (pipeline.value?.event === 'tag') {\n      return pipeline.value.ref.replaceAll('refs/tags/', '');\n    }\n\n    if (\n      pipeline.value?.event === 'pull_request' ||\n      pipeline.value?.event === 'pull_request_closed' ||\n      pipeline.value?.event === 'pull_request_metadata'\n    ) {\n      return `#${pipeline.value.ref\n        .replaceAll('refs/pull/', '')\n        .replaceAll('refs/merge-requests/', '')\n        .replaceAll('/merge', '')\n        .replaceAll('/head', '')}`;\n    }\n\n    return pipeline.value?.ref;\n  });\n\n  const created = computed(() => {\n    if (!pipeline.value) {\n      return undefined;\n    }\n\n    const start = pipeline.value.created || 0;\n\n    return toLocaleString(new Date(start * 1000));\n  });\n\n  return {\n    since,\n    duration,\n    durationElapsed,\n    message,\n    shortMessage,\n    prTitle,\n    prTitleWithDescription,\n    prettyRef,\n    created,\n  };\n};\n"
  },
  {
    "path": "web/src/compositions/usePipelineFeed.ts",
    "content": "import { computed, toRef } from 'vue';\n\nimport useUserConfig from '~/compositions/useUserConfig';\nimport { usePipelineStore } from '~/store/pipelines';\n\nimport useAuthentication from './useAuthentication';\n\nconst userConfig = useUserConfig();\n\nfunction toggle() {\n  userConfig.setUserConfig('isPipelineFeedOpen', !userConfig.userConfig.value.isPipelineFeedOpen);\n}\n\nfunction close() {\n  userConfig.setUserConfig('isPipelineFeedOpen', false);\n}\n\nexport default () => {\n  const pipelineStore = usePipelineStore();\n  const { isAuthenticated } = useAuthentication();\n\n  const isOpen = computed(() => userConfig.userConfig.value.isPipelineFeedOpen && !!isAuthenticated);\n\n  const sortedPipelines = toRef(pipelineStore, 'pipelineFeed');\n  const activePipelines = toRef(pipelineStore, 'activePipelines');\n\n  return {\n    toggle,\n    close,\n    isOpen,\n    sortedPipelines,\n    activePipelines,\n    load: pipelineStore.loadPipelineFeed,\n  };\n};\n"
  },
  {
    "path": "web/src/compositions/useRepoSearch.ts",
    "content": "import Fuse from 'fuse.js';\nimport { computed } from 'vue';\nimport type { Ref } from 'vue';\n\nimport type { Repo } from '~/lib/api/types';\n\n/*\n * Compares Repos lexicographically using owner/name .\n */\nfunction repoCompare(a: Repo, b: Repo) {\n  const x = `${a.owner}/${a.name}`;\n  const y = `${b.owner}/${b.name}`;\n\n  return x === y ? 0 : x > y ? 1 : -1;\n}\n\nexport function useRepoSearch(repos: Ref<Repo[] | undefined>, search: Ref<string>) {\n  const searchIndex = computed(\n    () =>\n      new Fuse(repos.value ?? [], {\n        includeScore: true,\n        keys: ['name', 'owner'],\n        threshold: 0.4,\n      }),\n  );\n\n  const searchedRepos = computed(() => {\n    if (search.value === '') {\n      return repos.value?.sort(repoCompare);\n    }\n\n    return searchIndex.value\n      .search(search.value)\n      .map((result) => result.item)\n      .sort(repoCompare);\n  });\n\n  return {\n    searchedRepos,\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useRepos.ts",
    "content": "import { useStorage } from '@vueuse/core';\nimport { ref } from 'vue';\n\nimport type { Repo } from '~/lib/api/types';\nimport { usePipelineStore } from '~/store/pipelines';\n\nexport default function useRepos() {\n  const pipelineStore = usePipelineStore();\n  const lastAccess = useStorage('woodpecker:repo-last-access', new Map<number, number>());\n\n  function repoWithLastPipeline(repo: Repo): Repo {\n    if (repo.last_pipeline_number === undefined) {\n      return repo;\n    }\n\n    if (repo.last_pipeline?.number === repo.last_pipeline_number) {\n      return repo;\n    }\n\n    const lastPipeline = pipelineStore.getPipeline(ref(repo.id), ref(repo.last_pipeline_number)).value;\n\n    return {\n      ...repo,\n      last_pipeline: lastPipeline,\n    };\n  }\n\n  function sortReposByLastAccess(repos: Repo[]): Repo[] {\n    return repos\n      .filter((r) => lastAccess.value.get(r.id) !== undefined)\n      .sort((a, b) => {\n        const aLastAccess = lastAccess.value.get(a.id)!;\n        const bLastAccess = lastAccess.value.get(b.id)!;\n\n        return bLastAccess - aLastAccess;\n      });\n  }\n\n  function sortReposByLastActivity(repos: Repo[]): Repo[] {\n    return repos.sort((a, b) => {\n      const aLastActivity = a.last_pipeline?.created ?? 0;\n      const bLastActivity = b.last_pipeline?.created ?? 0;\n      return bLastActivity - aLastActivity;\n    });\n  }\n\n  function updateLastAccess(repoId: number) {\n    lastAccess.value.set(repoId, Date.now());\n  }\n\n  return {\n    sortReposByLastAccess,\n    sortReposByLastActivity,\n    repoWithLastPipeline,\n    updateLastAccess,\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useRouteBack.ts",
    "content": "import { useRouter } from 'vue-router';\nimport type { RouteLocationRaw } from 'vue-router';\n\nexport function useRouteBack(to: RouteLocationRaw) {\n  const router = useRouter();\n\n  return async () => {\n    await router.replace(to);\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useTabs.ts",
    "content": "import { ref } from 'vue';\nimport type { RouteLocationRaw } from 'vue-router';\n\nimport type { IconNames } from '~/components/atomic/Icon.vue';\n\nimport { provide, requiredInject } from './useInjectProvide';\n\nexport interface Tab {\n  to: RouteLocationRaw;\n  title: string;\n  count?: number;\n  icon?: IconNames;\n  iconClass?: string;\n  matchChildren?: boolean;\n}\n\nexport function useTabsProvider() {\n  const tabs = ref<Tab[]>([]);\n  provide('tabs', tabs);\n}\n\nexport function useTabsClient() {\n  const tabs = requiredInject('tabs');\n  return { tabs };\n}\n"
  },
  {
    "path": "web/src/compositions/useTheme.ts",
    "content": "import { useColorMode } from '@vueuse/core';\nimport { watch } from 'vue';\n\nconst {\n  store: storeTheme,\n  state: resolvedTheme,\n  system: systemTheme,\n} = useColorMode({\n  storageKey: 'woodpecker:theme',\n});\n\nfunction updateTheme() {\n  if (resolvedTheme.value === 'dark') {\n    document.documentElement.classList.remove('light');\n    document.documentElement.classList.add('dark');\n    document.documentElement.setAttribute('data-theme', 'dark');\n    document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#2A2E3A'); // internal-wp-secondary-600 (see tailwind.config.ts)\n  } else {\n    document.documentElement.classList.remove('dark');\n    document.documentElement.classList.add('light');\n    document.documentElement.setAttribute('data-theme', 'light');\n    document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#369943'); // internal-wp-primary-400\n  }\n}\n\nwatch([storeTheme, systemTheme], updateTheme, { immediate: true });\n\nexport function useTheme() {\n  return {\n    theme: resolvedTheme,\n    storeTheme,\n  };\n}\n"
  },
  {
    "path": "web/src/compositions/useUserConfig.ts",
    "content": "import { useStorage } from '@vueuse/core';\nimport { computed } from 'vue';\n\ninterface UserConfig {\n  isPipelineFeedOpen: boolean;\n  redirectUrl: string;\n}\n\nconst config = useStorage<UserConfig>('woodpecker:user-config', {\n  isPipelineFeedOpen: false,\n  redirectUrl: '',\n});\n\nexport default () => ({\n  setUserConfig<T extends keyof UserConfig>(key: T, value: UserConfig[T]): void {\n    config.value = { ...config.value, [key]: value };\n  },\n  userConfig: computed(() => config.value),\n});\n"
  },
  {
    "path": "web/src/compositions/useVersion.ts",
    "content": "import semverCoerce from 'semver/functions/coerce';\nimport semverGt from 'semver/functions/gt';\nimport { onMounted, ref } from 'vue';\n\nimport useAuthentication from './useAuthentication';\nimport useConfig from './useConfig';\n\ninterface VersionInfo {\n  latest: string;\n  rc: string;\n  next: string;\n}\n\nconst version = ref<{\n  latest: string | undefined;\n  current: string;\n  currentShort: string;\n  needsUpdate: boolean;\n  usesNext: boolean;\n}>();\n\nasync function fetchVersion(): Promise<VersionInfo | undefined> {\n  try {\n    const resp = await fetch('https://woodpecker-ci.org/version.json');\n    const json = (await resp.json()) as VersionInfo;\n    return json;\n  } catch (error) {\n    console.error('Failed to fetch version info', error);\n    return undefined;\n  }\n}\n\nconst isInitialized = ref(false);\n\nexport function useVersion() {\n  if (isInitialized.value) {\n    return version;\n  }\n  isInitialized.value = true;\n\n  const config = useConfig();\n  const current = config.version as string;\n  const currentSemver = semverCoerce(current);\n  const usesNext = current.startsWith('next');\n\n  const { user } = useAuthentication();\n  if (config.skipVersionCheck || user?.admin !== true) {\n    version.value = {\n      latest: undefined,\n      current,\n      currentShort: usesNext ? 'next' : current,\n      needsUpdate: false,\n      usesNext,\n    };\n    return version;\n  }\n\n  if (current === 'dev') {\n    version.value = {\n      latest: undefined,\n      current,\n      currentShort: current,\n      needsUpdate: false,\n      usesNext,\n    };\n    return version;\n  }\n\n  onMounted(async () => {\n    const versionInfo = await fetchVersion();\n\n    let latest;\n    if (versionInfo) {\n      if (usesNext) {\n        latest = versionInfo.next;\n      } else if (current.includes('rc')) {\n        latest = versionInfo.rc;\n      } else {\n        latest = versionInfo.latest;\n      }\n    }\n\n    let needsUpdate = false;\n    if (usesNext) {\n      needsUpdate = latest !== current;\n    } else if (latest !== undefined && currentSemver !== null) {\n      needsUpdate = semverGt(latest, currentSemver);\n    }\n\n    version.value = {\n      latest,\n      current,\n      currentShort: usesNext ? 'next' : current,\n      needsUpdate,\n      usesNext,\n    };\n  });\n\n  return version;\n}\n"
  },
  {
    "path": "web/src/compositions/useWPTitle.ts",
    "content": "import { useTitle } from '@vueuse/core';\nimport type { Ref } from 'vue';\nimport { computed } from 'vue';\n\nexport function useWPTitle(elements: Ref<string[]>) {\n  useTitle(computed(() => `${elements.value.join(' · ')} · Woodpecker`));\n}\n"
  },
  {
    "path": "web/src/lib/api/client.ts",
    "content": "export interface ApiError {\n  status: number;\n  message: string;\n}\n\ntype QueryParams = Record<string, string | number | boolean>;\n\nexport function encodeQueryString(_params: unknown = {}): string {\n  const __params = _params as QueryParams;\n  const params: QueryParams = {};\n\n  Object.keys(__params).forEach((key) => {\n    const val = __params[key];\n    if (val !== undefined) {\n      params[key] = val;\n    }\n  });\n\n  return Object.keys(params)\n    .sort()\n    .map((key) => {\n      const val = params[key];\n      return `${encodeURIComponent(key)}=${encodeURIComponent(val)}`;\n    })\n    .join('&');\n}\n\nexport default class ApiClient {\n  server: string;\n\n  token: string | null;\n\n  csrf: string | null;\n\n  onerror: ((err: ApiError) => void) | undefined;\n\n  constructor(server: string, token: string | null, csrf: string | null) {\n    this.server = server;\n    this.token = token;\n    this.csrf = csrf;\n  }\n\n  private async _request(method: string, path: string, data?: unknown): Promise<unknown> {\n    const res = await fetch(`${this.server}${path}`, {\n      method,\n      headers: {\n        ...(method !== 'GET' && this.csrf !== null ? { 'X-CSRF-TOKEN': this.csrf } : {}),\n        ...(this.token !== null ? { Authorization: `Bearer ${this.token}` } : {}),\n        ...(data !== undefined ? { 'Content-Type': 'application/json' } : {}),\n      },\n      body: data !== undefined ? JSON.stringify(data) : undefined,\n    });\n\n    if (!res.ok) {\n      let message = res.statusText;\n      const resText = await res.text();\n      if (resText) {\n        message = `${res.statusText}: ${resText}`;\n      }\n      const error: ApiError = {\n        status: res.status,\n        message,\n      };\n      if (this.onerror) {\n        this.onerror(error);\n      }\n      throw new Error(message);\n    }\n\n    const contentType = res.headers.get('Content-Type');\n    if (contentType !== null && contentType.startsWith('application/json')) {\n      return res.json();\n    }\n\n    return res.text();\n  }\n\n  async _get(path: string) {\n    return this._request('GET', path);\n  }\n\n  async _post(path: string, data?: unknown) {\n    return this._request('POST', path, data);\n  }\n\n  async _patch(path: string, data?: unknown) {\n    return this._request('PATCH', path, data);\n  }\n\n  async _delete(path: string) {\n    return this._request('DELETE', path);\n  }\n\n  _subscribe<T>(path: string, callback: (data: T) => void, opts = { reconnect: true }) {\n    const query = encodeQueryString({\n      access_token: this.token ?? undefined,\n    });\n    let _path = this.server ? this.server + path : path;\n    _path = this.token !== null ? `${_path}?${query}` : _path;\n\n    const events = new EventSource(_path);\n    events.onmessage = (event) => {\n      const data = JSON.parse(event.data as string) as T;\n      // eslint-disable-next-line promise/prefer-await-to-callbacks\n      callback(data);\n    };\n\n    if (!opts.reconnect) {\n      events.onerror = (err) => {\n        // TODO: check if such events really have a data property\n        if ((err as Event & { data: string }).data === 'eof') {\n          events.close();\n        }\n      };\n    }\n    return events;\n  }\n\n  setErrorHandler(onerror: (err: ApiError) => void) {\n    this.onerror = onerror;\n  }\n}\n"
  },
  {
    "path": "web/src/lib/api/index.ts",
    "content": "import ApiClient, { encodeQueryString } from './client';\nimport type {\n  Agent,\n  Cron,\n  ExtensionSettings,\n  Forge,\n  Org,\n  OrgPermissions,\n  Pipeline,\n  PipelineConfig,\n  PipelineFeed,\n  PipelineLog,\n  PullRequest,\n  QueueInfo,\n  Registry,\n  Repo,\n  RepoPermissions,\n  RepoSettings,\n  Secret,\n  User,\n} from './types';\n\nconst DEFAULT_FORGE_ID = 1;\n\ninterface RepoListOptions {\n  all?: boolean;\n}\n\n// PipelineOptions is the data for creating a new pipeline\ninterface PipelineOptions {\n  branch: string;\n  variables: Record<string, string>;\n}\n\ninterface DeploymentOptions {\n  id: string;\n  environment: string;\n  task: string;\n  variables: Record<string, string>;\n}\n\ninterface PaginationOptions {\n  page?: number;\n  perPage?: number;\n}\n\nexport default class WoodpeckerClient extends ApiClient {\n  async getRepoList(opts?: RepoListOptions): Promise<Repo[]> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/user/repos?${query}`) as Promise<Repo[]>;\n  }\n\n  async lookupRepo(owner: string, name: string): Promise<Repo | undefined> {\n    return this._get(`/api/repos/lookup/${owner}/${name}`) as Promise<Repo | undefined>;\n  }\n\n  async getRepo(repoId: number): Promise<Repo> {\n    return this._get(`/api/repos/${repoId}`) as Promise<Repo>;\n  }\n\n  async getRepoPermissions(repoId: number): Promise<RepoPermissions> {\n    return this._get(`/api/repos/${repoId}/permissions`) as Promise<RepoPermissions>;\n  }\n\n  async getRepoBranches(repoId: number, opts?: PaginationOptions): Promise<string[]> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/branches?${query}`) as Promise<string[]>;\n  }\n\n  async getRepoPullRequests(repoId: number, opts?: PaginationOptions): Promise<PullRequest[]> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/pull_requests?${query}`) as Promise<PullRequest[]>;\n  }\n\n  async activateRepo(forgeRemoteId: string): Promise<Repo> {\n    return this._post(`/api/repos?forge_remote_id=${forgeRemoteId}`) as Promise<Repo>;\n  }\n\n  async updateRepo(repoId: number, repoSettings: Partial<RepoSettings & ExtensionSettings>): Promise<unknown> {\n    return this._patch(`/api/repos/${repoId}`, repoSettings);\n  }\n\n  async deleteRepo(repoId: number, remove = true): Promise<unknown> {\n    const query = encodeQueryString({ remove });\n    return this._delete(`/api/repos/${repoId}?${query}`);\n  }\n\n  async repairRepo(repoId: number): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/repair`);\n  }\n\n  async createPipeline(repoId: number, options: PipelineOptions): Promise<Pipeline | string> {\n    return this._post(`/api/repos/${repoId}/pipelines`, options) as Promise<Pipeline | string>;\n  }\n\n  // Deploy triggers a deployment for an existing pipeline using the\n  // specified target environment and task.\n  async deployPipeline(repoId: number, pipelineNumber: string, options: DeploymentOptions): Promise<Pipeline> {\n    const vars = {\n      ...options.variables,\n      event: 'deployment',\n      deploy_to: options.environment,\n      deploy_task: options.task,\n    };\n    const query = encodeQueryString(vars);\n    return this._post(`/api/repos/${repoId}/pipelines/${pipelineNumber}?${query}`) as Promise<Pipeline>;\n  }\n\n  async getPipelineList(\n    repoId: number,\n    opts?: PaginationOptions & { before?: string; after?: string; ref?: string; branch?: string; events?: string },\n  ): Promise<Pipeline[]> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/pipelines?${query}`) as Promise<Pipeline[]>;\n  }\n\n  async getPipeline(repoId: number, pipelineNumber: number | 'latest'): Promise<Pipeline> {\n    return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}`) as Promise<Pipeline>;\n  }\n\n  async getPipelineConfig(repoId: number, pipelineNumber: number): Promise<PipelineConfig[]> {\n    return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/config`) as Promise<PipelineConfig[]>;\n  }\n\n  async getPipelineMetadata(repoId: number, pipelineNumber: number): Promise<any> {\n    return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/metadata`) as Promise<any>;\n  }\n\n  async getPipelineFeed(): Promise<PipelineFeed[]> {\n    return this._get(`/api/user/feed`) as Promise<PipelineFeed[]>;\n  }\n\n  async cancelPipeline(repoId: number, pipelineNumber: number): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/pipelines/${pipelineNumber}/cancel`);\n  }\n\n  async approvePipeline(repoId: number, pipelineNumber: string): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/pipelines/${pipelineNumber}/approve`);\n  }\n\n  async declinePipeline(repoId: number, pipelineNumber: string): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/pipelines/${pipelineNumber}/decline`);\n  }\n\n  async restartPipeline(\n    repoId: number,\n    pipeline: string,\n    opts?: { event?: string; deploy_to?: string; fork?: boolean },\n  ): Promise<Pipeline> {\n    const query = encodeQueryString(opts);\n    return this._post(`/api/repos/${repoId}/pipelines/${pipeline}?${query}`) as Promise<Pipeline>;\n  }\n\n  async getLogs(repoId: number, pipeline: number, step: number): Promise<PipelineLog[]> {\n    return this._get(`/api/repos/${repoId}/logs/${pipeline}/${step}`) as Promise<PipelineLog[]>;\n  }\n\n  async deleteLogs(repoId: number, pipeline: number, step: number): Promise<unknown> {\n    return this._delete(`/api/repos/${repoId}/logs/${pipeline}/${step}`);\n  }\n\n  async getSecretList(repoId: number, opts?: PaginationOptions): Promise<Secret[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/secrets?${query}`) as Promise<Secret[] | null>;\n  }\n\n  async createSecret(repoId: number, secret: Partial<Secret>): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/secrets`, secret);\n  }\n\n  async updateSecret(repoId: number, secret: Partial<Secret>): Promise<unknown> {\n    const secretName = encodeURIComponent(secret.name ?? '');\n    return this._patch(`/api/repos/${repoId}/secrets/${secretName}`, secret);\n  }\n\n  async deleteSecret(repoId: number, secretName: string): Promise<unknown> {\n    const name = encodeURIComponent(secretName);\n    return this._delete(`/api/repos/${repoId}/secrets/${name}`);\n  }\n\n  async getRegistryList(repoId: number, opts?: PaginationOptions): Promise<Registry[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/registries?${query}`) as Promise<Registry[] | null>;\n  }\n\n  async createRegistry(repoId: number, registry: Partial<Registry>): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/registries`, registry);\n  }\n\n  async updateRegistry(repoId: number, registry: Partial<Registry>): Promise<unknown> {\n    return this._patch(`/api/repos/${repoId}/registries/${registry.address}`, registry);\n  }\n\n  async deleteRegistry(repoId: number, registryAddress: string): Promise<unknown> {\n    return this._delete(`/api/repos/${repoId}/registries/${registryAddress}`);\n  }\n\n  async getOrgRegistryList(orgId: number, opts?: PaginationOptions): Promise<Registry[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/orgs/${orgId}/registries?${query}`) as Promise<Registry[] | null>;\n  }\n\n  async createOrgRegistry(orgId: number, registry: Partial<Registry>): Promise<unknown> {\n    return this._post(`/api/orgs/${orgId}/registries`, registry);\n  }\n\n  async updateOrgRegistry(orgId: number, registry: Partial<Registry>): Promise<unknown> {\n    return this._patch(`/api/orgs/${orgId}/registries/${registry.address}`, registry);\n  }\n\n  async deleteOrgRegistry(orgId: number, registryAddress: string): Promise<unknown> {\n    return this._delete(`/api/orgs/${orgId}/registries/${registryAddress}`);\n  }\n\n  async getGlobalRegistryList(opts?: PaginationOptions): Promise<Registry[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/registries?${query}`) as Promise<Registry[] | null>;\n  }\n\n  async createGlobalRegistry(registry: Partial<Registry>): Promise<unknown> {\n    return this._post(`/api/registries`, registry);\n  }\n\n  async updateGlobalRegistry(registry: Partial<Registry>): Promise<unknown> {\n    return this._patch(`/api/registries/${registry.address}`, registry);\n  }\n\n  async deleteGlobalRegistry(registryAddress: string): Promise<unknown> {\n    return this._delete(`/api/registries/${registryAddress}`);\n  }\n\n  async getCronList(repoId: number, opts?: PaginationOptions): Promise<Cron[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos/${repoId}/cron?${query}`) as Promise<Cron[] | null>;\n  }\n\n  async createCron(repoId: number, cron: Partial<Cron>): Promise<unknown> {\n    return this._post(`/api/repos/${repoId}/cron`, cron);\n  }\n\n  async updateCron(repoId: number, cron: Partial<Cron>): Promise<unknown> {\n    return this._patch(`/api/repos/${repoId}/cron/${cron.id}`, cron);\n  }\n\n  async deleteCron(repoId: number, cronId: number): Promise<unknown> {\n    return this._delete(`/api/repos/${repoId}/cron/${cronId}`);\n  }\n\n  async runCron(repoId: number, cronId: number): Promise<Pipeline> {\n    return this._post(`/api/repos/${repoId}/cron/${cronId}`) as Promise<Pipeline>;\n  }\n\n  async getOrg(orgId: number): Promise<Org> {\n    return this._get(`/api/orgs/${orgId}`) as Promise<Org>;\n  }\n\n  async lookupOrg(name: string): Promise<Org> {\n    return this._get(`/api/orgs/lookup/${name}`) as Promise<Org>;\n  }\n\n  async getOrgPermissions(orgId: number): Promise<OrgPermissions> {\n    return this._get(`/api/orgs/${orgId}/permissions`) as Promise<OrgPermissions>;\n  }\n\n  async getOrgSecretList(orgId: number, opts?: PaginationOptions): Promise<Secret[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/orgs/${orgId}/secrets?${query}`) as Promise<Secret[] | null>;\n  }\n\n  async createOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {\n    return this._post(`/api/orgs/${orgId}/secrets`, secret);\n  }\n\n  async updateOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {\n    const secretName = encodeURIComponent(secret.name ?? '');\n    return this._patch(`/api/orgs/${orgId}/secrets/${secretName}`, secret);\n  }\n\n  async deleteOrgSecret(orgId: number, secretName: string): Promise<unknown> {\n    const name = encodeURIComponent(secretName);\n    return this._delete(`/api/orgs/${orgId}/secrets/${name}`);\n  }\n\n  async getGlobalSecretList(opts?: PaginationOptions): Promise<Secret[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/secrets?${query}`) as Promise<Secret[] | null>;\n  }\n\n  async createGlobalSecret(secret: Partial<Secret>): Promise<unknown> {\n    return this._post(`/api/secrets`, secret);\n  }\n\n  async updateGlobalSecret(secret: Partial<Secret>): Promise<unknown> {\n    const secretName = encodeURIComponent(secret.name ?? '');\n    return this._patch(`/api/secrets/${secretName}`, secret);\n  }\n\n  async deleteGlobalSecret(secretName: string): Promise<unknown> {\n    const name = encodeURIComponent(secretName);\n    return this._delete(`/api/secrets/${name}`);\n  }\n\n  async getSelf(): Promise<unknown> {\n    return this._get('/api/user');\n  }\n\n  async getToken(): Promise<string> {\n    return this._post('/api/user/token') as Promise<string>;\n  }\n\n  async getSignaturePublicKey(): Promise<string> {\n    return this._get('/api/signature/public-key') as Promise<string>;\n  }\n\n  async getAgents(opts?: PaginationOptions): Promise<Agent[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/agents?${query}`) as Promise<Agent[] | null>;\n  }\n\n  async getAgent(agentId: Agent['id']): Promise<Agent> {\n    return this._get(`/api/agents/${agentId}`) as Promise<Agent>;\n  }\n\n  async createAgent(agent: Partial<Agent>): Promise<Agent> {\n    return this._post('/api/agents', agent) as Promise<Agent>;\n  }\n\n  async updateAgent(agent: Partial<Agent>): Promise<Agent> {\n    return this._patch(`/api/agents/${agent.id}`, agent) as Promise<Agent>;\n  }\n\n  async deleteAgent(agent: Agent): Promise<unknown> {\n    return this._delete(`/api/agents/${agent.id}`);\n  }\n\n  async getOrgAgents(orgId: number, opts?: PaginationOptions): Promise<Agent[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/orgs/${orgId}/agents?${query}`) as Promise<Agent[] | null>;\n  }\n\n  async createOrgAgent(orgId: number, agent: Partial<Agent>): Promise<Agent> {\n    return this._post(`/api/orgs/${orgId}/agents`, agent) as Promise<Agent>;\n  }\n\n  async updateOrgAgent(orgId: number, agentId: number, agent: Partial<Agent>): Promise<Agent> {\n    return this._patch(`/api/orgs/${orgId}/agents/${agentId}`, agent) as Promise<Agent>;\n  }\n\n  async deleteOrgAgent(orgId: number, agentId: number): Promise<unknown> {\n    return this._delete(`/api/orgs/${orgId}/agents/${agentId}`);\n  }\n\n  async getForges(opts?: PaginationOptions): Promise<Forge[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/forges?${query}`) as Promise<Forge[] | null>;\n  }\n\n  async getForge(forgeId: Forge['id']): Promise<Forge> {\n    return this._get(`/api/forges/${forgeId}`) as Promise<Forge>;\n  }\n\n  async createForge(forge: Partial<Forge>): Promise<Forge> {\n    return this._post('/api/forges', forge) as Promise<Forge>;\n  }\n\n  async updateForge(forge: Partial<Forge>): Promise<unknown> {\n    return this._patch(`/api/forges/${forge.id}`, forge);\n  }\n\n  async deleteForge(forge: Forge): Promise<unknown> {\n    return this._delete(`/api/forges/${forge.id}`);\n  }\n\n  async getQueueInfo(): Promise<QueueInfo> {\n    return this._get('/api/queue/info') as Promise<QueueInfo>;\n  }\n\n  async pauseQueue(): Promise<unknown> {\n    return this._post('/api/queue/pause');\n  }\n\n  async resumeQueue(): Promise<unknown> {\n    return this._post('/api/queue/resume');\n  }\n\n  async getUsers(opts?: PaginationOptions): Promise<User[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/users?${query}`) as Promise<User[] | null>;\n  }\n\n  async getUser(username: string, forgeID?: number): Promise<User> {\n    const forge = forgeID ?? DEFAULT_FORGE_ID;\n    return this._get(`/api/users/${username}?forge_id=${forge}`) as Promise<User>;\n  }\n\n  async createUser(user: Partial<User>): Promise<User> {\n    return this._post('/api/users', user) as Promise<User>;\n  }\n\n  async updateUser(user: Partial<User>): Promise<unknown> {\n    return this._patch(`/api/users/${user.login}`, user);\n  }\n\n  async deleteUser(user: User): Promise<unknown> {\n    return this._delete(`/api/users/${user.login}?forge_id=${user.forge_id}`);\n  }\n\n  async resetToken(): Promise<string> {\n    return this._delete('/api/user/token') as Promise<string>;\n  }\n\n  async getOrgs(opts?: PaginationOptions): Promise<Org[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/orgs?${query}`) as Promise<Org[] | null>;\n  }\n\n  async deleteOrg(org: Org): Promise<unknown> {\n    return this._delete(`/api/orgs/${org.id}`);\n  }\n\n  async getAllRepos(opts?: PaginationOptions): Promise<Repo[] | null> {\n    const query = encodeQueryString(opts);\n    return this._get(`/api/repos?${query}`) as Promise<Repo[] | null>;\n  }\n\n  async repairAllRepos(): Promise<unknown> {\n    return this._post(`/api/repos/repair`);\n  }\n\n  // eslint-disable-next-line promise/prefer-await-to-callbacks\n  on(callback: (data: { pipeline?: Pipeline; repo?: Repo }) => void): EventSource {\n    return this._subscribe('/api/stream/events', callback, {\n      reconnect: true,\n    });\n  }\n\n  streamLogs(\n    repoId: number,\n    pipeline: number,\n    step: number,\n    // eslint-disable-next-line promise/prefer-await-to-callbacks\n    callback: (data: PipelineLog) => void,\n  ): EventSource {\n    return this._subscribe(`/api/stream/logs/${repoId}/${pipeline}/${step}`, callback, {\n      reconnect: true,\n    });\n  }\n}\n"
  },
  {
    "path": "web/src/lib/api/types/agent.ts",
    "content": "export interface Agent {\n  id: number;\n  name: string;\n  owner_id: number;\n  org_id: number;\n  token: string;\n  created: number;\n  updated: number;\n  last_contact: number;\n  platform: string;\n  backend: string;\n  capacity: number;\n  version: string;\n  no_schedule: boolean;\n  custom_labels: Record<string, string>;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/cron.ts",
    "content": "export interface Cron {\n  id: number;\n  name: string;\n  branch: string;\n  schedule: string;\n  enabled: boolean;\n  next_exec: number;\n  variables: Record<string, string>;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/forge.ts",
    "content": "export type ForgeType = 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'bitbucket-dc' | 'addon' | 'forgejo';\n\nexport interface Forge {\n  id: number;\n  type: ForgeType;\n  url: string;\n  client?: string;\n  oauth_client_secret?: string;\n  skip_verify?: boolean;\n  oauth_host?: string;\n  additional_options?: Record<string, unknown>;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/index.ts",
    "content": "export * from './agent';\nexport * from './cron';\nexport * from './forge';\nexport * from './org';\nexport * from './pipeline';\nexport * from './pipelineConfig';\nexport * from './pull_request';\nexport * from './queue';\nexport * from './registry';\nexport * from './repo';\nexport * from './secret';\nexport * from './user';\nexport * from './webhook';\n"
  },
  {
    "path": "web/src/lib/api/types/org.ts",
    "content": "// A version control organization.\nexport interface Org {\n  // The name of the organization.\n  id: number;\n  name: string;\n  is_user: boolean;\n}\n\nexport interface OrgPermissions {\n  member: boolean;\n  admin: boolean;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/pipeline.ts",
    "content": "import type { WebhookEvents } from './webhook';\n\nexport interface PipelineError<D = unknown> {\n  type: string;\n  message: string;\n  data?: D;\n  is_warning: boolean;\n}\n\nexport interface CancelInfo {\n  canceled_by_user: string;\n  canceled_by_step: string;\n  superseded_by: number;\n}\n\n// A pipeline for a repository.\nexport interface Pipeline {\n  id: number;\n\n  // The pipeline number.\n  // This number is specified within the context of the repository the pipeline belongs to and is unique within that.\n  number: number;\n\n  parent: number;\n\n  event: WebhookEvents;\n\n  event_reason: string[];\n\n  //  The current status of the pipeline.\n  status: PipelineStatus;\n\n  errors?: PipelineError[];\n\n  // When the pipeline request was received.\n  created: number;\n\n  // When the pipeline was updated last time in database.\n  updated: number;\n\n  // When the pipeline began execution.\n  started: number;\n\n  // When the pipeline was finished.\n  finished: number;\n\n  // Where the deployment should go.\n  deploy_to: string;\n\n  // The commit for the pipeline.\n  commit: string;\n\n  // The branch the commit was pushed to.\n  branch: string;\n\n  // The commit message.\n  message: string;\n\n  // When the commit was created.\n  timestamp: number;\n\n  // The alias for the commit.\n  ref: string;\n\n  // The mapping from the local repository to a branch in the forge.\n  refspec: string;\n\n  // The clone URL of the forge repository.\n  clone_url: string;\n\n  title: string;\n\n  sender: string;\n\n  // The login for the author of the commit.\n  author: string;\n\n  // The avatar for the author of the commit.\n  author_avatar: string;\n\n  //  email for the author of the commit.\n  author_email: string;\n\n  // This url will point to the repository state associated with the pipeline's commit.\n  forge_url: string;\n\n  reviewed_by: string;\n\n  reviewed: number;\n\n  // The steps associated with this pipeline.\n  // A pipeline will have multiple steps if a matrix pipeline was used or if a rebuild was requested.\n  workflows?: PipelineWorkflow[];\n\n  changed_files?: string[];\n\n  cancel_info: CancelInfo;\n\n  version: string;\n}\n\nexport type PipelineStatus =\n  | 'blocked'\n  | 'declined'\n  | 'error'\n  | 'failure'\n  | 'killed'\n  | 'pending'\n  | 'running'\n  | 'skipped'\n  | 'started'\n  | 'success'\n  | 'canceled';\n\nexport interface PipelineWorkflow {\n  id: number;\n  pipeline_id: number;\n  pid: number;\n  name: string;\n  state: PipelineStatus;\n  environ?: Record<string, string>;\n  started?: number;\n  finished?: number;\n  agent_id?: number;\n  error?: string;\n  children: PipelineStep[];\n}\n\nexport interface PipelineStep {\n  id: number;\n  uuid: string;\n  pipeline_id: number;\n  pid: number;\n  ppid: number;\n  name: string;\n  state: PipelineStatus;\n  exit_code: number;\n  started?: number;\n  finished?: number;\n  error?: string;\n  type?: StepType;\n}\n\nexport interface PipelineLog {\n  id: number;\n  step_id: number;\n  time: number;\n  line: number;\n  data: string; // base64 encoded\n  type: number;\n}\n\nexport type PipelineFeed = Pipeline & {\n  repo_id: number;\n};\n\n/* eslint-disable no-unused-vars */\nexport enum StepType {\n  Clone = 'clone',\n  Service = 'service',\n  Plugin = 'plugin',\n  Commands = 'commands',\n  Cache = 'cache',\n}\n/* eslint-enable */\n"
  },
  {
    "path": "web/src/lib/api/types/pipelineConfig.ts",
    "content": "// A config for a pipeline.\nexport interface PipelineConfig {\n  hash: string;\n  name: string;\n  data: string;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/pull_request.ts",
    "content": "// A version control pull request.\nexport interface PullRequest {\n  // The index of the pull request.\n  index: string;\n  // The title of the pull request.\n  title: string;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/queue.ts",
    "content": "export interface Task {\n  id: number;\n  pid: number;\n  name: string;\n  labels: Record<string, string>;\n  dependencies: string[];\n  dep_status: Record<string, string>;\n  run_on: string[];\n  agent_id: number;\n  agent_name: string;\n  pipeline_id: number;\n  pipeline_number: number;\n  repo_id: number;\n}\n\nexport interface QueueStats {\n  worker_count: number;\n  pending_count: number;\n  waiting_on_deps_count: number;\n  running_count: number;\n  completed_count: number;\n}\n\nexport interface QueueInfo {\n  pending: Task[];\n  waiting_on_deps: Task[];\n  running: Task[];\n  stats: QueueStats;\n  paused: boolean;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/registry.ts",
    "content": "export interface Registry {\n  id: string;\n  repo_id: number;\n  org_id: number;\n  address: string;\n  username: string;\n  password: string;\n  readonly: boolean;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/repo.ts",
    "content": "import type { Pipeline } from './pipeline';\n\n// A version control repository.\nexport interface Repo {\n  // Is the repo currently active or not\n  active: boolean;\n\n  // The unique identifier for the repository.\n  id: number;\n\n  // The id of the repository on the source control management system.\n  forge_remote_id: string;\n\n  // The id of the forge that the repository is on.\n  forge_id: number;\n\n  // The source control management being used.\n  // Currently, this is either 'git' or 'hg' (Mercurial).\n  scm: string;\n\n  // Whether the forge repo has PRs enabled.\n  pr_enabled: boolean;\n\n  // The id of the organization that owns the repository.\n  org_id: number;\n\n  // The owner of the repository.\n  owner: string;\n\n  // The name of the repository.\n  name: string;\n\n  // The full name of the repository.\n  // This is created from the owner and name of the repository.\n  full_name: string;\n\n  // The url for the avatar image.\n  avatar_url: string;\n\n  // The url to view the repository.\n  forge_url: string;\n\n  // The url used to clone the repository.\n  clone_url: string;\n\n  // The default branch of the repository.\n  default_branch: string;\n\n  // Whether the repository is publicly visible.\n  private: boolean;\n\n  // Whether the repository has trusted access for pipelines.\n  // If the repository is trusted then the host network can be used and\n  // volumes can be created.\n  trusted: RepoTrusted;\n\n  // x-dart-type: Duration\n  // The amount of time in minutes before the pipeline is killed.\n  timeout: number;\n\n  // Whether pull requests should trigger a pipeline.\n  allow_pr: boolean;\n\n  allow_deploy: boolean;\n\n  config_file: string;\n\n  visibility: RepoVisibility;\n\n  last_pipeline_number?: number;\n\n  last_pipeline?: Pipeline;\n\n  require_approval: RepoRequireApproval;\n\n  approval_allowed_users: string[];\n\n  // Events that will cancel running pipelines before starting a new one\n  cancel_previous_pipeline_events: string[];\n\n  netrc_trusted: string[];\n\n  // Endpoint for config extensions\n  config_extension_endpoint: string;\n\n  config_extension_exclusive: boolean;\n\n  // Whether to include netrc credentials in config extension requests\n  config_extension_netrc: boolean;\n\n  // Endpoint for registry extensions\n  registry_extension_endpoint: string;\n\n  // Whether to include netrc credentials in registry extension requests\n  registry_extension_netrc: boolean;\n\n  // Endpoint for secret extensions\n  secret_extension_endpoint: string;\n\n  // Whether to include netrc credentials in secret extension requests\n  secret_extension_netrc: boolean;\n\n  // True if forge returned a repo with same name but different forge remote id\n  has_forge_name_conflict?: boolean;\n\n  // True if repo only exist in the woodpecker store and not at the forge anymore\n  has_no_forge_repo?: boolean;\n}\n\n/* eslint-disable no-unused-vars */\nexport enum RepoVisibility {\n  Public = 'public',\n  Private = 'private',\n  Internal = 'internal',\n}\n\nexport enum RepoRequireApproval {\n  None = 'none',\n  Forks = 'forks',\n  PullRequests = 'pull_requests',\n  AllEvents = 'all_events',\n}\n/* eslint-enable */\n\nexport type RepoSettings = Pick<\n  Repo,\n  | 'config_file'\n  | 'timeout'\n  | 'visibility'\n  | 'trusted'\n  | 'require_approval'\n  | 'approval_allowed_users'\n  | 'allow_pr'\n  | 'allow_deploy'\n  | 'cancel_previous_pipeline_events'\n  | 'netrc_trusted'\n>;\n\nexport type ExtensionSettings = Pick<\n  Repo,\n  | 'config_extension_endpoint'\n  | 'config_extension_exclusive'\n  | 'config_extension_netrc'\n  | 'registry_extension_endpoint'\n  | 'registry_extension_netrc'\n  | 'secret_extension_endpoint'\n  | 'secret_extension_netrc'\n>;\n\nexport interface RepoPermissions {\n  pull: boolean;\n  push: boolean;\n  admin: boolean;\n  synced: number;\n}\n\nexport interface RepoTrusted {\n  network: boolean;\n  volumes: boolean;\n  security: boolean;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/secret.ts",
    "content": "import type { WebhookEvents } from './webhook';\n\nexport interface Secret {\n  id: string;\n  repo_id: number;\n  org_id: number;\n  name: string;\n  value: string;\n  events: WebhookEvents[];\n  images: string[];\n  note: string;\n}\n"
  },
  {
    "path": "web/src/lib/api/types/user.ts",
    "content": "// The user account.\nexport interface User {\n  id: number;\n  // The unique identifier for the account.\n\n  forge_id: number;\n  // The unique identifier of the forge the account belongs to.\n\n  forge_remote_id: string;\n  // The unique identifier of user at the remote forge.\n\n  login: string;\n  // The login name for the account.\n\n  email: string;\n  // The email address for the account.\n\n  avatar_url: string;\n  // The url for the avatar image.\n\n  admin: boolean;\n  // Whether the account has administrative privileges.\n\n  active: boolean;\n  // Whether the account is currently active.\n\n  org_id: number;\n  // The ID of the org assigned to the user.\n}\n"
  },
  {
    "path": "web/src/lib/api/types/webhook.ts",
    "content": "/* eslint-disable no-unused-vars */\nexport enum WebhookEvents {\n  Push = 'push',\n  Tag = 'tag',\n  Release = 'release',\n  PullRequest = 'pull_request',\n  PullRequestClosed = 'pull_request_closed',\n  PullRequestMetadata = 'pull_request_metadata',\n  Deploy = 'deployment',\n  Cron = 'cron',\n  Manual = 'manual',\n}\n/* eslint-enable */\n"
  },
  {
    "path": "web/src/lib/utils/index.ts",
    "content": "import { toRaw } from 'vue';\n\nexport function debounce<T extends unknown[]>(fn: (...args: T) => void, delay: number): (...args: T) => void {\n  let timer: ReturnType<typeof setTimeout>;\n  return (...args: T) => {\n    clearTimeout(timer);\n    timer = setTimeout(fn, delay, ...args);\n  };\n}\n\nexport function deepClone<T>(value: T): T {\n  return JSON.parse(JSON.stringify(toRaw(value))) as T;\n}\n\nexport function escapeHtml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#x27;');\n}\n"
  },
  {
    "path": "web/src/lib/utils.test.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { escapeHtml } from './utils';\n\ndescribe('escapeHtml', () => {\n  it('should return plain text unchanged', () => {\n    expect(escapeHtml('hello world')).toBe('hello world');\n  });\n\n  it('should return empty string unchanged', () => {\n    expect(escapeHtml('')).toBe('');\n  });\n\n  it('should escape HTML tags', () => {\n    expect(escapeHtml('<b>bold</b>')).toBe('&lt;b&gt;bold&lt;/b&gt;');\n    expect(escapeHtml('<script>alert(\"xss\")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');\n  });\n\n  it('should escape ampersands', () => {\n    expect(escapeHtml('foo & bar')).toBe('foo &amp; bar');\n    expect(escapeHtml('a&&b')).toBe('a&amp;&amp;b');\n  });\n\n  it('should escape double quotes', () => {\n    expect(escapeHtml('say \"hello\"')).toBe('say &quot;hello&quot;');\n  });\n\n  it('should escape single quotes', () => {\n    expect(escapeHtml(\"it's\")).toBe('it&#x27;s');\n  });\n\n  it('should escape greater-than signs', () => {\n    expect(escapeHtml('a > b')).toBe('a &gt; b');\n  });\n\n  it('should escape mixed content', () => {\n    expect(escapeHtml(`<a href=\"foo\">it's & that's <b>all</b>`)).toBe(\n      '&lt;a href=&quot;foo&quot;&gt;it&#x27;s &amp; that&#x27;s &lt;b&gt;all&lt;/b&gt;',\n    );\n  });\n\n  it('should escape already-escaped ampersands', () => {\n    expect(escapeHtml('&amp;')).toBe('&amp;amp;');\n  });\n});\n"
  },
  {
    "path": "web/src/main.ts",
    "content": "import '~/compositions/useFavicon';\nimport '~/tailwind.css';\nimport '~/style.css';\n\nimport { createPinia } from 'pinia';\nimport { createApp } from 'vue';\n\nimport App from '~/App.vue';\nimport useEvents from '~/compositions/useEvents';\nimport { i18n } from '~/compositions/useI18n';\nimport { notifications } from '~/compositions/useNotifications';\nimport router from '~/router';\n\n// eslint-disable-next-line ts/no-unsafe-argument\nconst app = createApp(App);\n\napp.use(router);\napp.use(notifications);\napp.use(i18n);\n\napp.use(createPinia());\napp.mount('#app');\n\nuseEvents();\n"
  },
  {
    "path": "web/src/router.ts",
    "content": "import type { Component } from 'vue';\nimport { createRouter, createWebHistory } from 'vue-router';\nimport type { RouteRecordRaw } from 'vue-router';\n\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useConfig from '~/compositions/useConfig';\nimport useUserConfig from '~/compositions/useUserConfig';\n\ndeclare module 'vue-router' {\n  interface RouteMeta {\n    authentication?: 'required' | 'guest-only';\n    repoHeader?: true;\n    layout?: 'default' | 'blank';\n  }\n}\n\nconst routes: RouteRecordRaw[] = [\n  {\n    path: '/',\n    name: 'home',\n    redirect: { name: 'repos' },\n  },\n  {\n    path: '/repos',\n    component: (): Component => import('~/views/RouterView.vue'),\n    children: [\n      {\n        path: '',\n        name: 'repos',\n        component: (): Component => import('~/views/Repos.vue'),\n        meta: { authentication: 'required' },\n      },\n      {\n        path: 'add',\n        name: 'repo-add',\n        component: (): Component => import('~/views/RepoAdd.vue'),\n        meta: { authentication: 'required' },\n      },\n      {\n        path: ':repoId',\n        component: (): Component => import('~/views/repo/RepoWrapper.vue'),\n        props: true,\n        children: [\n          {\n            path: '',\n            name: 'repo',\n            component: (): Component => import('~/views/repo/RepoPipelines.vue'),\n            meta: { repoHeader: true },\n          },\n          {\n            path: 'branches',\n            meta: { repoHeader: true },\n            children: [\n              {\n                path: '',\n                name: 'repo-branches',\n                component: (): Component => import('~/views/repo/RepoBranches.vue'),\n              },\n              {\n                path: ':branch',\n                name: 'repo-branch',\n                component: (): Component => import('~/views/repo/RepoBranch.vue'),\n                props: (route) => ({ branch: route.params.branch }),\n              },\n            ],\n          },\n\n          {\n            path: 'pull-requests',\n            meta: { repoHeader: true },\n            children: [\n              {\n                path: '',\n                name: 'repo-pull-requests',\n                component: (): Component => import('~/views/repo/RepoPullRequests.vue'),\n              },\n              {\n                path: ':pullRequest',\n                name: 'repo-pull-request',\n                component: (): Component => import('~/views/repo/RepoPullRequest.vue'),\n                props: (route) => ({ pullRequest: route.params.pullRequest }),\n              },\n            ],\n          },\n          {\n            path: 'pipeline/:pipelineId',\n            component: (): Component => import('~/views/repo/pipeline/PipelineWrapper.vue'),\n            props: true,\n            children: [\n              {\n                path: ':stepId?',\n                name: 'repo-pipeline',\n                component: (): Component => import('~/views/repo/pipeline/Pipeline.vue'),\n                props: true,\n              },\n              {\n                path: 'changed-files',\n                name: 'repo-pipeline-changed-files',\n                component: (): Component => import('~/views/repo/pipeline/PipelineChangedFiles.vue'),\n              },\n              {\n                path: 'config',\n                name: 'repo-pipeline-config',\n                component: (): Component => import('~/views/repo/pipeline/PipelineConfig.vue'),\n                props: true,\n              },\n              {\n                path: 'errors',\n                name: 'repo-pipeline-errors',\n                component: (): Component => import('~/views/repo/pipeline/PipelineErrors.vue'),\n                props: true,\n              },\n              {\n                path: 'debug',\n                name: 'repo-pipeline-debug',\n                component: (): Component => import('~/views/repo/pipeline/PipelineDebug.vue'),\n                props: true,\n                meta: { authentication: 'required' },\n              },\n            ],\n          },\n          {\n            path: 'settings',\n            component: (): Component => import('~/views/repo/settings/RepoSettings.vue'),\n            meta: { authentication: 'required' },\n            props: true,\n            children: [\n              {\n                path: '',\n                name: 'repo-settings',\n                component: (): Component => import('~/views/repo/settings/General.vue'),\n                props: true,\n              },\n              {\n                path: 'secrets',\n                name: 'repo-settings-secrets',\n                component: (): Component => import('~/views/repo/settings/Secrets.vue'),\n                props: true,\n              },\n              {\n                path: 'registries',\n                name: 'repo-settings-registries',\n                component: (): Component => import('~/views/repo/settings/Registries.vue'),\n                props: true,\n              },\n              {\n                path: 'crons',\n                name: 'repo-settings-crons',\n                component: (): Component => import('~/views/repo/settings/Crons.vue'),\n                props: true,\n              },\n              {\n                path: 'badge',\n                name: 'repo-settings-badge',\n                component: (): Component => import('~/views/repo/settings/Badge.vue'),\n                props: true,\n              },\n              {\n                path: 'actions',\n                name: 'repo-settings-actions',\n                component: (): Component => import('~/views/repo/settings/Actions.vue'),\n                props: true,\n              },\n              {\n                path: 'extensions',\n                name: 'repo-settings-extensions',\n                component: (): Component => import('~/views/repo/settings/Extensions.vue'),\n                props: true,\n              },\n            ],\n          },\n          {\n            path: 'manual',\n            name: 'repo-manual',\n            component: (): Component => import('~/views/repo/RepoManualPipeline.vue'),\n            meta: { authentication: 'required', repoHeader: true },\n          },\n        ],\n      },\n      {\n        path: ':repoOwner/:repoName/:pathMatch(.*)*',\n        component: (): Component => import('~/views/repo/RepoDeprecatedRedirect.vue'),\n        props: true,\n      },\n    ],\n  },\n  {\n    path: '/orgs/:orgId',\n    component: (): Component => import('~/views/org/OrgWrapper.vue'),\n    props: true,\n    children: [\n      {\n        path: '',\n        name: 'org',\n        component: (): Component => import('~/views/org/OrgRepos.vue'),\n        props: true,\n      },\n      {\n        path: 'settings',\n        component: (): Component => import('~/views/org/settings/OrgSettingsWrapper.vue'),\n        meta: { authentication: 'required' },\n        props: true,\n        children: [\n          {\n            path: '',\n            name: 'org-settings',\n            redirect: { name: 'org-settings-secrets' },\n          },\n          {\n            path: 'secrets',\n            name: 'org-settings-secrets',\n            component: (): Component => import('~/views/org/settings/OrgSecrets.vue'),\n            props: true,\n          },\n          {\n            path: 'registries',\n            name: 'org-settings-registries',\n            component: (): Component => import('~/views/org/settings/OrgRegistries.vue'),\n            props: true,\n          },\n          {\n            path: 'agents',\n            name: 'org-settings-agents',\n            component: (): Component => import('~/views/org/settings/OrgAgents.vue'),\n            props: true,\n          },\n        ],\n      },\n    ],\n  },\n  {\n    path: '/org/:orgName/:pathMatch(.*)*',\n    component: (): Component => import('~/views/org/OrgDeprecatedRedirect.vue'),\n    props: true,\n  },\n  {\n    path: '/admin',\n    component: (): Component => import('~/views/admin/AdminSettingsWrapper.vue'),\n    meta: { authentication: 'required' },\n    children: [\n      {\n        path: '',\n        name: 'admin-settings',\n        component: (): Component => import('~/views/admin/AdminInfo.vue'),\n      },\n      {\n        path: 'secrets',\n        name: 'admin-settings-secrets',\n        component: (): Component => import('~/views/admin/AdminSecrets.vue'),\n      },\n      {\n        path: 'registries',\n        name: 'admin-settings-registries',\n        component: (): Component => import('~/views/admin/AdminRegistries.vue'),\n      },\n      {\n        path: 'repos',\n        name: 'admin-settings-repos',\n        component: (): Component => import('~/views/admin/AdminRepos.vue'),\n      },\n      {\n        path: 'users',\n        name: 'admin-settings-users',\n        component: (): Component => import('~/views/admin/AdminUsers.vue'),\n      },\n      {\n        path: 'orgs',\n        name: 'admin-settings-orgs',\n        component: (): Component => import('~/views/admin/AdminOrgs.vue'),\n      },\n      {\n        path: 'agents',\n        name: 'admin-settings-agents',\n        component: (): Component => import('~/views/admin/AdminAgents.vue'),\n      },\n      {\n        path: 'queue',\n        name: 'admin-settings-queue',\n        component: (): Component => import('~/views/admin/AdminQueue.vue'),\n      },\n      {\n        path: 'forges',\n        component: (): Component => import('~/views/RouterView.vue'),\n        props: true,\n        children: [\n          {\n            path: '',\n            name: 'admin-settings-forges',\n            component: (): Component => import('~/views/admin/forges/AdminForges.vue'),\n          },\n          {\n            path: ':forgeId',\n            name: 'admin-settings-forge',\n            component: (): Component => import('~/views/admin/forges/AdminForge.vue'),\n            props: true,\n          },\n          {\n            path: 'create',\n            name: 'admin-settings-forge-create',\n            component: (): Component => import('~/views/admin/forges/AdminForgeCreate.vue'),\n          },\n        ],\n      },\n    ],\n  },\n\n  {\n    path: '/user',\n    component: (): Component => import('~/views/user/UserWrapper.vue'),\n    meta: { authentication: 'required' },\n    children: [\n      {\n        path: '',\n        name: 'user',\n        component: (): Component => import('~/views/user/UserGeneral.vue'),\n      },\n      {\n        path: 'secrets',\n        name: 'user-secrets',\n        component: (): Component => import('~/views/user/UserSecrets.vue'),\n      },\n      {\n        path: 'registries',\n        name: 'user-registries',\n        component: (): Component => import('~/views/user/UserRegistries.vue'),\n      },\n      {\n        path: 'cli-and-api',\n        name: 'user-cli-and-api',\n        component: (): Component => import('~/views/user/UserCLIAndAPI.vue'),\n      },\n      {\n        path: 'agents',\n        name: 'user-agents',\n        component: (): Component => import('~/views/user/UserAgents.vue'),\n      },\n    ],\n  },\n  {\n    path: '/login',\n    name: 'login',\n    component: (): Component => import('~/views/Login.vue'),\n    meta: { layout: 'blank', authentication: 'guest-only' },\n  },\n  {\n    path: '/cli/auth',\n    component: (): Component => import('~/views/cli/Auth.vue'),\n    meta: { authentication: 'required' },\n  },\n\n  // TODO: deprecated routes => remove after some time\n  {\n    path: '/:ownerOrOrgId',\n    redirect: (route) => ({ name: 'org', params: route.params }),\n  },\n  {\n    path: '/:repoOwner/:repoName/:pathMatch(.*)*',\n    component: (): Component => import('~/views/repo/RepoDeprecatedRedirect.vue'),\n    props: true,\n  },\n\n  // not found handler\n  {\n    path: '/:pathMatch(.*)*',\n    name: 'not-found',\n    component: (): Component => import('~/views/NotFound.vue'),\n  },\n];\n\nconst { rootPath } = useConfig();\nconst router = createRouter({\n  history: createWebHistory(),\n  routes: routes.map((r) => ({ ...r, path: `${rootPath}${r.path}` })),\n});\n\nrouter.beforeEach(async (to, _, next) => {\n  const authenticationMode = to.matched.toReversed().find((record) => record.meta.authentication != null)\n    ?.meta.authentication;\n\n  const config = useUserConfig();\n  const { redirectUrl } = config.userConfig.value;\n\n  const { isAuthenticated } = useAuthentication();\n\n  // redirect to saved url when not on login page\n  if (redirectUrl !== '' && isAuthenticated && authenticationMode !== 'guest-only') {\n    config.setUserConfig('redirectUrl', '');\n    next(redirectUrl);\n    return;\n  }\n\n  if (authenticationMode === 'required' && !isAuthenticated) {\n    config.setUserConfig('redirectUrl', to.fullPath);\n    next({ name: 'login' });\n    return;\n  }\n\n  if (authenticationMode === 'guest-only' && isAuthenticated) {\n    next({ name: 'home' });\n    return;\n  }\n\n  next();\n});\n\nexport default router;\n"
  },
  {
    "path": "web/src/store/pipelines.ts",
    "content": "import { defineStore } from 'pinia';\nimport { computed, reactive, ref } from 'vue';\nimport type { Ref } from 'vue';\n\nimport useApiClient from '~/compositions/useApiClient';\nimport type { Pipeline, PipelineFeed, PipelineWorkflow } from '~/lib/api/types';\nimport { useRepoStore } from '~/store/repos';\n\n/**\n * Compare two pipelines by creation timestamp.\n * @param {object} a - A pipeline.\n * @param {object} b - A pipeline.\n * @returns {number} 0 if created at the same time, < 0 if b was create before a, > 0 otherwise\n */\nfunction comparePipelines(a: Pipeline, b: Pipeline): number {\n  return (b.created || -1) - (a.created || -1);\n}\n\n/**\n * Compare two pipelines by the status.\n * Giving pending, running, or started higher priority than other status\n * @param {object} a - A pipeline.\n * @param {object} b - A pipeline.\n * @returns {number} 0 if status same priority, < 0 if b has higher priority, > 0 otherwise\n */\nfunction comparePipelinesWithStatus(a: Pipeline, b: Pipeline): number {\n  const bPriority = ['pending', 'running', 'started'].includes(b.status) ? 1 : 0;\n  const aPriority = ['pending', 'running', 'started'].includes(a.status) ? 1 : 0;\n  return bPriority - aPriority || comparePipelines(a, b);\n}\n\nexport const usePipelineStore = defineStore('pipelines', () => {\n  const apiClient = useApiClient();\n  const repoStore = useRepoStore();\n\n  const pipelines: Map<number, Map<number, Pipeline>> = reactive(new Map());\n  const loading = ref(false);\n\n  function setPipeline(repoId: number, pipeline: Pipeline) {\n    const repoPipelines = pipelines.get(repoId) ?? new Map<number, Pipeline>();\n    repoPipelines.set(pipeline.number, {\n      ...repoPipelines.get(pipeline.number),\n      ...pipeline,\n    });\n\n    // Update last pipeline number for the repo\n    const repo = repoStore.repos.get(repoId);\n    if (repo?.last_pipeline_number !== undefined && repo.last_pipeline_number < pipeline.number) {\n      repo.last_pipeline_number = pipeline.number;\n      repoStore.setRepo(repo);\n    }\n\n    pipelines.set(repoId, repoPipelines);\n  }\n\n  function getRepoPipelines(repoId: Ref<number>) {\n    return computed(() => [...(pipelines.get(repoId.value)?.values() ?? [])].sort(comparePipelines));\n  }\n\n  function getPipeline(repoId: Ref<number>, _pipelineNumber: Ref<string | number>) {\n    return computed(() => {\n      if (typeof _pipelineNumber.value === 'string') {\n        const pipelineNumber = Number.parseInt(_pipelineNumber.value, 10);\n        return pipelines.get(repoId.value)?.get(pipelineNumber);\n      }\n\n      return pipelines.get(repoId.value)?.get(_pipelineNumber.value);\n    });\n  }\n\n  function setWorkflow(repoId: number, pipelineNumber: number, workflow: PipelineWorkflow) {\n    const pipeline = getPipeline(ref(repoId), ref(pipelineNumber.toString())).value;\n    if (!pipeline) {\n      throw new Error(\"Can't find pipeline\");\n    }\n\n    if (!pipeline.workflows) {\n      pipeline.workflows = [];\n    }\n\n    pipeline.workflows = [...pipeline.workflows.filter((p) => p.pid !== workflow.pid), workflow];\n    setPipeline(repoId, pipeline);\n  }\n\n  const perPage = 50;\n  const hasMore = ref(false);\n\n  async function loadRepoPipelines(repoId: number, page?: number) {\n    loading.value = true;\n    const _pipelines = await apiClient.getPipelineList(repoId, { page, perPage });\n    _pipelines.forEach((pipeline) => {\n      setPipeline(repoId, pipeline);\n    });\n    hasMore.value = _pipelines.length >= perPage;\n    loading.value = false;\n  }\n\n  async function loadPipeline(repoId: number, pipelinesNumber: number) {\n    loading.value = true;\n    const pipeline = await apiClient.getPipeline(repoId, pipelinesNumber);\n    setPipeline(repoId, pipeline);\n    loading.value = false;\n  }\n\n  const pipelineFeed = computed(() =>\n    [...pipelines.entries()]\n      .reduce<PipelineFeed[]>((acc, [_repoId, repoPipelines]) => {\n        const repoPipelinesArray = Array.from(repoPipelines.entries(), ([_pipelineNumber, pipeline]) => ({\n          ...pipeline,\n          repo_id: _repoId,\n          number: _pipelineNumber,\n        }));\n        return [...acc, ...repoPipelinesArray];\n      }, [])\n      .sort(comparePipelinesWithStatus)\n      .filter((pipeline) => repoStore.ownedRepoIds.includes(pipeline.repo_id)),\n  );\n\n  const activePipelines = computed(() =>\n    pipelineFeed.value.filter((pipeline) => ['pending', 'running', 'started'].includes(pipeline.status)),\n  );\n\n  async function loadPipelineFeed() {\n    await repoStore.loadRepos();\n\n    loading.value = true;\n    const _pipelines = await apiClient.getPipelineFeed();\n    _pipelines.forEach((pipeline) => {\n      setPipeline(pipeline.repo_id, pipeline);\n    });\n    loading.value = false;\n  }\n\n  return {\n    pipelines,\n    loading,\n    setPipeline,\n    setWorkflow,\n    getRepoPipelines,\n    getPipeline,\n    loadRepoPipelines,\n    loadPipeline,\n    hasMore,\n    activePipelines,\n    pipelineFeed,\n    loadPipelineFeed,\n  };\n});\n"
  },
  {
    "path": "web/src/store/repos.ts",
    "content": "import { defineStore } from 'pinia';\nimport { computed, reactive, ref } from 'vue';\nimport type { Ref } from 'vue';\n\nimport useApiClient from '~/compositions/useApiClient';\nimport useConfig from '~/compositions/useConfig';\nimport { usePaginate } from '~/compositions/usePaginate';\nimport type { Repo } from '~/lib/api/types';\n\nimport { usePipelineStore } from './pipelines';\n\nexport const useRepoStore = defineStore('repos', () => {\n  const apiClient = useApiClient();\n  const pipelineStore = usePipelineStore();\n\n  const repos: Map<number, Repo> = reactive(new Map());\n  const ownedRepoIds = ref<number[]>([]);\n\n  const ownedRepos = computed(() =>\n    [...repos.entries()].filter(([repoId]) => ownedRepoIds.value.includes(repoId)).map(([, repo]) => repo),\n  );\n\n  function getRepo(repoId: Ref<number>) {\n    return computed(() => repos.get(repoId.value));\n  }\n\n  function setRepo(repo: Repo) {\n    repos.set(repo.id, {\n      ...repos.get(repo.id),\n      ...repo,\n    });\n  }\n\n  async function loadRepo(repoId: number) {\n    const repo = await apiClient.getRepo(repoId);\n    setRepo(repo);\n    return repo;\n  }\n\n  async function loadRepos() {\n    const _ownedRepos = await apiClient.getRepoList();\n\n    _ownedRepos.forEach((repo) => {\n      if (repo.last_pipeline) {\n        pipelineStore.setPipeline(repo.id, repo.last_pipeline);\n        repo.last_pipeline_number = repo.last_pipeline.number;\n      }\n      setRepo(repo);\n    });\n\n    ownedRepoIds.value = _ownedRepos.map((repo) => repo.id);\n\n    // If the current user is a system admin, also hydrate the store with all repos (paginated)\n    const { user } = useConfig();\n    const isSystemAdmin = !!user?.admin;\n    if (isSystemAdmin) {\n      const allRepos = await usePaginate<Repo>(async (page: number) =>\n        apiClient.getAllRepos({ page }).then((r) => r ?? []),\n      );\n      allRepos.forEach((repo) => {\n        if (repo.last_pipeline) {\n          pipelineStore.setPipeline(repo.id, repo.last_pipeline);\n          repo.last_pipeline_number = repo.last_pipeline.number;\n        }\n        setRepo(repo);\n      });\n    }\n  }\n\n  return {\n    repos,\n    ownedRepos,\n    ownedRepoIds,\n    getRepo,\n    setRepo,\n    loadRepo,\n    loadRepos,\n  };\n});\n"
  },
  {
    "path": "web/src/style/console.css",
    "content": ".ansi-black-fg {\n  color: #374151;\n}\n.ansi-red-fg {\n  color: #cc0000;\n}\n.ansi-green-fg {\n  color: #4e9a06;\n}\n.ansi-yellow-fg {\n  color: #c4a000;\n}\n.ansi-blue-fg {\n  color: #729fcf;\n}\n.ansi-magenta-fg {\n  color: #75507b;\n}\n.ansi-cyan-fg {\n  color: #06989a;\n}\n.ansi-white-fg {\n  color: #d3d7cf;\n}\n.ansi-bright-black-fg {\n  color: #555753;\n}\n.ansi-bright-red-fg {\n  color: #ef2929;\n}\n.ansi-bright-green-fg {\n  color: #8ae234;\n}\n.ansi-bright-yellow-fg {\n  color: #fce94f;\n}\n.ansi-bright-blue-fg {\n  color: #32afff;\n}\n.ansi-bright-magenta-fg {\n  color: #ad7fa8;\n}\n.ansi-bright-cyan-fg {\n  color: #34e2e2;\n}\n.ansi-bright-white-fg {\n  color: #ffffff;\n}\n\n.ansi-black-bg {\n  background-color: #374151;\n}\n.ansi-red-bg {\n  background-color: #cc0000;\n}\n.ansi-green-bg {\n  background-color: #4e9a06;\n}\n.ansi-yellow-bg {\n  background-color: #c4a000;\n}\n.ansi-blue-bg {\n  background-color: #729fcf;\n}\n.ansi-magenta-bg {\n  background-color: #75507b;\n}\n.ansi-cyan-bg {\n  background-color: #06989a;\n}\n.ansi-white-bg {\n  background-color: #d3d7cf;\n}\n.ansi-bright-black-bg {\n  background-color: #555753;\n}\n.ansi-bright-red-bg {\n  background-color: #ef2929;\n}\n.ansi-bright-green-bg {\n  background-color: #8ae234;\n}\n.ansi-bright-yellow-bg {\n  background-color: #fce94f;\n}\n.ansi-bright-blue-bg {\n  background-color: #32afff;\n}\n.ansi-bright-magenta-bg {\n  background-color: #ad7fa8;\n}\n.ansi-bright-cyan-bg {\n  background-color: #34e2e2;\n}\n.ansi-bright-white-bg {\n  background-color: #ffffff;\n}\n\n.dark .ansi-black-fg {\n  color: #666666;\n}\n.dark .ansi-red-fg {\n  color: #ff7070;\n}\n.dark .ansi-green-fg {\n  color: #b0f986;\n}\n.dark .ansi-yellow-fg {\n  color: #c6c502;\n}\n.dark .ansi-blue-fg {\n  color: #8db7e0;\n}\n.dark .ansi-magenta-fg {\n  color: #f271fb;\n}\n.dark .ansi-cyan-fg {\n  color: #6bf7ff;\n}\n.dark .ansi-white-fg {\n  color: #9ca3af;\n}\n.dark .ansi-bright-black-fg {\n  color: #838887;\n}\n.dark .ansi-bright-red-fg {\n  color: #ff3333;\n}\n.dark .ansi-bright-green-fg {\n  color: #00ff00;\n}\n.dark .ansi-bright-yellow-fg {\n  color: #fffc67;\n}\n.dark .ansi-bright-blue-fg {\n  color: #6871ff;\n}\n.dark .ansi-bright-magenta-fg {\n  color: #ff76ff;\n}\n.dark .ansi-bright-cyan-fg {\n  color: #60fcff;\n}\n.dark .ansi-bright-white-fg {\n  color: #e6e3e3;\n}\n\n.dark .ansi-black-bg {\n  background-color: #666666;\n}\n.dark .ansi-red-bg {\n  background-color: #ff7070;\n}\n.dark .ansi-green-bg {\n  background-color: #b0f986;\n}\n.dark .ansi-yellow-bg {\n  background-color: #c6c502;\n}\n.dark .ansi-blue-bg {\n  background-color: #8db7e0;\n}\n.dark .ansi-magenta-bg {\n  background-color: #f271fb;\n}\n.dark .ansi-cyan-bg {\n  background-color: #6bf7ff;\n}\n.dark .ansi-white-bg {\n  background-color: #9ca3af;\n}\n.dark .ansi-bright-black-bg {\n  background-color: #838887;\n}\n.dark .ansi-bright-red-bg {\n  background-color: #ff3333;\n}\n.dark .ansi-bright-green-bg {\n  background-color: #00ff00;\n}\n.dark .ansi-bright-yellow-bg {\n  background-color: #fffc67;\n}\n.dark .ansi-bright-blue-bg {\n  background-color: #6871ff;\n}\n.dark .ansi-bright-magenta-bg {\n  background-color: #ff76ff;\n}\n.dark .ansi-bright-cyan-bg {\n  background-color: #60fcff;\n}\n.dark .ansi-bright-white-bg {\n  background-color: #e6e3e3;\n}\n"
  },
  {
    "path": "web/src/style/prism.css",
    "content": "/* cSpell:ignore atrule hexcode */\n.token.atrule {\n  color: #7c4dff;\n}\n\n.token.attr-name {\n  color: #39adb5;\n}\n\n.token.attr-value {\n  color: #f6a434;\n}\n\n.token.attribute {\n  color: #f6a434;\n}\n\n.token.boolean {\n  color: #7c4dff;\n}\n\n.token.builtin {\n  color: #39adb5;\n}\n\n.token.cdata {\n  color: #39adb5;\n}\n\n.token.char {\n  color: #39adb5;\n}\n\n.token.class {\n  color: #39adb5;\n}\n\n.token.class-name {\n  color: #6182b8;\n}\n\n.token.comment {\n  color: #aabfc9;\n}\n\n.token.constant {\n  color: #7c4dff;\n}\n\n.token.deleted {\n  color: #e53935;\n}\n\n.token.doctype {\n  color: #aabfc9;\n}\n\n.token.entity {\n  color: #e53935;\n}\n\n.token.function {\n  color: #7c4dff;\n}\n\n.token.hexcode {\n  color: #f76d47;\n}\n\n.token.id {\n  color: #7c4dff;\n  font-weight: bold;\n}\n\n.token.important {\n  color: #7c4dff;\n  font-weight: bold;\n}\n\n.token.inserted {\n  color: #39adb5;\n}\n\n.token.keyword {\n  color: #7c4dff;\n}\n\n.token.number {\n  color: #f76d47;\n}\n\n.token.operator {\n  color: #39adb5;\n}\n\n.token.prolog {\n  color: #aabfc9;\n}\n\n.token.property {\n  color: #39adb5;\n}\n\n.token.pseudo-class {\n  color: #f6a434;\n}\n\n.token.pseudo-element {\n  color: #f6a434;\n}\n\n.token.punctuation {\n  color: #39adb5;\n}\n\n.token.regex {\n  color: #6182b8;\n}\n\n.token.selector {\n  color: #e53935;\n}\n\n.token.string {\n  color: #f6a434;\n}\n\n.token.symbol {\n  color: #7c4dff;\n}\n\n.token.tag {\n  color: #e53935;\n}\n\n.token.unit {\n  color: #f76d47;\n}\n\n.token.url {\n  color: #e53935;\n}\n\n.token.variable {\n  color: #e53935;\n}\n\n.dark .token.atrule {\n  color: #c792ea;\n}\n\n.dark .token.attr-name {\n  color: #ffcb6b;\n}\n\n.dark .token.attr-value {\n  color: #a5e844;\n}\n\n.dark .token.attribute {\n  color: #a5e844;\n}\n\n.dark .token.boolean {\n  color: #c792ea;\n}\n\n.dark .token.builtin {\n  color: #ffcb6b;\n}\n\n.dark .token.cdata {\n  color: #80cbc4;\n}\n\n.dark .token.char {\n  color: #80cbc4;\n}\n\n.dark .token.class {\n  color: #ffcb6b;\n}\n\n.dark .token.class-name {\n  color: #f2ff00;\n}\n\n.dark .token.comment {\n  color: #616161;\n}\n\n.dark .token.constant {\n  color: #c792ea;\n}\n\n.dark .token.deleted {\n  color: #ff6666;\n}\n\n.dark .token.doctype {\n  color: #616161;\n}\n\n.dark .token.entity {\n  color: #ff6666;\n}\n\n.dark .token.function {\n  color: #c792ea;\n}\n\n.dark .token.hexcode {\n  color: #f2ff00;\n}\n\n.dark .token.id {\n  color: #c792ea;\n  font-weight: bold;\n}\n\n.dark .token.important {\n  color: #c792ea;\n  font-weight: bold;\n}\n\n.dark .token.inserted {\n  color: #80cbc4;\n}\n\n.dark .token.keyword {\n  color: #c792ea;\n}\n\n.dark .token.number {\n  color: #fd9170;\n}\n\n.dark .token.operator {\n  color: #89ddff;\n}\n\n.dark .token.prolog {\n  color: #616161;\n}\n\n.dark .token.property {\n  color: #80cbc4;\n}\n\n.dark .token.pseudo-class {\n  color: #a5e844;\n}\n\n.dark .token.pseudo-element {\n  color: #a5e844;\n}\n\n.dark .token.punctuation {\n  color: #89ddff;\n}\n\n.dark .token.regex {\n  color: #f2ff00;\n}\n\n.dark .token.selector {\n  color: #ff6666;\n}\n\n.dark .token.string {\n  color: #a5e844;\n}\n\n.dark .token.symbol {\n  color: #c792ea;\n}\n\n.dark .token.tag {\n  color: #ff6666;\n}\n\n.dark .token.unit {\n  color: #fd9170;\n}\n\n.dark .token.url {\n  color: #ff6666;\n}\n\n.dark .token.variable {\n  color: #ff6666;\n}\n"
  },
  {
    "path": "web/src/style.css",
    "content": "@reference \"./tailwind.css\";\n\n:root,\n:root[data-theme='light'] {\n  --wp-background-100: var(--color-white);\n  --wp-background-200: var(--color-gray-50);\n  --wp-background-300: var(--color-gray-200);\n  --wp-background-400: var(--color-gray-300);\n  --wp-background-500: var(--color-gray-400);\n\n  --wp-text-100: var(--color-gray-600);\n  --wp-text-200: var(--color-gray-700);\n  --wp-text-alt-100: var(--color-gray-500);\n\n  --wp-primary-100: var(--color-int-wp-primary-300);\n  --wp-primary-200: var(--color-int-wp-primary-400);\n  --wp-primary-300: var(--color-int-wp-primary-500);\n  --wp-primary-text-100: var(--color-white);\n\n  --wp-control-neutral-100: var(--color-gray-100);\n  --wp-control-neutral-200: var(--color-gray-200);\n  --wp-control-neutral-300: var(--color-gray-300);\n\n  --wp-control-info-100: var(--color-int-wp-control-info-100);\n  --wp-control-info-200: var(--color-int-wp-control-info-200);\n  --wp-control-info-300: var(--color-int-wp-control-info-300);\n\n  --wp-control-ok-100: var(--color-int-wp-control-ok-100);\n  --wp-control-ok-200: var(--color-int-wp-control-ok-200);\n  --wp-control-ok-300: var(--color-int-wp-control-ok-300);\n\n  --wp-error-100: var(--color-int-wp-error-100);\n  --wp-error-200: var(--color-int-wp-error-200);\n  --wp-error-300: var(--color-int-wp-error-300);\n\n  --wp-state-neutral-100: var(--color-int-wp-state-neutral-100);\n  --wp-state-ok-100: var(--color-int-wp-state-ok-100);\n  --wp-state-info-100: var(--color-int-wp-state-info-100);\n  --wp-state-warn-100: var(--color-int-wp-state-warn-100);\n\n  --wp-hint-warn-100: var(--color-int-wp-hint-warn-100);\n  --wp-hint-warn-200: var(--color-int-wp-hint-warn-200);\n\n  --wp-code-inline-100: var(--color-gray-200);\n  --wp-code-inline-200: var(--color-gray-200);\n\n  --wp-code-inline-text-100: var(--color-gray-600);\n\n  --wp-code-100: var(--color-int-wp-secondary-300);\n  --wp-code-200: var(--color-int-wp-secondary-600);\n  --wp-code-300: var(--color-int-wp-secondary-600);\n\n  --wp-code-text-100: var(--color-gray-200);\n  --wp-code-text-alt-100: var(--color-gray-300);\n\n  --wp-link-100: var(--color-blue-600);\n  --wp-link-200: var(--color-blue-700);\n}\n\n:root[data-theme='dark'] {\n  --wp-background-100: var(--color-int-wp-secondary-200);\n  --wp-background-200: var(--color-int-wp-secondary-400);\n  --wp-background-300: var(--color-int-wp-secondary-500);\n  --wp-background-400: var(--color-int-wp-secondary-600);\n  --wp-background-500: var(--color-int-wp-secondary-800);\n\n  --wp-text-100: var(--color-gray-300);\n  --wp-text-200: var(--color-gray-200);\n  --wp-text-alt-100: var(--color-gray-400);\n\n  --wp-primary-100: var(--color-int-wp-secondary-300);\n  --wp-primary-200: var(--color-int-wp-secondary-400);\n  --wp-primary-300: var(--color-int-wp-secondary-600);\n  --wp-primary-text-100: var(--color-gray-300);\n\n  --wp-control-neutral-100: var(--color-int-wp-secondary-400);\n  --wp-control-neutral-200: var(--color-int-wp-secondary-300);\n  --wp-control-neutral-300: var(--color-int-wp-secondary-200);\n\n  --wp-control-info-100: var(--color-int-wp-control-info-dark-100);\n  --wp-control-info-200: var(--color-int-wp-control-info-dark-200);\n  --wp-control-info-300: var(--color-int-wp-control-info-dark-300);\n\n  --wp-control-ok-100: var(--color-int-wp-control-ok-dark-100);\n  --wp-control-ok-200: var(--color-int-wp-control-ok-dark-200);\n  --wp-control-ok-300: var(--color-int-wp-control-ok-dark-300);\n\n  --wp-error-100: var(--color-int-wp-error-200);\n  --wp-error-200: var(--color-int-wp-error-300);\n  --wp-error-300: var(--color-int-wp-error-300);\n\n  --wp-state-neutral-100: var(--color-int-wp-state-neutral-100);\n  --wp-state-ok-100: var(--color-int-wp-state-ok-dark-100);\n  --wp-state-info-100: var(--color-int-wp-state-info-dark-100);\n  --wp-state-warn-100: var(--color-int-wp-state-warn-dark-100);\n\n  --wp-hint-warn-100: var(--color-int-wp-hint-warn-dark-100);\n  --wp-hint-warn-200: var(--color-int-wp-hint-warn-dark-200);\n\n  --wp-code-inline-100: var(--color-int-wp-secondary-700);\n  --wp-code-inline-200: var(--color-int-wp-secondary-300);\n\n  --wp-code-inline-text-100: var(--color-gray-300);\n\n  --wp-code-100: var(--color-int-wp-secondary-700); /* #222631 */\n  --wp-code-200: var(--color-int-wp-secondary-600); /* #2a2e3a */\n  --wp-code-300: var(--color-int-wp-secondary-800); /* #1B1F28 */\n\n  --wp-code-text-100: var(--color-gray-200);\n  --wp-code-text-alt-100: var(--color-gray-400);\n\n  --wp-link-100: var(--color-blue-400);\n  --wp-link-200: var(--color-blue-500);\n}\n\nhtml,\nbody,\n#app {\n  width: 100%;\n  height: 100%;\n}\n\n.vue-notification {\n  @apply rounded-md border-l-4 text-base;\n}\n\n.vue-notification .notification-title {\n  @apply text-base font-normal;\n}\n\n.vue-notification.success {\n  @apply !border-wp-error-200 !bg-wp-control-ok-100 !border-l;\n}\n\n.vue-notification.error {\n  @apply !border-wp-error-200 !bg-wp-error-100 !border-l;\n}\n\n*::-webkit-scrollbar {\n  @apply h-3 w-3 bg-transparent;\n}\n\n* {\n  scrollbar-width: thin;\n}\n\n*::-webkit-scrollbar-thumb {\n  transition: background 0.2s ease-in-out;\n  border: 3px solid transparent;\n  @apply dark:bg-wp-background-200 rounded-full bg-gray-200 bg-clip-content;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n  @apply dark:bg-wp-background-100 bg-gray-300;\n}\n\n*::-webkit-scrollbar-corner {\n  @apply bg-transparent;\n}\n\n.code-box {\n  @apply bg-wp-code-inline-100 text-wp-code-inline-text-100 rounded-md p-4 text-sm break-words;\n  white-space: pre-wrap;\n}\n\n.code-box-inline,\ncode:not(pre > code) {\n  @apply bg-wp-code-inline-100 text-wp-code-inline-text-100 rounded-md px-1.5 py-0.5;\n}\n\n.code-box-log {\n  @apply bg-wp-code-300 text-wp-code-text-100 p-4 break-words;\n  white-space: pre-wrap;\n}\n"
  },
  {
    "path": "web/src/tailwind.css",
    "content": "@import 'tailwindcss';\n\n@plugin '@tailwindcss/typography';\n\n@source './**/*.css';\n\n@custom-variant dark (&:is(.dark *));\n\n@theme {\n  --color-int-wp-primary-100: #8ad97f;\n  --color-int-wp-primary-200: #68c464;\n  --color-int-wp-primary-300: #4caf50;\n  --color-int-wp-primary-400: #369943;\n  --color-int-wp-primary-500: #248438;\n  --color-int-wp-primary-600: #166e30;\n\n  --color-int-wp-secondary-200: #434858;\n  --color-int-wp-secondary-300: #383c4a;\n  --color-int-wp-secondary-400: #303440;\n  --color-int-wp-secondary-500: #2d313d;\n  --color-int-wp-secondary-600: #2a2e3a;\n  --color-int-wp-secondary-700: #222631;\n  --color-int-wp-secondary-800: #1b1f28;\n\n  --color-int-wp-control-neutral-100: #fff;\n  --color-int-wp-control-neutral-200: #d1d5db;\n  --color-int-wp-control-neutral-300: #9ca3af;\n\n  --color-int-wp-control-info-100: #0e7490;\n  --color-int-wp-control-info-200: #155e75;\n  --color-int-wp-control-info-300: #164e63;\n\n  --color-int-wp-control-info-dark-100: #266778;\n  --color-int-wp-control-info-dark-200: #2a5360;\n  --color-int-wp-control-info-dark-300: #284651;\n\n  --color-int-wp-control-ok-100: #369943;\n  --color-int-wp-control-ok-200: #248438;\n  --color-int-wp-control-ok-300: #166e30;\n\n  --color-int-wp-control-ok-dark-100: #408f4b;\n  --color-int-wp-control-ok-dark-200: #2c7c3d;\n  --color-int-wp-control-ok-dark-300: #1d6733;\n\n  --color-int-wp-error-100: #b91c1c;\n  --color-int-wp-error-200: #991b1b;\n  --color-int-wp-error-300: #7f1d1d;\n\n  --color-int-wp-state-neutral-100: #4b5563;\n\n  --color-int-wp-state-ok-100: #16a34a;\n\n  --color-int-wp-state-ok-dark-100: #29904f;\n\n  --color-int-wp-state-info-100: #0891b2;\n\n  --color-int-wp-state-info-dark-100: #1b869f;\n\n  --color-int-wp-state-warn-100: #facc15;\n\n  --color-int-wp-state-warn-dark-100: #e2be2d;\n\n  --color-int-wp-hint-warn-100: #fef9c3;\n  --color-int-wp-hint-warn-200: #fde047;\n\n  --color-int-wp-hint-warn-dark-100: #c5ba7f;\n  --color-int-wp-hint-warn-dark-200: #a18e51;\n\n  --color-wp-background-100: var(--wp-background-100);\n  --color-wp-background-200: var(--wp-background-200);\n  --color-wp-background-300: var(--wp-background-300);\n  --color-wp-background-400: var(--wp-background-400);\n\n  --color-wp-text-100: var(--wp-text-100);\n  --color-wp-text-200: var(--wp-text-200);\n\n  --color-wp-text-alt-100: var(--wp-text-alt-100);\n\n  --color-wp-primary-100: var(--wp-primary-100);\n  --color-wp-primary-200: var(--wp-primary-200);\n  --color-wp-primary-300: var(--wp-primary-300);\n\n  --color-wp-primary-text-100: var(--wp-primary-text-100);\n\n  --color-wp-control-neutral-100: var(--wp-control-neutral-100);\n  --color-wp-control-neutral-200: var(--wp-control-neutral-200);\n  --color-wp-control-neutral-300: var(--wp-control-neutral-300);\n\n  --color-wp-control-info-100: var(--wp-control-info-100);\n  --color-wp-control-info-200: var(--wp-control-info-200);\n  --color-wp-control-info-300: var(--wp-control-info-300);\n\n  --color-wp-control-ok-100: var(--wp-control-ok-100);\n  --color-wp-control-ok-200: var(--wp-control-ok-200);\n  --color-wp-control-ok-300: var(--wp-control-ok-300);\n\n  --color-wp-error-100: var(--wp-error-100);\n  --color-wp-error-200: var(--wp-error-200);\n  --color-wp-error-300: var(--wp-error-300);\n\n  --color-wp-state-neutral-100: var(--wp-state-neutral-100);\n\n  --color-wp-state-ok-100: var(--wp-state-ok-100);\n\n  --color-wp-state-info-100: var(--wp-state-info-100);\n\n  --color-wp-state-warn-100: var(--wp-state-warn-100);\n\n  --color-wp-hint-warn-100: var(--wp-hint-warn-100);\n  --color-wp-hint-warn-200: var(--wp-hint-warn-200);\n\n  --color-wp-code-inline-100: var(--wp-code-inline-100);\n  --color-wp-code-inline-200: var(--wp-code-inline-200);\n\n  --color-wp-code-inline-text-100: var(--wp-code-inline-text-100);\n\n  --color-wp-code-100: var(--wp-code-100);\n  --color-wp-code-200: var(--wp-code-200);\n  --color-wp-code-300: var(--wp-code-300);\n\n  --color-wp-code-text-100: var(--wp-code-text-100);\n\n  --color-wp-code-text-alt-100: var(--wp-code-text-alt-100);\n\n  --color-wp-link-100: var(--wp-link-100);\n  --color-wp-link-200: var(--wp-link-200);\n\n  --spacing-sm: 24rem;\n  --spacing-md: 28rem;\n  --spacing-lg: 32rem;\n  --spacing-xl: 36rem;\n  --spacing-2xl: 42rem;\n  --spacing-3xl: 48rem;\n\n  --font-sans:\n    system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif;\n\n  --transition-property-height: max-height;\n\n  --stroke-int-wp-primary-100: #8ad97f;\n  --stroke-int-wp-primary-200: #68c464;\n  --stroke-int-wp-primary-300: #4caf50;\n  --stroke-int-wp-primary-400: #369943;\n  --stroke-int-wp-primary-500: #248438;\n  --stroke-int-wp-primary-600: #166e30;\n\n  --stroke-int-wp-secondary-200: #434858;\n  --stroke-int-wp-secondary-300: #383c4a;\n  --stroke-int-wp-secondary-400: #303440;\n  --stroke-int-wp-secondary-500: #2d313d;\n  --stroke-int-wp-secondary-600: #2a2e3a;\n  --stroke-int-wp-secondary-700: #222631;\n  --stroke-int-wp-secondary-800: #1b1f28;\n\n  --stroke-int-wp-control-neutral-100: #fff;\n  --stroke-int-wp-control-neutral-200: #d1d5db;\n  --stroke-int-wp-control-neutral-300: #9ca3af;\n\n  --stroke-int-wp-control-info-100: #0e7490;\n  --stroke-int-wp-control-info-200: #155e75;\n  --stroke-int-wp-control-info-300: #164e63;\n\n  --stroke-int-wp-control-info-dark-100: #266778;\n  --stroke-int-wp-control-info-dark-200: #2a5360;\n  --stroke-int-wp-control-info-dark-300: #284651;\n\n  --stroke-int-wp-control-ok-100: #369943;\n  --stroke-int-wp-control-ok-200: #248438;\n  --stroke-int-wp-control-ok-300: #166e30;\n\n  --stroke-int-wp-control-ok-dark-100: #408f4b;\n  --stroke-int-wp-control-ok-dark-200: #2c7c3d;\n  --stroke-int-wp-control-ok-dark-300: #1d6733;\n\n  --stroke-int-wp-error-100: #b91c1c;\n  --stroke-int-wp-error-200: #991b1b;\n  --stroke-int-wp-error-300: #7f1d1d;\n\n  --stroke-int-wp-state-neutral-100: #4b5563;\n\n  --stroke-int-wp-state-ok-100: #16a34a;\n\n  --stroke-int-wp-state-ok-dark-100: #29904f;\n\n  --stroke-int-wp-state-info-100: #0891b2;\n\n  --stroke-int-wp-state-info-dark-100: #1b869f;\n\n  --stroke-int-wp-state-warn-100: #facc15;\n\n  --stroke-int-wp-state-warn-dark-100: #e2be2d;\n\n  --stroke-int-wp-hint-warn-100: #fef9c3;\n  --stroke-int-wp-hint-warn-200: #fde047;\n\n  --stroke-int-wp-hint-warn-dark-100: #c5ba7f;\n  --stroke-int-wp-hint-warn-dark-200: #a18e51;\n\n  --stroke-wp-background-100: var(--wp-background-100);\n  --stroke-wp-background-200: var(--wp-background-200);\n  --stroke-wp-background-300: var(--wp-background-300);\n  --stroke-wp-background-400: var(--wp-background-400);\n\n  --stroke-wp-text-100: var(--wp-text-100);\n  --stroke-wp-text-200: var(--wp-text-200);\n\n  --stroke-wp-text-alt-100: var(--wp-text-alt-100);\n\n  --stroke-wp-primary-100: var(--wp-primary-100);\n  --stroke-wp-primary-200: var(--wp-primary-200);\n  --stroke-wp-primary-300: var(--wp-primary-300);\n\n  --stroke-wp-primary-text-100: var(--wp-primary-text-100);\n\n  --stroke-wp-control-neutral-100: var(--wp-control-neutral-100);\n  --stroke-wp-control-neutral-200: var(--wp-control-neutral-200);\n  --stroke-wp-control-neutral-300: var(--wp-control-neutral-300);\n\n  --stroke-wp-control-info-100: var(--wp-control-info-100);\n  --stroke-wp-control-info-200: var(--wp-control-info-200);\n  --stroke-wp-control-info-300: var(--wp-control-info-300);\n\n  --stroke-wp-control-ok-100: var(--wp-control-ok-100);\n  --stroke-wp-control-ok-200: var(--wp-control-ok-200);\n  --stroke-wp-control-ok-300: var(--wp-control-ok-300);\n\n  --stroke-wp-error-100: var(--wp-error-100);\n  --stroke-wp-error-200: var(--wp-error-200);\n  --stroke-wp-error-300: var(--wp-error-300);\n\n  --stroke-wp-state-neutral-100: var(--wp-state-neutral-100);\n\n  --stroke-wp-state-ok-100: var(--wp-state-ok-100);\n\n  --stroke-wp-state-info-100: var(--wp-state-info-100);\n\n  --stroke-wp-state-warn-100: var(--wp-state-warn-100);\n\n  --stroke-wp-hint-warn-100: var(--wp-hint-warn-100);\n  --stroke-wp-hint-warn-200: var(--wp-hint-warn-200);\n\n  --stroke-wp-code-inline-100: var(--wp-code-inline-100);\n  --stroke-wp-code-inline-200: var(--wp-code-inline-200);\n\n  --stroke-wp-code-inline-text-100: var(--wp-code-inline-text-100);\n\n  --stroke-wp-code-100: var(--wp-code-100);\n  --stroke-wp-code-200: var(--wp-code-200);\n  --stroke-wp-code-300: var(--wp-code-300);\n\n  --stroke-wp-code-text-100: var(--wp-code-text-100);\n\n  --stroke-wp-code-text-alt-100: var(--wp-code-text-alt-100);\n\n  --stroke-wp-link-100: var(--wp-link-100);\n  --stroke-wp-link-200: var(--wp-link-200);\n\n  --stroke-red-50: oklch(0.971 0.013 17.38);\n  --stroke-red-100: oklch(0.936 0.032 17.717);\n  --stroke-red-200: oklch(0.885 0.062 18.334);\n  --stroke-red-300: oklch(0.808 0.114 19.571);\n  --stroke-red-400: oklch(0.704 0.191 22.216);\n  --stroke-red-500: oklch(0.637 0.237 25.331);\n  --stroke-red-600: oklch(0.577 0.245 27.325);\n  --stroke-red-700: oklch(0.505 0.213 27.518);\n  --stroke-red-800: oklch(0.444 0.177 26.899);\n  --stroke-red-900: oklch(0.396 0.141 25.723);\n  --stroke-red-950: oklch(0.258 0.092 26.042);\n  --stroke-orange-50: oklch(0.98 0.016 73.684);\n  --stroke-orange-100: oklch(0.954 0.038 75.164);\n  --stroke-orange-200: oklch(0.901 0.076 70.697);\n  --stroke-orange-300: oklch(0.837 0.128 66.29);\n  --stroke-orange-400: oklch(0.75 0.183 55.934);\n  --stroke-orange-500: oklch(0.705 0.213 47.604);\n  --stroke-orange-600: oklch(0.646 0.222 41.116);\n  --stroke-orange-700: oklch(0.553 0.195 38.402);\n  --stroke-orange-800: oklch(0.47 0.157 37.304);\n  --stroke-orange-900: oklch(0.408 0.123 38.172);\n  --stroke-orange-950: oklch(0.266 0.079 36.259);\n  --stroke-amber-50: oklch(0.987 0.022 95.277);\n  --stroke-amber-100: oklch(0.962 0.059 95.617);\n  --stroke-amber-200: oklch(0.924 0.12 95.746);\n  --stroke-amber-300: oklch(0.879 0.169 91.605);\n  --stroke-amber-400: oklch(0.828 0.189 84.429);\n  --stroke-amber-500: oklch(0.769 0.188 70.08);\n  --stroke-amber-600: oklch(0.666 0.179 58.318);\n  --stroke-amber-700: oklch(0.555 0.163 48.998);\n  --stroke-amber-800: oklch(0.473 0.137 46.201);\n  --stroke-amber-900: oklch(0.414 0.112 45.904);\n  --stroke-amber-950: oklch(0.279 0.077 45.635);\n  --stroke-yellow-50: oklch(0.987 0.026 102.212);\n  --stroke-yellow-100: oklch(0.973 0.071 103.193);\n  --stroke-yellow-200: oklch(0.945 0.129 101.54);\n  --stroke-yellow-300: oklch(0.905 0.182 98.111);\n  --stroke-yellow-400: oklch(0.852 0.199 91.936);\n  --stroke-yellow-500: oklch(0.795 0.184 86.047);\n  --stroke-yellow-600: oklch(0.681 0.162 75.834);\n  --stroke-yellow-700: oklch(0.554 0.135 66.442);\n  --stroke-yellow-800: oklch(0.476 0.114 61.907);\n  --stroke-yellow-900: oklch(0.421 0.095 57.708);\n  --stroke-yellow-950: oklch(0.286 0.066 53.813);\n  --stroke-lime-50: oklch(0.986 0.031 120.757);\n  --stroke-lime-100: oklch(0.967 0.067 122.328);\n  --stroke-lime-200: oklch(0.938 0.127 124.321);\n  --stroke-lime-300: oklch(0.897 0.196 126.665);\n  --stroke-lime-400: oklch(0.841 0.238 128.85);\n  --stroke-lime-500: oklch(0.768 0.233 130.85);\n  --stroke-lime-600: oklch(0.648 0.2 131.684);\n  --stroke-lime-700: oklch(0.532 0.157 131.589);\n  --stroke-lime-800: oklch(0.453 0.124 130.933);\n  --stroke-lime-900: oklch(0.405 0.101 131.063);\n  --stroke-lime-950: oklch(0.274 0.072 132.109);\n  --stroke-green-50: oklch(0.982 0.018 155.826);\n  --stroke-green-100: oklch(0.962 0.044 156.743);\n  --stroke-green-200: oklch(0.925 0.084 155.995);\n  --stroke-green-300: oklch(0.871 0.15 154.449);\n  --stroke-green-400: oklch(0.792 0.209 151.711);\n  --stroke-green-500: oklch(0.723 0.219 149.579);\n  --stroke-green-600: oklch(0.627 0.194 149.214);\n  --stroke-green-700: oklch(0.527 0.154 150.069);\n  --stroke-green-800: oklch(0.448 0.119 151.328);\n  --stroke-green-900: oklch(0.393 0.095 152.535);\n  --stroke-green-950: oklch(0.266 0.065 152.934);\n  --stroke-emerald-50: oklch(0.979 0.021 166.113);\n  --stroke-emerald-100: oklch(0.95 0.052 163.051);\n  --stroke-emerald-200: oklch(0.905 0.093 164.15);\n  --stroke-emerald-300: oklch(0.845 0.143 164.978);\n  --stroke-emerald-400: oklch(0.765 0.177 163.223);\n  --stroke-emerald-500: oklch(0.696 0.17 162.48);\n  --stroke-emerald-600: oklch(0.596 0.145 163.225);\n  --stroke-emerald-700: oklch(0.508 0.118 165.612);\n  --stroke-emerald-800: oklch(0.432 0.095 166.913);\n  --stroke-emerald-900: oklch(0.378 0.077 168.94);\n  --stroke-emerald-950: oklch(0.262 0.051 172.552);\n  --stroke-teal-50: oklch(0.984 0.014 180.72);\n  --stroke-teal-100: oklch(0.953 0.051 180.801);\n  --stroke-teal-200: oklch(0.91 0.096 180.426);\n  --stroke-teal-300: oklch(0.855 0.138 181.071);\n  --stroke-teal-400: oklch(0.777 0.152 181.912);\n  --stroke-teal-500: oklch(0.704 0.14 182.503);\n  --stroke-teal-600: oklch(0.6 0.118 184.704);\n  --stroke-teal-700: oklch(0.511 0.096 186.391);\n  --stroke-teal-800: oklch(0.437 0.078 188.216);\n  --stroke-teal-900: oklch(0.386 0.063 188.416);\n  --stroke-teal-950: oklch(0.277 0.046 192.524);\n  --stroke-cyan-50: oklch(0.984 0.019 200.873);\n  --stroke-cyan-100: oklch(0.956 0.045 203.388);\n  --stroke-cyan-200: oklch(0.917 0.08 205.041);\n  --stroke-cyan-300: oklch(0.865 0.127 207.078);\n  --stroke-cyan-400: oklch(0.789 0.154 211.53);\n  --stroke-cyan-500: oklch(0.715 0.143 215.221);\n  --stroke-cyan-600: oklch(0.609 0.126 221.723);\n  --stroke-cyan-700: oklch(0.52 0.105 223.128);\n  --stroke-cyan-800: oklch(0.45 0.085 224.283);\n  --stroke-cyan-900: oklch(0.398 0.07 227.392);\n  --stroke-cyan-950: oklch(0.302 0.056 229.695);\n  --stroke-sky-50: oklch(0.977 0.013 236.62);\n  --stroke-sky-100: oklch(0.951 0.026 236.824);\n  --stroke-sky-200: oklch(0.901 0.058 230.902);\n  --stroke-sky-300: oklch(0.828 0.111 230.318);\n  --stroke-sky-400: oklch(0.746 0.16 232.661);\n  --stroke-sky-500: oklch(0.685 0.169 237.323);\n  --stroke-sky-600: oklch(0.588 0.158 241.966);\n  --stroke-sky-700: oklch(0.5 0.134 242.749);\n  --stroke-sky-800: oklch(0.443 0.11 240.79);\n  --stroke-sky-900: oklch(0.391 0.09 240.876);\n  --stroke-sky-950: oklch(0.293 0.066 243.157);\n  --stroke-blue-50: oklch(0.97 0.014 254.604);\n  --stroke-blue-100: oklch(0.932 0.032 255.585);\n  --stroke-blue-200: oklch(0.882 0.059 254.128);\n  --stroke-blue-300: oklch(0.809 0.105 251.813);\n  --stroke-blue-400: oklch(0.707 0.165 254.624);\n  --stroke-blue-500: oklch(0.623 0.214 259.815);\n  --stroke-blue-600: oklch(0.546 0.245 262.881);\n  --stroke-blue-700: oklch(0.488 0.243 264.376);\n  --stroke-blue-800: oklch(0.424 0.199 265.638);\n  --stroke-blue-900: oklch(0.379 0.146 265.522);\n  --stroke-blue-950: oklch(0.282 0.091 267.935);\n  --stroke-indigo-50: oklch(0.962 0.018 272.314);\n  --stroke-indigo-100: oklch(0.93 0.034 272.788);\n  --stroke-indigo-200: oklch(0.87 0.065 274.039);\n  --stroke-indigo-300: oklch(0.785 0.115 274.713);\n  --stroke-indigo-400: oklch(0.673 0.182 276.935);\n  --stroke-indigo-500: oklch(0.585 0.233 277.117);\n  --stroke-indigo-600: oklch(0.511 0.262 276.966);\n  --stroke-indigo-700: oklch(0.457 0.24 277.023);\n  --stroke-indigo-800: oklch(0.398 0.195 277.366);\n  --stroke-indigo-900: oklch(0.359 0.144 278.697);\n  --stroke-indigo-950: oklch(0.257 0.09 281.288);\n  --stroke-violet-50: oklch(0.969 0.016 293.756);\n  --stroke-violet-100: oklch(0.943 0.029 294.588);\n  --stroke-violet-200: oklch(0.894 0.057 293.283);\n  --stroke-violet-300: oklch(0.811 0.111 293.571);\n  --stroke-violet-400: oklch(0.702 0.183 293.541);\n  --stroke-violet-500: oklch(0.606 0.25 292.717);\n  --stroke-violet-600: oklch(0.541 0.281 293.009);\n  --stroke-violet-700: oklch(0.491 0.27 292.581);\n  --stroke-violet-800: oklch(0.432 0.232 292.759);\n  --stroke-violet-900: oklch(0.38 0.189 293.745);\n  --stroke-violet-950: oklch(0.283 0.141 291.089);\n  --stroke-purple-50: oklch(0.977 0.014 308.299);\n  --stroke-purple-100: oklch(0.946 0.033 307.174);\n  --stroke-purple-200: oklch(0.902 0.063 306.703);\n  --stroke-purple-300: oklch(0.827 0.119 306.383);\n  --stroke-purple-400: oklch(0.714 0.203 305.504);\n  --stroke-purple-500: oklch(0.627 0.265 303.9);\n  --stroke-purple-600: oklch(0.558 0.288 302.321);\n  --stroke-purple-700: oklch(0.496 0.265 301.924);\n  --stroke-purple-800: oklch(0.438 0.218 303.724);\n  --stroke-purple-900: oklch(0.381 0.176 304.987);\n  --stroke-purple-950: oklch(0.291 0.149 302.717);\n  --stroke-fuchsia-50: oklch(0.977 0.017 320.058);\n  --stroke-fuchsia-100: oklch(0.952 0.037 318.852);\n  --stroke-fuchsia-200: oklch(0.903 0.076 319.62);\n  --stroke-fuchsia-300: oklch(0.833 0.145 321.434);\n  --stroke-fuchsia-400: oklch(0.74 0.238 322.16);\n  --stroke-fuchsia-500: oklch(0.667 0.295 322.15);\n  --stroke-fuchsia-600: oklch(0.591 0.293 322.896);\n  --stroke-fuchsia-700: oklch(0.518 0.253 323.949);\n  --stroke-fuchsia-800: oklch(0.452 0.211 324.591);\n  --stroke-fuchsia-900: oklch(0.401 0.17 325.612);\n  --stroke-fuchsia-950: oklch(0.293 0.136 325.661);\n  --stroke-pink-50: oklch(0.971 0.014 343.198);\n  --stroke-pink-100: oklch(0.948 0.028 342.258);\n  --stroke-pink-200: oklch(0.899 0.061 343.231);\n  --stroke-pink-300: oklch(0.823 0.12 346.018);\n  --stroke-pink-400: oklch(0.718 0.202 349.761);\n  --stroke-pink-500: oklch(0.656 0.241 354.308);\n  --stroke-pink-600: oklch(0.592 0.249 0.584);\n  --stroke-pink-700: oklch(0.525 0.223 3.958);\n  --stroke-pink-800: oklch(0.459 0.187 3.815);\n  --stroke-pink-900: oklch(0.408 0.153 2.432);\n  --stroke-pink-950: oklch(0.284 0.109 3.907);\n  --stroke-rose-50: oklch(0.969 0.015 12.422);\n  --stroke-rose-100: oklch(0.941 0.03 12.58);\n  --stroke-rose-200: oklch(0.892 0.058 10.001);\n  --stroke-rose-300: oklch(0.81 0.117 11.638);\n  --stroke-rose-400: oklch(0.712 0.194 13.428);\n  --stroke-rose-500: oklch(0.645 0.246 16.439);\n  --stroke-rose-600: oklch(0.586 0.253 17.585);\n  --stroke-rose-700: oklch(0.514 0.222 16.935);\n  --stroke-rose-800: oklch(0.455 0.188 13.697);\n  --stroke-rose-900: oklch(0.41 0.159 10.272);\n  --stroke-rose-950: oklch(0.271 0.105 12.094);\n  --stroke-slate-50: oklch(0.984 0.003 247.858);\n  --stroke-slate-100: oklch(0.968 0.007 247.896);\n  --stroke-slate-200: oklch(0.929 0.013 255.508);\n  --stroke-slate-300: oklch(0.869 0.022 252.894);\n  --stroke-slate-400: oklch(0.704 0.04 256.788);\n  --stroke-slate-500: oklch(0.554 0.046 257.417);\n  --stroke-slate-600: oklch(0.446 0.043 257.281);\n  --stroke-slate-700: oklch(0.372 0.044 257.287);\n  --stroke-slate-800: oklch(0.279 0.041 260.031);\n  --stroke-slate-900: oklch(0.208 0.042 265.755);\n  --stroke-slate-950: oklch(0.129 0.042 264.695);\n  --stroke-gray-50: oklch(0.985 0.002 247.839);\n  --stroke-gray-100: oklch(0.967 0.003 264.542);\n  --stroke-gray-200: oklch(0.928 0.006 264.531);\n  --stroke-gray-300: oklch(0.872 0.01 258.338);\n  --stroke-gray-400: oklch(0.707 0.022 261.325);\n  --stroke-gray-500: oklch(0.551 0.027 264.364);\n  --stroke-gray-600: oklch(0.446 0.03 256.802);\n  --stroke-gray-700: oklch(0.373 0.034 259.733);\n  --stroke-gray-800: oklch(0.278 0.033 256.848);\n  --stroke-gray-900: oklch(0.21 0.034 264.665);\n  --stroke-gray-950: oklch(0.13 0.028 261.692);\n  --stroke-zinc-50: oklch(0.985 0 0);\n  --stroke-zinc-100: oklch(0.967 0.001 286.375);\n  --stroke-zinc-200: oklch(0.92 0.004 286.32);\n  --stroke-zinc-300: oklch(0.871 0.006 286.286);\n  --stroke-zinc-400: oklch(0.705 0.015 286.067);\n  --stroke-zinc-500: oklch(0.552 0.016 285.938);\n  --stroke-zinc-600: oklch(0.442 0.017 285.786);\n  --stroke-zinc-700: oklch(0.37 0.013 285.805);\n  --stroke-zinc-800: oklch(0.274 0.006 286.033);\n  --stroke-zinc-900: oklch(0.21 0.006 285.885);\n  --stroke-zinc-950: oklch(0.141 0.005 285.823);\n  --stroke-neutral-50: oklch(0.985 0 0);\n  --stroke-neutral-100: oklch(0.97 0 0);\n  --stroke-neutral-200: oklch(0.922 0 0);\n  --stroke-neutral-300: oklch(0.87 0 0);\n  --stroke-neutral-400: oklch(0.708 0 0);\n  --stroke-neutral-500: oklch(0.556 0 0);\n  --stroke-neutral-600: oklch(0.439 0 0);\n  --stroke-neutral-700: oklch(0.371 0 0);\n  --stroke-neutral-800: oklch(0.269 0 0);\n  --stroke-neutral-900: oklch(0.205 0 0);\n  --stroke-neutral-950: oklch(0.145 0 0);\n  --stroke-stone-50: oklch(0.985 0.001 106.423);\n  --stroke-stone-100: oklch(0.97 0.001 106.424);\n  --stroke-stone-200: oklch(0.923 0.003 48.717);\n  --stroke-stone-300: oklch(0.869 0.005 56.366);\n  --stroke-stone-400: oklch(0.709 0.01 56.259);\n  --stroke-stone-500: oklch(0.553 0.013 58.071);\n  --stroke-stone-600: oklch(0.444 0.011 73.639);\n  --stroke-stone-700: oklch(0.374 0.01 67.558);\n  --stroke-stone-800: oklch(0.268 0.007 34.298);\n  --stroke-stone-900: oklch(0.216 0.006 56.043);\n  --stroke-stone-950: oklch(0.147 0.004 49.25);\n  --stroke-black: #000;\n  --stroke-white: #fff;\n  --stroke-int-wp-primary-100: #8ad97f;\n  --stroke-int-wp-primary-200: #68c464;\n  --stroke-int-wp-primary-300: #4caf50;\n  --stroke-int-wp-primary-400: #369943;\n  --stroke-int-wp-primary-500: #248438;\n  --stroke-int-wp-primary-600: #166e30;\n  --stroke-int-wp-secondary-200: #434858;\n  --stroke-int-wp-secondary-300: #383c4a;\n  --stroke-int-wp-secondary-400: #303440;\n  --stroke-int-wp-secondary-500: #2d313d;\n  --stroke-int-wp-secondary-600: #2a2e3a;\n  --stroke-int-wp-secondary-700: #222631;\n  --stroke-int-wp-secondary-800: #1b1f28;\n  --stroke-int-wp-control-neutral-100: #fff;\n  --stroke-int-wp-control-neutral-200: #d1d5db;\n  --stroke-int-wp-control-neutral-300: #9ca3af;\n  --stroke-int-wp-control-info-100: #0e7490;\n  --stroke-int-wp-control-info-200: #155e75;\n  --stroke-int-wp-control-info-300: #164e63;\n  --stroke-int-wp-control-info-dark-100: #266778;\n  --stroke-int-wp-control-info-dark-200: #2a5360;\n  --stroke-int-wp-control-info-dark-300: #284651;\n  --stroke-int-wp-control-ok-100: #369943;\n  --stroke-int-wp-control-ok-200: #248438;\n  --stroke-int-wp-control-ok-300: #166e30;\n  --stroke-int-wp-control-ok-dark-100: #408f4b;\n  --stroke-int-wp-control-ok-dark-200: #2c7c3d;\n  --stroke-int-wp-control-ok-dark-300: #1d6733;\n  --stroke-int-wp-error-100: #b91c1c;\n  --stroke-int-wp-error-200: #991b1b;\n  --stroke-int-wp-error-300: #7f1d1d;\n  --stroke-int-wp-state-neutral-100: #4b5563;\n  --stroke-int-wp-state-ok-100: #16a34a;\n  --stroke-int-wp-state-ok-dark-100: #29904f;\n  --stroke-int-wp-state-info-100: #0891b2;\n  --stroke-int-wp-state-info-dark-100: #1b869f;\n  --stroke-int-wp-state-warn-100: #facc15;\n  --stroke-int-wp-state-warn-dark-100: #e2be2d;\n  --stroke-int-wp-hint-warn-100: #fef9c3;\n  --stroke-int-wp-hint-warn-200: #fde047;\n  --stroke-int-wp-hint-warn-dark-100: #c5ba7f;\n  --stroke-int-wp-hint-warn-dark-200: #a18e51;\n  --stroke-wp-background-100: var(--wp-background-100);\n  --stroke-wp-background-200: var(--wp-background-200);\n  --stroke-wp-background-300: var(--wp-background-300);\n  --stroke-wp-background-400: var(--wp-background-400);\n  --stroke-wp-text-100: var(--wp-text-100);\n  --stroke-wp-text-200: var(--wp-text-200);\n  --stroke-wp-text-alt-100: var(--wp-text-alt-100);\n  --stroke-wp-primary-100: var(--wp-primary-100);\n  --stroke-wp-primary-200: var(--wp-primary-200);\n  --stroke-wp-primary-300: var(--wp-primary-300);\n  --stroke-wp-primary-text-100: var(--wp-primary-text-100);\n  --stroke-wp-control-neutral-100: var(--wp-control-neutral-100);\n  --stroke-wp-control-neutral-200: var(--wp-control-neutral-200);\n  --stroke-wp-control-neutral-300: var(--wp-control-neutral-300);\n  --stroke-wp-control-info-100: var(--wp-control-info-100);\n  --stroke-wp-control-info-200: var(--wp-control-info-200);\n  --stroke-wp-control-info-300: var(--wp-control-info-300);\n  --stroke-wp-control-ok-100: var(--wp-control-ok-100);\n  --stroke-wp-control-ok-200: var(--wp-control-ok-200);\n  --stroke-wp-control-ok-300: var(--wp-control-ok-300);\n  --stroke-wp-error-100: var(--wp-error-100);\n  --stroke-wp-error-200: var(--wp-error-200);\n  --stroke-wp-error-300: var(--wp-error-300);\n  --stroke-wp-state-neutral-100: var(--wp-state-neutral-100);\n  --stroke-wp-state-ok-100: var(--wp-state-ok-100);\n  --stroke-wp-state-info-100: var(--wp-state-info-100);\n  --stroke-wp-state-warn-100: var(--wp-state-warn-100);\n  --stroke-wp-hint-warn-100: var(--wp-hint-warn-100);\n  --stroke-wp-hint-warn-200: var(--wp-hint-warn-200);\n  --stroke-wp-code-inline-100: var(--wp-code-inline-100);\n  --stroke-wp-code-inline-200: var(--wp-code-inline-200);\n  --stroke-wp-code-inline-text-100: var(--wp-code-inline-text-100);\n  --stroke-wp-code-100: var(--wp-code-100);\n  --stroke-wp-code-200: var(--wp-code-200);\n  --stroke-wp-code-300: var(--wp-code-300);\n  --stroke-wp-code-text-100: var(--wp-code-text-100);\n  --stroke-wp-code-text-alt-100: var(--wp-code-text-alt-100);\n  --stroke-wp-link-100: var(--wp-link-100);\n  --stroke-wp-link-200: var(--wp-link-200);\n\n  --fill-int-wp-primary-100: #8ad97f;\n  --fill-int-wp-primary-200: #68c464;\n  --fill-int-wp-primary-300: #4caf50;\n  --fill-int-wp-primary-400: #369943;\n  --fill-int-wp-primary-500: #248438;\n  --fill-int-wp-primary-600: #166e30;\n\n  --fill-int-wp-secondary-200: #434858;\n  --fill-int-wp-secondary-300: #383c4a;\n  --fill-int-wp-secondary-400: #303440;\n  --fill-int-wp-secondary-500: #2d313d;\n  --fill-int-wp-secondary-600: #2a2e3a;\n  --fill-int-wp-secondary-700: #222631;\n  --fill-int-wp-secondary-800: #1b1f28;\n\n  --fill-int-wp-control-neutral-100: #fff;\n  --fill-int-wp-control-neutral-200: #d1d5db;\n  --fill-int-wp-control-neutral-300: #9ca3af;\n\n  --fill-int-wp-control-info-100: #0e7490;\n  --fill-int-wp-control-info-200: #155e75;\n  --fill-int-wp-control-info-300: #164e63;\n\n  --fill-int-wp-control-info-dark-100: #266778;\n  --fill-int-wp-control-info-dark-200: #2a5360;\n  --fill-int-wp-control-info-dark-300: #284651;\n\n  --fill-int-wp-control-ok-100: #369943;\n  --fill-int-wp-control-ok-200: #248438;\n  --fill-int-wp-control-ok-300: #166e30;\n\n  --fill-int-wp-control-ok-dark-100: #408f4b;\n  --fill-int-wp-control-ok-dark-200: #2c7c3d;\n  --fill-int-wp-control-ok-dark-300: #1d6733;\n\n  --fill-int-wp-error-100: #b91c1c;\n  --fill-int-wp-error-200: #991b1b;\n  --fill-int-wp-error-300: #7f1d1d;\n\n  --fill-int-wp-state-neutral-100: #4b5563;\n\n  --fill-int-wp-state-ok-100: #16a34a;\n\n  --fill-int-wp-state-ok-dark-100: #29904f;\n\n  --fill-int-wp-state-info-100: #0891b2;\n\n  --fill-int-wp-state-info-dark-100: #1b869f;\n\n  --fill-int-wp-state-warn-100: #facc15;\n\n  --fill-int-wp-state-warn-dark-100: #e2be2d;\n\n  --fill-int-wp-hint-warn-100: #fef9c3;\n  --fill-int-wp-hint-warn-200: #fde047;\n\n  --fill-int-wp-hint-warn-dark-100: #c5ba7f;\n  --fill-int-wp-hint-warn-dark-200: #a18e51;\n\n  --fill-wp-background-100: var(--wp-background-100);\n  --fill-wp-background-200: var(--wp-background-200);\n  --fill-wp-background-300: var(--wp-background-300);\n  --fill-wp-background-400: var(--wp-background-400);\n\n  --fill-wp-text-100: var(--wp-text-100);\n  --fill-wp-text-200: var(--wp-text-200);\n\n  --fill-wp-text-alt-100: var(--wp-text-alt-100);\n\n  --fill-wp-primary-100: var(--wp-primary-100);\n  --fill-wp-primary-200: var(--wp-primary-200);\n  --fill-wp-primary-300: var(--wp-primary-300);\n\n  --fill-wp-primary-text-100: var(--wp-primary-text-100);\n\n  --fill-wp-control-neutral-100: var(--wp-control-neutral-100);\n  --fill-wp-control-neutral-200: var(--wp-control-neutral-200);\n  --fill-wp-control-neutral-300: var(--wp-control-neutral-300);\n\n  --fill-wp-control-info-100: var(--wp-control-info-100);\n  --fill-wp-control-info-200: var(--wp-control-info-200);\n  --fill-wp-control-info-300: var(--wp-control-info-300);\n\n  --fill-wp-control-ok-100: var(--wp-control-ok-100);\n  --fill-wp-control-ok-200: var(--wp-control-ok-200);\n  --fill-wp-control-ok-300: var(--wp-control-ok-300);\n\n  --fill-wp-error-100: var(--wp-error-100);\n  --fill-wp-error-200: var(--wp-error-200);\n  --fill-wp-error-300: var(--wp-error-300);\n\n  --fill-wp-state-neutral-100: var(--wp-state-neutral-100);\n\n  --fill-wp-state-ok-100: var(--wp-state-ok-100);\n\n  --fill-wp-state-info-100: var(--wp-state-info-100);\n\n  --fill-wp-state-warn-100: var(--wp-state-warn-100);\n\n  --fill-wp-hint-warn-100: var(--wp-hint-warn-100);\n  --fill-wp-hint-warn-200: var(--wp-hint-warn-200);\n\n  --fill-wp-code-inline-100: var(--wp-code-inline-100);\n  --fill-wp-code-inline-200: var(--wp-code-inline-200);\n\n  --fill-wp-code-inline-text-100: var(--wp-code-inline-text-100);\n\n  --fill-wp-code-100: var(--wp-code-100);\n  --fill-wp-code-200: var(--wp-code-200);\n  --fill-wp-code-300: var(--wp-code-300);\n\n  --fill-wp-code-text-100: var(--wp-code-text-100);\n\n  --fill-wp-code-text-alt-100: var(--wp-code-text-alt-100);\n\n  --fill-wp-link-100: var(--wp-link-100);\n  --fill-wp-link-200: var(--wp-link-200);\n\n  --fill-red-50: oklch(0.971 0.013 17.38);\n  --fill-red-100: oklch(0.936 0.032 17.717);\n  --fill-red-200: oklch(0.885 0.062 18.334);\n  --fill-red-300: oklch(0.808 0.114 19.571);\n  --fill-red-400: oklch(0.704 0.191 22.216);\n  --fill-red-500: oklch(0.637 0.237 25.331);\n  --fill-red-600: oklch(0.577 0.245 27.325);\n  --fill-red-700: oklch(0.505 0.213 27.518);\n  --fill-red-800: oklch(0.444 0.177 26.899);\n  --fill-red-900: oklch(0.396 0.141 25.723);\n  --fill-red-950: oklch(0.258 0.092 26.042);\n  --fill-orange-50: oklch(0.98 0.016 73.684);\n  --fill-orange-100: oklch(0.954 0.038 75.164);\n  --fill-orange-200: oklch(0.901 0.076 70.697);\n  --fill-orange-300: oklch(0.837 0.128 66.29);\n  --fill-orange-400: oklch(0.75 0.183 55.934);\n  --fill-orange-500: oklch(0.705 0.213 47.604);\n  --fill-orange-600: oklch(0.646 0.222 41.116);\n  --fill-orange-700: oklch(0.553 0.195 38.402);\n  --fill-orange-800: oklch(0.47 0.157 37.304);\n  --fill-orange-900: oklch(0.408 0.123 38.172);\n  --fill-orange-950: oklch(0.266 0.079 36.259);\n  --fill-amber-50: oklch(0.987 0.022 95.277);\n  --fill-amber-100: oklch(0.962 0.059 95.617);\n  --fill-amber-200: oklch(0.924 0.12 95.746);\n  --fill-amber-300: oklch(0.879 0.169 91.605);\n  --fill-amber-400: oklch(0.828 0.189 84.429);\n  --fill-amber-500: oklch(0.769 0.188 70.08);\n  --fill-amber-600: oklch(0.666 0.179 58.318);\n  --fill-amber-700: oklch(0.555 0.163 48.998);\n  --fill-amber-800: oklch(0.473 0.137 46.201);\n  --fill-amber-900: oklch(0.414 0.112 45.904);\n  --fill-amber-950: oklch(0.279 0.077 45.635);\n  --fill-yellow-50: oklch(0.987 0.026 102.212);\n  --fill-yellow-100: oklch(0.973 0.071 103.193);\n  --fill-yellow-200: oklch(0.945 0.129 101.54);\n  --fill-yellow-300: oklch(0.905 0.182 98.111);\n  --fill-yellow-400: oklch(0.852 0.199 91.936);\n  --fill-yellow-500: oklch(0.795 0.184 86.047);\n  --fill-yellow-600: oklch(0.681 0.162 75.834);\n  --fill-yellow-700: oklch(0.554 0.135 66.442);\n  --fill-yellow-800: oklch(0.476 0.114 61.907);\n  --fill-yellow-900: oklch(0.421 0.095 57.708);\n  --fill-yellow-950: oklch(0.286 0.066 53.813);\n  --fill-lime-50: oklch(0.986 0.031 120.757);\n  --fill-lime-100: oklch(0.967 0.067 122.328);\n  --fill-lime-200: oklch(0.938 0.127 124.321);\n  --fill-lime-300: oklch(0.897 0.196 126.665);\n  --fill-lime-400: oklch(0.841 0.238 128.85);\n  --fill-lime-500: oklch(0.768 0.233 130.85);\n  --fill-lime-600: oklch(0.648 0.2 131.684);\n  --fill-lime-700: oklch(0.532 0.157 131.589);\n  --fill-lime-800: oklch(0.453 0.124 130.933);\n  --fill-lime-900: oklch(0.405 0.101 131.063);\n  --fill-lime-950: oklch(0.274 0.072 132.109);\n  --fill-green-50: oklch(0.982 0.018 155.826);\n  --fill-green-100: oklch(0.962 0.044 156.743);\n  --fill-green-200: oklch(0.925 0.084 155.995);\n  --fill-green-300: oklch(0.871 0.15 154.449);\n  --fill-green-400: oklch(0.792 0.209 151.711);\n  --fill-green-500: oklch(0.723 0.219 149.579);\n  --fill-green-600: oklch(0.627 0.194 149.214);\n  --fill-green-700: oklch(0.527 0.154 150.069);\n  --fill-green-800: oklch(0.448 0.119 151.328);\n  --fill-green-900: oklch(0.393 0.095 152.535);\n  --fill-green-950: oklch(0.266 0.065 152.934);\n  --fill-emerald-50: oklch(0.979 0.021 166.113);\n  --fill-emerald-100: oklch(0.95 0.052 163.051);\n  --fill-emerald-200: oklch(0.905 0.093 164.15);\n  --fill-emerald-300: oklch(0.845 0.143 164.978);\n  --fill-emerald-400: oklch(0.765 0.177 163.223);\n  --fill-emerald-500: oklch(0.696 0.17 162.48);\n  --fill-emerald-600: oklch(0.596 0.145 163.225);\n  --fill-emerald-700: oklch(0.508 0.118 165.612);\n  --fill-emerald-800: oklch(0.432 0.095 166.913);\n  --fill-emerald-900: oklch(0.378 0.077 168.94);\n  --fill-emerald-950: oklch(0.262 0.051 172.552);\n  --fill-teal-50: oklch(0.984 0.014 180.72);\n  --fill-teal-100: oklch(0.953 0.051 180.801);\n  --fill-teal-200: oklch(0.91 0.096 180.426);\n  --fill-teal-300: oklch(0.855 0.138 181.071);\n  --fill-teal-400: oklch(0.777 0.152 181.912);\n  --fill-teal-500: oklch(0.704 0.14 182.503);\n  --fill-teal-600: oklch(0.6 0.118 184.704);\n  --fill-teal-700: oklch(0.511 0.096 186.391);\n  --fill-teal-800: oklch(0.437 0.078 188.216);\n  --fill-teal-900: oklch(0.386 0.063 188.416);\n  --fill-teal-950: oklch(0.277 0.046 192.524);\n  --fill-cyan-50: oklch(0.984 0.019 200.873);\n  --fill-cyan-100: oklch(0.956 0.045 203.388);\n  --fill-cyan-200: oklch(0.917 0.08 205.041);\n  --fill-cyan-300: oklch(0.865 0.127 207.078);\n  --fill-cyan-400: oklch(0.789 0.154 211.53);\n  --fill-cyan-500: oklch(0.715 0.143 215.221);\n  --fill-cyan-600: oklch(0.609 0.126 221.723);\n  --fill-cyan-700: oklch(0.52 0.105 223.128);\n  --fill-cyan-800: oklch(0.45 0.085 224.283);\n  --fill-cyan-900: oklch(0.398 0.07 227.392);\n  --fill-cyan-950: oklch(0.302 0.056 229.695);\n  --fill-sky-50: oklch(0.977 0.013 236.62);\n  --fill-sky-100: oklch(0.951 0.026 236.824);\n  --fill-sky-200: oklch(0.901 0.058 230.902);\n  --fill-sky-300: oklch(0.828 0.111 230.318);\n  --fill-sky-400: oklch(0.746 0.16 232.661);\n  --fill-sky-500: oklch(0.685 0.169 237.323);\n  --fill-sky-600: oklch(0.588 0.158 241.966);\n  --fill-sky-700: oklch(0.5 0.134 242.749);\n  --fill-sky-800: oklch(0.443 0.11 240.79);\n  --fill-sky-900: oklch(0.391 0.09 240.876);\n  --fill-sky-950: oklch(0.293 0.066 243.157);\n  --fill-blue-50: oklch(0.97 0.014 254.604);\n  --fill-blue-100: oklch(0.932 0.032 255.585);\n  --fill-blue-200: oklch(0.882 0.059 254.128);\n  --fill-blue-300: oklch(0.809 0.105 251.813);\n  --fill-blue-400: oklch(0.707 0.165 254.624);\n  --fill-blue-500: oklch(0.623 0.214 259.815);\n  --fill-blue-600: oklch(0.546 0.245 262.881);\n  --fill-blue-700: oklch(0.488 0.243 264.376);\n  --fill-blue-800: oklch(0.424 0.199 265.638);\n  --fill-blue-900: oklch(0.379 0.146 265.522);\n  --fill-blue-950: oklch(0.282 0.091 267.935);\n  --fill-indigo-50: oklch(0.962 0.018 272.314);\n  --fill-indigo-100: oklch(0.93 0.034 272.788);\n  --fill-indigo-200: oklch(0.87 0.065 274.039);\n  --fill-indigo-300: oklch(0.785 0.115 274.713);\n  --fill-indigo-400: oklch(0.673 0.182 276.935);\n  --fill-indigo-500: oklch(0.585 0.233 277.117);\n  --fill-indigo-600: oklch(0.511 0.262 276.966);\n  --fill-indigo-700: oklch(0.457 0.24 277.023);\n  --fill-indigo-800: oklch(0.398 0.195 277.366);\n  --fill-indigo-900: oklch(0.359 0.144 278.697);\n  --fill-indigo-950: oklch(0.257 0.09 281.288);\n  --fill-violet-50: oklch(0.969 0.016 293.756);\n  --fill-violet-100: oklch(0.943 0.029 294.588);\n  --fill-violet-200: oklch(0.894 0.057 293.283);\n  --fill-violet-300: oklch(0.811 0.111 293.571);\n  --fill-violet-400: oklch(0.702 0.183 293.541);\n  --fill-violet-500: oklch(0.606 0.25 292.717);\n  --fill-violet-600: oklch(0.541 0.281 293.009);\n  --fill-violet-700: oklch(0.491 0.27 292.581);\n  --fill-violet-800: oklch(0.432 0.232 292.759);\n  --fill-violet-900: oklch(0.38 0.189 293.745);\n  --fill-violet-950: oklch(0.283 0.141 291.089);\n  --fill-purple-50: oklch(0.977 0.014 308.299);\n  --fill-purple-100: oklch(0.946 0.033 307.174);\n  --fill-purple-200: oklch(0.902 0.063 306.703);\n  --fill-purple-300: oklch(0.827 0.119 306.383);\n  --fill-purple-400: oklch(0.714 0.203 305.504);\n  --fill-purple-500: oklch(0.627 0.265 303.9);\n  --fill-purple-600: oklch(0.558 0.288 302.321);\n  --fill-purple-700: oklch(0.496 0.265 301.924);\n  --fill-purple-800: oklch(0.438 0.218 303.724);\n  --fill-purple-900: oklch(0.381 0.176 304.987);\n  --fill-purple-950: oklch(0.291 0.149 302.717);\n  --fill-fuchsia-50: oklch(0.977 0.017 320.058);\n  --fill-fuchsia-100: oklch(0.952 0.037 318.852);\n  --fill-fuchsia-200: oklch(0.903 0.076 319.62);\n  --fill-fuchsia-300: oklch(0.833 0.145 321.434);\n  --fill-fuchsia-400: oklch(0.74 0.238 322.16);\n  --fill-fuchsia-500: oklch(0.667 0.295 322.15);\n  --fill-fuchsia-600: oklch(0.591 0.293 322.896);\n  --fill-fuchsia-700: oklch(0.518 0.253 323.949);\n  --fill-fuchsia-800: oklch(0.452 0.211 324.591);\n  --fill-fuchsia-900: oklch(0.401 0.17 325.612);\n  --fill-fuchsia-950: oklch(0.293 0.136 325.661);\n  --fill-pink-50: oklch(0.971 0.014 343.198);\n  --fill-pink-100: oklch(0.948 0.028 342.258);\n  --fill-pink-200: oklch(0.899 0.061 343.231);\n  --fill-pink-300: oklch(0.823 0.12 346.018);\n  --fill-pink-400: oklch(0.718 0.202 349.761);\n  --fill-pink-500: oklch(0.656 0.241 354.308);\n  --fill-pink-600: oklch(0.592 0.249 0.584);\n  --fill-pink-700: oklch(0.525 0.223 3.958);\n  --fill-pink-800: oklch(0.459 0.187 3.815);\n  --fill-pink-900: oklch(0.408 0.153 2.432);\n  --fill-pink-950: oklch(0.284 0.109 3.907);\n  --fill-rose-50: oklch(0.969 0.015 12.422);\n  --fill-rose-100: oklch(0.941 0.03 12.58);\n  --fill-rose-200: oklch(0.892 0.058 10.001);\n  --fill-rose-300: oklch(0.81 0.117 11.638);\n  --fill-rose-400: oklch(0.712 0.194 13.428);\n  --fill-rose-500: oklch(0.645 0.246 16.439);\n  --fill-rose-600: oklch(0.586 0.253 17.585);\n  --fill-rose-700: oklch(0.514 0.222 16.935);\n  --fill-rose-800: oklch(0.455 0.188 13.697);\n  --fill-rose-900: oklch(0.41 0.159 10.272);\n  --fill-rose-950: oklch(0.271 0.105 12.094);\n  --fill-slate-50: oklch(0.984 0.003 247.858);\n  --fill-slate-100: oklch(0.968 0.007 247.896);\n  --fill-slate-200: oklch(0.929 0.013 255.508);\n  --fill-slate-300: oklch(0.869 0.022 252.894);\n  --fill-slate-400: oklch(0.704 0.04 256.788);\n  --fill-slate-500: oklch(0.554 0.046 257.417);\n  --fill-slate-600: oklch(0.446 0.043 257.281);\n  --fill-slate-700: oklch(0.372 0.044 257.287);\n  --fill-slate-800: oklch(0.279 0.041 260.031);\n  --fill-slate-900: oklch(0.208 0.042 265.755);\n  --fill-slate-950: oklch(0.129 0.042 264.695);\n  --fill-gray-50: oklch(0.985 0.002 247.839);\n  --fill-gray-100: oklch(0.967 0.003 264.542);\n  --fill-gray-200: oklch(0.928 0.006 264.531);\n  --fill-gray-300: oklch(0.872 0.01 258.338);\n  --fill-gray-400: oklch(0.707 0.022 261.325);\n  --fill-gray-500: oklch(0.551 0.027 264.364);\n  --fill-gray-600: oklch(0.446 0.03 256.802);\n  --fill-gray-700: oklch(0.373 0.034 259.733);\n  --fill-gray-800: oklch(0.278 0.033 256.848);\n  --fill-gray-900: oklch(0.21 0.034 264.665);\n  --fill-gray-950: oklch(0.13 0.028 261.692);\n  --fill-zinc-50: oklch(0.985 0 0);\n  --fill-zinc-100: oklch(0.967 0.001 286.375);\n  --fill-zinc-200: oklch(0.92 0.004 286.32);\n  --fill-zinc-300: oklch(0.871 0.006 286.286);\n  --fill-zinc-400: oklch(0.705 0.015 286.067);\n  --fill-zinc-500: oklch(0.552 0.016 285.938);\n  --fill-zinc-600: oklch(0.442 0.017 285.786);\n  --fill-zinc-700: oklch(0.37 0.013 285.805);\n  --fill-zinc-800: oklch(0.274 0.006 286.033);\n  --fill-zinc-900: oklch(0.21 0.006 285.885);\n  --fill-zinc-950: oklch(0.141 0.005 285.823);\n  --fill-neutral-50: oklch(0.985 0 0);\n  --fill-neutral-100: oklch(0.97 0 0);\n  --fill-neutral-200: oklch(0.922 0 0);\n  --fill-neutral-300: oklch(0.87 0 0);\n  --fill-neutral-400: oklch(0.708 0 0);\n  --fill-neutral-500: oklch(0.556 0 0);\n  --fill-neutral-600: oklch(0.439 0 0);\n  --fill-neutral-700: oklch(0.371 0 0);\n  --fill-neutral-800: oklch(0.269 0 0);\n  --fill-neutral-900: oklch(0.205 0 0);\n  --fill-neutral-950: oklch(0.145 0 0);\n  --fill-stone-50: oklch(0.985 0.001 106.423);\n  --fill-stone-100: oklch(0.97 0.001 106.424);\n  --fill-stone-200: oklch(0.923 0.003 48.717);\n  --fill-stone-300: oklch(0.869 0.005 56.366);\n  --fill-stone-400: oklch(0.709 0.01 56.259);\n  --fill-stone-500: oklch(0.553 0.013 58.071);\n  --fill-stone-600: oklch(0.444 0.011 73.639);\n  --fill-stone-700: oklch(0.374 0.01 67.558);\n  --fill-stone-800: oklch(0.268 0.007 34.298);\n  --fill-stone-900: oklch(0.216 0.006 56.043);\n  --fill-stone-950: oklch(0.147 0.004 49.25);\n  --fill-black: #000;\n  --fill-white: #fff;\n  --fill-int-wp-primary-100: #8ad97f;\n  --fill-int-wp-primary-200: #68c464;\n  --fill-int-wp-primary-300: #4caf50;\n  --fill-int-wp-primary-400: #369943;\n  --fill-int-wp-primary-500: #248438;\n  --fill-int-wp-primary-600: #166e30;\n  --fill-int-wp-secondary-200: #434858;\n  --fill-int-wp-secondary-300: #383c4a;\n  --fill-int-wp-secondary-400: #303440;\n  --fill-int-wp-secondary-500: #2d313d;\n  --fill-int-wp-secondary-600: #2a2e3a;\n  --fill-int-wp-secondary-700: #222631;\n  --fill-int-wp-secondary-800: #1b1f28;\n  --fill-int-wp-control-neutral-100: #fff;\n  --fill-int-wp-control-neutral-200: #d1d5db;\n  --fill-int-wp-control-neutral-300: #9ca3af;\n  --fill-int-wp-control-info-100: #0e7490;\n  --fill-int-wp-control-info-200: #155e75;\n  --fill-int-wp-control-info-300: #164e63;\n  --fill-int-wp-control-info-dark-100: #266778;\n  --fill-int-wp-control-info-dark-200: #2a5360;\n  --fill-int-wp-control-info-dark-300: #284651;\n  --fill-int-wp-control-ok-100: #369943;\n  --fill-int-wp-control-ok-200: #248438;\n  --fill-int-wp-control-ok-300: #166e30;\n  --fill-int-wp-control-ok-dark-100: #408f4b;\n  --fill-int-wp-control-ok-dark-200: #2c7c3d;\n  --fill-int-wp-control-ok-dark-300: #1d6733;\n  --fill-int-wp-error-100: #b91c1c;\n  --fill-int-wp-error-200: #991b1b;\n  --fill-int-wp-error-300: #7f1d1d;\n  --fill-int-wp-state-neutral-100: #4b5563;\n  --fill-int-wp-state-ok-100: #16a34a;\n  --fill-int-wp-state-ok-dark-100: #29904f;\n  --fill-int-wp-state-info-100: #0891b2;\n  --fill-int-wp-state-info-dark-100: #1b869f;\n  --fill-int-wp-state-warn-100: #facc15;\n  --fill-int-wp-state-warn-dark-100: #e2be2d;\n  --fill-int-wp-hint-warn-100: #fef9c3;\n  --fill-int-wp-hint-warn-200: #fde047;\n  --fill-int-wp-hint-warn-dark-100: #c5ba7f;\n  --fill-int-wp-hint-warn-dark-200: #a18e51;\n  --fill-wp-background-100: var(--wp-background-100);\n  --fill-wp-background-200: var(--wp-background-200);\n  --fill-wp-background-300: var(--wp-background-300);\n  --fill-wp-background-400: var(--wp-background-400);\n  --fill-wp-text-100: var(--wp-text-100);\n  --fill-wp-text-200: var(--wp-text-200);\n  --fill-wp-text-alt-100: var(--wp-text-alt-100);\n  --fill-wp-primary-100: var(--wp-primary-100);\n  --fill-wp-primary-200: var(--wp-primary-200);\n  --fill-wp-primary-300: var(--wp-primary-300);\n  --fill-wp-primary-text-100: var(--wp-primary-text-100);\n  --fill-wp-control-neutral-100: var(--wp-control-neutral-100);\n  --fill-wp-control-neutral-200: var(--wp-control-neutral-200);\n  --fill-wp-control-neutral-300: var(--wp-control-neutral-300);\n  --fill-wp-control-info-100: var(--wp-control-info-100);\n  --fill-wp-control-info-200: var(--wp-control-info-200);\n  --fill-wp-control-info-300: var(--wp-control-info-300);\n  --fill-wp-control-ok-100: var(--wp-control-ok-100);\n  --fill-wp-control-ok-200: var(--wp-control-ok-200);\n  --fill-wp-control-ok-300: var(--wp-control-ok-300);\n  --fill-wp-error-100: var(--wp-error-100);\n  --fill-wp-error-200: var(--wp-error-200);\n  --fill-wp-error-300: var(--wp-error-300);\n  --fill-wp-state-neutral-100: var(--wp-state-neutral-100);\n  --fill-wp-state-ok-100: var(--wp-state-ok-100);\n  --fill-wp-state-info-100: var(--wp-state-info-100);\n  --fill-wp-state-warn-100: var(--wp-state-warn-100);\n  --fill-wp-hint-warn-100: var(--wp-hint-warn-100);\n  --fill-wp-hint-warn-200: var(--wp-hint-warn-200);\n  --fill-wp-code-inline-100: var(--wp-code-inline-100);\n  --fill-wp-code-inline-200: var(--wp-code-inline-200);\n  --fill-wp-code-inline-text-100: var(--wp-code-inline-text-100);\n  --fill-wp-code-100: var(--wp-code-100);\n  --fill-wp-code-200: var(--wp-code-200);\n  --fill-wp-code-300: var(--wp-code-300);\n  --fill-wp-code-text-100: var(--wp-code-text-100);\n  --fill-wp-code-text-alt-100: var(--wp-code-text-alt-100);\n  --fill-wp-link-100: var(--wp-link-100);\n  --fill-wp-link-200: var(--wp-link-200);\n}\n\n/*\n  The default border color has changed to `currentColor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentColor);\n  }\n}\n\n@utility w-fill {\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}\n\n@utility display-unset {\n  display: unset;\n}\n"
  },
  {
    "path": "web/src/views/Login.vue",
    "content": "<template>\n  <main class=\"flex h-full w-full flex-col items-center justify-center\">\n    <Error v-if=\"errorMessage\" class=\"w-full md:w-3xl\">\n      <span class=\"whitespace-pre\">{{ errorMessage }}</span>\n      <span v-if=\"errorDescription\" class=\"mt-1 whitespace-pre\">{{ errorDescription }}</span>\n      <a\n        v-if=\"errorUri\"\n        :href=\"errorUri\"\n        target=\"_blank\"\n        class=\"text-wp-link-100 hover:text-wp-link-200 mt-1 cursor-pointer\"\n      >\n        <span>{{ errorUri }}</span>\n      </a>\n    </Error>\n\n    <div\n      class=\"min-h-sm border-wp-background-400 dark:border-wp-background-100 bg-wp-background-100 dark:bg-wp-background-200 flex w-full flex-col overflow-hidden border md:m-8 md:w-3xl md:flex-row md:rounded-md\"\n    >\n      <div class=\"bg-wp-primary-200 dark:bg-wp-primary-300 flex min-h-48 items-center justify-center md:w-3/5\">\n        <WoodpeckerLogo preserveAspectRatio=\"xMinYMin slice\" class=\"h-32 w-32 md:h-48 md:w-48\" />\n      </div>\n      <div class=\"flex min-h-48 flex-col items-center justify-center gap-4 p-4 text-center md:w-2/5\">\n        <h1 class=\"text-wp-text-100 text-xl\">{{ $t('login_to_woodpecker_with') }}</h1>\n        <div class=\"flex flex-col gap-2\">\n          <Button\n            v-for=\"forge in forgesWithNameAndFavicon\"\n            :key=\"forge.id\"\n            :start-icon=\"forge.type === 'addon' ? 'repo' : forge.type\"\n            class=\"whitespace-normal!\"\n            @click=\"authenticate(forge.id)\"\n          >\n            <div class=\"mr-2 w-4\">\n              <img\n                v-if=\"forge.favicon && !failedForgeFavicons.has(forge.id)\"\n                :src=\"forge.favicon\"\n                :alt=\"$t('login_to_woodpecker_with', { forge: forge.name })\"\n                @error=\"() => failedForgeFavicons.add(forge.id)\"\n              />\n              <Icon v-else :name=\"forge.type === 'addon' ? 'repo' : forge.type\" />\n            </div>\n\n            {{ forge.name }}\n          </Button>\n        </div>\n      </div>\n    </div>\n  </main>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute } from 'vue-router';\n\nimport WoodpeckerLogo from '~/assets/logo.svg?component';\nimport Button from '~/components/atomic/Button.vue';\nimport Error from '~/components/atomic/Error.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Forge } from '~/lib/api/types';\n\nconst route = useRoute();\nconst { authenticate } = useAuthentication();\nconst i18n = useI18n();\nconst apiClient = useApiClient();\n\nconst forges = ref<Forge[]>([]);\n\nconst authErrorMessages = {\n  oauth_error: i18n.t('oauth_error'),\n  internal_error: i18n.t('internal_error'),\n  registration_closed: i18n.t('registration_closed'),\n  access_denied: i18n.t('access_denied'),\n  invalid_state: i18n.t('invalid_state'),\n  org_access_denied: i18n.t('org_access_denied'),\n};\n\nconst errorMessage = ref<string>();\nconst errorDescription = ref<string>(route.query.error_description as string);\nconst errorUri = ref<string>(route.query.error_uri as string);\n\nonMounted(async () => {\n  forges.value = (await apiClient.getForges()) ?? [];\n\n  if (route.query.error) {\n    const error = route.query.error as keyof typeof authErrorMessages;\n    errorMessage.value = authErrorMessages[error] ?? error;\n  }\n});\n\nuseWPTitle(computed(() => [i18n.t('login')]));\n\nconst failedForgeFavicons = ref(new Set<number>()); // Track which favicons failed to load\n\nconst forgesWithNameAndFavicon = computed(() =>\n  forges.value.map((forge) => {\n    let name = forge.type.charAt(0).toUpperCase() + forge.type.slice(1);\n    let favicon: null | string = null;\n\n    if (forge.url || forge.oauth_host) {\n      const url = new URL(forge.oauth_host || forge.url);\n      name = url.hostname;\n      favicon = `${url.origin}/favicon.ico`;\n    }\n\n    return {\n      ...forge,\n      name,\n      favicon,\n    };\n  }),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/NotFound.vue",
    "content": "<template>\n  <div class=\"flex h-full w-full flex-col items-center justify-center\">\n    <p class=\"mb-8 text-2xl\">{{ $t('not_found.not_found') }}</p>\n    <router-link class=\"text-blue-400\" replace :to=\"{ name: 'home' }\">\n      {{ $t('not_found.back_home') }}\n    </router-link>\n  </div>\n</template>\n"
  },
  {
    "path": "web/src/views/RepoAdd.vue",
    "content": "<template>\n  <Scaffold v-model:search=\"search\" :go-back=\"goBack\">\n    <template #title>\n      {{ $t('repo.add') }}\n    </template>\n\n    <div class=\"space-y-4\">\n      <template v-if=\"repos !== undefined && repos.length > 0\">\n        <template v-for=\"repo in searchedRepos\" :key=\"repo.forge_remote_id\">\n          <!-- Conflict case: forge repo exists but a stale Woodpecker repo with same name blocks activation -->\n          <div v-if=\"repo.has_forge_name_conflict\" class=\"space-y-0\">\n            <!-- New forge repo (that causes conflict) -->\n            <ListItem class=\"items-center rounded-b-none! border-b-0!\">\n              <div class=\"flex w-full items-center\">\n                <span class=\"text-wp-text-100\">{{ repo.full_name }}</span>\n                <span class=\"text-wp-text-alt-100 ml-2 text-xs\">{{ $t('repo.enable.new_forge_repo') }}</span>\n                <div class=\"ml-auto flex items-center\">\n                  <Button :text=\"$t('repo.enable.conflict')\" :title=\"$t('repo.enable.conflict_desc')\" disabled />\n                </div>\n              </div>\n            </ListItem>\n\n            <!-- Old stale Woodpecker repo -->\n            <ListItem\n              :to=\"{ name: 'repo', params: { repoId: repo.id } }\"\n              class=\"items-center rounded-t-none! border-t-0! opacity-80\"\n            >\n              <span class=\"text-wp-text-alt-100\">{{ repo.full_name }}</span>\n              <span class=\"text-wp-text-alt-100 ml-2 text-xs\">{{ $t('repo.enable.stale_wp_repo') }}</span>\n              <div class=\"ml-auto\" @click.prevent.stop>\n                <Button\n                  start-icon=\"toolbox\"\n                  :text=\"$t('repo.settings.actions.actions')\"\n                  :to=\"{ name: 'repo-settings-actions', params: { repoId: repo.id } }\"\n                />\n              </div>\n            </ListItem>\n          </div>\n\n          <!-- Conflict case: has no forge counterpart -->\n          <ListItem\n            v-else-if=\"repo.has_no_forge_repo\"\n            :to=\"{ name: 'repo', params: { repoId: repo.id } }\"\n            class=\"items-center\"\n          >\n            <span class=\"text-wp-text-100\">{{ repo.full_name }}</span>\n            <span class=\"text-wp-text-alt-100 ml-auto\">{{ $t('repo.enable.forge_repo_missing') }}</span>\n          </ListItem>\n\n          <!-- Normal case: already active -->\n          <ListItem v-else-if=\"repo.active\" :to=\"{ name: 'repo', params: { repoId: repo.id } }\" class=\"items-center\">\n            <span class=\"text-wp-text-100\">{{ repo.full_name }}</span>\n            <span class=\"text-wp-text-alt-100 ml-auto\">{{ $t('repo.enable.enabled') }}</span>\n          </ListItem>\n\n          <!-- Normal case: can be enabled -->\n          <ListItem\n            v-else\n            class=\"items-center\"\n            :to=\"repo.id ? { name: 'repo', params: { repoId: repo.id } } : undefined\"\n          >\n            <span class=\"text-wp-text-100\">{{ repo.full_name }}</span>\n            <div class=\"ml-auto flex items-center\">\n              <Badge v-if=\"repo.id\" class=\"md:display-unset mr-2 hidden\" :value=\"$t('repo.enable.disabled')\" />\n              <Button\n                :text=\"$t('repo.enable.enable')\"\n                :is-loading=\"isActivatingRepo && repoToActivate?.forge_remote_id === repo.forge_remote_id\"\n                @click=\"activateRepo(repo)\"\n              />\n            </div>\n          </ListItem>\n        </template>\n      </template>\n      <div v-else-if=\"loading\" class=\"text-wp-text-100 flex justify-center\">\n        <Icon name=\"spinner\" />\n      </div>\n    </div>\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useRepoSearch } from '~/compositions/useRepoSearch';\nimport { useRouteBack } from '~/compositions/useRouteBack';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Repo } from '~/lib/api/types';\n\nconst router = useRouter();\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst repos = ref<Repo[]>();\nconst repoToActivate = ref<Repo>();\nconst search = ref('');\nconst i18n = useI18n();\nconst loading = ref(false);\n\nconst { searchedRepos } = useRepoSearch(repos, search);\n\nonMounted(async () => {\n  loading.value = true;\n  repos.value = await apiClient.getRepoList({ all: true });\n  loading.value = false;\n});\n\nconst { doSubmit: activateRepo, isLoading: isActivatingRepo } = useAsyncAction(async (repo: Repo) => {\n  repoToActivate.value = repo;\n  const _repo = await apiClient.activateRepo(repo.forge_remote_id);\n  notifications.notify({ title: i18n.t('repo.enable.success'), type: 'success' });\n  repoToActivate.value = undefined;\n  await router.push({ name: 'repo', params: { repoId: _repo.id } });\n});\n\nconst goBack = useRouteBack({ name: 'repos' });\n\nuseWPTitle(computed(() => [i18n.t('repo.add')]));\n</script>\n"
  },
  {
    "path": "web/src/views/Repos.vue",
    "content": "<template>\n  <Scaffold v-model:search=\"search\">\n    <template #title>\n      {{ $t('repositories.title') }}\n    </template>\n\n    <template #headerActions>\n      <Button :to=\"{ name: 'repo-add' }\" start-icon=\"plus\" :text=\"$t('repo.add')\" />\n    </template>\n\n    <Transition name=\"fade\" mode=\"out-in\">\n      <div v-if=\"search === '' && repos.length > 0\" class=\"grid gap-8\">\n        <div v-if=\"reposLastAccess.length > 0 && repos.length > 4\" class=\"flex flex-col gap-4\">\n          <div>\n            <h2 class=\"text-wp-text-100 text-lg\">{{ $t('repositories.last.title') }}</h2>\n            <span class=\"text-wp-text-alt-100\">{{ $t('repositories.last.desc') }}</span>\n          </div>\n          <div class=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-2\">\n            <RepoItem v-for=\"repo in reposLastAccess\" :key=\"repo.id\" :repo=\"repo\" />\n          </div>\n        </div>\n\n        <div class=\"flex flex-col gap-4\">\n          <div>\n            <h2 class=\"text-wp-text-100 text-lg\">{{ $t('repositories.all.title') }}</h2>\n            <span class=\"text-wp-text-alt-100\">{{ $t('repositories.all.desc') }}</span>\n          </div>\n          <div class=\"flex flex-col gap-4\">\n            <RepoItem v-for=\"repo in reposLastActivity\" :key=\"repo.id\" :repo=\"repo\" />\n          </div>\n        </div>\n      </div>\n      <div v-else class=\"flex flex-col\">\n        <div v-if=\"reposLastActivity.length > 0\" class=\"flex flex-col gap-4\">\n          <RepoItem v-for=\"repo in reposLastActivity\" :key=\"repo.id\" :repo=\"repo\" />\n        </div>\n        <span v-else class=\"text-wp-text-100 text-center text-lg\">{{ $t('no_search_results') }}</span>\n      </div>\n    </Transition>\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport RepoItem from '~/components/repo/RepoItem.vue';\nimport useRepos from '~/compositions/useRepos';\nimport { useRepoSearch } from '~/compositions/useRepoSearch';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { useRepoStore } from '~/store/repos';\n\nconst repoStore = useRepoStore();\n\nconst { sortReposByLastAccess, sortReposByLastActivity, repoWithLastPipeline } = useRepos();\nconst repos = computed(() => Object.values(repoStore.ownedRepos).map((r) => repoWithLastPipeline(r)));\n\nconst reposLastAccess = computed(() => sortReposByLastAccess(repos.value || []).slice(0, 4));\n\nconst search = ref('');\nconst { searchedRepos } = useRepoSearch(repos, search);\nconst reposLastActivity = computed(() => sortReposByLastActivity(searchedRepos.value || []));\n\nonMounted(async () => {\n  await repoStore.loadRepos();\n});\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repositories.title')]));\n</script>\n\n<style scoped>\n.fade-enter-active,\n.fade-leave-active {\n  transition: all 0.2s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "web/src/views/RouterView.vue",
    "content": "<template>\n  <router-view />\n</template>\n"
  },
  {
    "path": "web/src/views/admin/AdminAgents.vue",
    "content": "<template>\n  <AgentManager\n    :description=\"$t('admin.settings.agents.desc')\"\n    :load-agents=\"loadAgents\"\n    :create-agent=\"createAgent\"\n    :update-agent=\"updateAgent\"\n    :delete-agent=\"deleteAgent\"\n    :is-admin=\"true\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport AgentManager from '~/components/agent/AgentManager.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Agent } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\n\nconst loadAgents = (page: number) => apiClient.getAgents({ page });\nconst createAgent = (agent: Partial<Agent>) => apiClient.createAgent(agent);\nconst updateAgent = (agent: Agent) => apiClient.updateAgent(agent);\nconst deleteAgent = (agent: Agent) => apiClient.deleteAgent(agent);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('admin.settings.agents.agents'), t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminInfo.vue",
    "content": "<template>\n  <Settings :title=\"$t('info')\">\n    <div class=\"flex flex-col items-center gap-4\">\n      <WoodpeckerLogo class=\"fill-wp-text-200 h-32 w-32\" />\n\n      <i18n-t keypath=\"running_version\" tag=\"p\" class=\"text-center text-xl\">\n        <span class=\"font-bold\">{{ version?.current }}</span>\n      </i18n-t>\n\n      <Error v-if=\"version?.needsUpdate\">\n        <i18n-t keypath=\"update_woodpecker\" tag=\"span\">\n          <a\n            v-if=\"!version.usesNext\"\n            :href=\"`https://github.com/woodpecker-ci/woodpecker/releases/tag/${version.latest}`\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            class=\"underline\"\n          >\n            {{ version.latest }}\n          </a>\n          <span v-else>\n            {{ version.latest }}\n          </span>\n        </i18n-t>\n      </Error>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport WoodpeckerLogo from '~/assets/logo.svg?component';\nimport Error from '~/components/atomic/Error.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport { useVersion } from '~/compositions/useVersion';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst version = useVersion();\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('info'), t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminOrgs.vue",
    "content": "<template>\n  <Settings :title=\"$t('admin.settings.orgs.orgs')\" :description=\"$t('admin.settings.orgs.desc')\">\n    <div class=\"text-wp-text-100 space-y-4\">\n      <ListItem\n        v-for=\"org in orgs\"\n        :key=\"org.id\"\n        class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center gap-2\"\n      >\n        <span>{{ org.name }}</span>\n        <div class=\"ml-auto flex items-center gap-2\">\n          <IconButton\n            icon=\"chevron-right\"\n            :title=\"$t('admin.settings.orgs.view')\"\n            class=\"h-8 w-8\"\n            :to=\"{ name: 'org', params: { orgId: org.id } }\"\n          />\n          <IconButton\n            icon=\"settings-outline\"\n            :title=\"$t('admin.settings.orgs.org_settings')\"\n            class=\"h-8 w-8\"\n            :to=\"{ name: 'org-settings', params: { orgId: org.id } }\"\n          />\n          <IconButton\n            icon=\"trash\"\n            :title=\"$t('admin.settings.orgs.delete_org')\"\n            class=\"hover:text-wp-error-100 h-8 w-8\"\n            :is-loading=\"isDeleting\"\n            @click=\"deleteOrg(org)\"\n          />\n        </div>\n      </ListItem>\n\n      <div v-if=\"loading\" class=\"flex justify-center\">\n        <Icon name=\"spinner\" class=\"animate-spin\" />\n      </div>\n      <div v-else-if=\"orgs?.length === 0\" class=\"ml-2\">{{ $t('admin.settings.orgs.none') }}</div>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Org } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\n\nasync function loadOrgs(page: number): Promise<Org[] | null> {\n  return apiClient.getOrgs({ page });\n}\n\nconst { resetPage, data: orgs, loading } = usePagination(loadOrgs);\n\nconst { doSubmit: deleteOrg, isLoading: isDeleting } = useAsyncAction(async (_org: Org) => {\n  // eslint-disable-next-line no-alert\n  if (!confirm(t('admin.settings.orgs.delete_confirm'))) {\n    return;\n  }\n\n  await apiClient.deleteOrg(_org);\n  notifications.notify({ title: t('admin.settings.orgs.deleted'), type: 'success' });\n  await resetPage();\n});\n\nuseWPTitle(computed(() => [t('admin.settings.orgs.orgs'), t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminQueue.vue",
    "content": "<template>\n  <Settings :title=\"$t('admin.settings.queue.queue')\" :description=\"$t('admin.settings.queue.desc')\">\n    <template #headerActions>\n      <div v-if=\"queueInfo\">\n        <div class=\"flex items-center gap-2\">\n          <Button\n            v-if=\"queueInfo.paused\"\n            :text=\"$t('admin.settings.queue.resume')\"\n            start-icon=\"play\"\n            @click=\"resumeQueue\"\n          />\n          <Button v-else :text=\"$t('admin.settings.queue.pause')\" start-icon=\"pause\" @click=\"pauseQueue\" />\n          <Icon\n            :name=\"queueInfo.paused ? 'pause' : 'play'\"\n            class=\"h-6 w-6\"\n            :class=\"{\n              'text-wp-error-100': queueInfo.paused,\n              'text-wp-text-100': !queueInfo.paused,\n            }\"\n          />\n        </div>\n      </div>\n    </template>\n\n    <div class=\"flex flex-col\">\n      <AdminQueueStats :stats=\"queueInfo?.stats\" />\n\n      <div v-if=\"tasks.length > 0\" class=\"flex flex-col\">\n        <p class=\"mt-6 mb-2 text-xl\">{{ $t('admin.settings.queue.tasks') }}</p>\n        <ListItem\n          v-for=\"task in tasks\"\n          :key=\"task.id\"\n          class=\"bg-wp-background-200! dark:bg-wp-background-200! mb-2 flex-col items-center gap-4\"\n        >\n          <div\n            class=\"border-wp-background-400 dark:border-wp-background-100 flex w-full items-center justify-between gap-2 border-b pb-2\"\n          >\n            <div\n              class=\"flex items-center gap-2\"\n              :title=\"\n                task.status === 'pending'\n                  ? $t('admin.settings.queue.task_pending')\n                  : task.status === 'running'\n                    ? $t('admin.settings.queue.task_running')\n                    : $t('admin.settings.queue.task_waiting_on_deps')\n              \"\n            >\n              <Icon\n                :name=\"\n                  task.status === 'pending'\n                    ? 'status-pending'\n                    : task.status === 'running'\n                      ? 'status-running'\n                      : 'status-declined'\n                \"\n                :class=\"{\n                  'text-wp-error-100': task.status === 'waiting_on_deps',\n                  'text-wp-state-info-100': task.status === 'running',\n                  'text-wp-state-neutral-100': task.status === 'pending',\n                }\"\n              />\n              <span>{{ task.name }}</span>\n            </div>\n            <div class=\"ml-auto flex items-center gap-2\">\n              <span class=\"flex gap-2\">\n                <Badge v-if=\"task.agent_name\" :label=\"$t('admin.settings.queue.agent')\" :value=\"task.agent_name\" />\n                <Badge\n                  v-if=\"task.dependencies\"\n                  :label=\"$t('admin.settings.queue.waiting_for')\"\n                  :value=\"task.dependencies.join(', ')\"\n                />\n              </span>\n            </div>\n            <div class=\"ml-2 flex items-center gap-2\">\n              <IconButton\n                v-if=\"task.pipeline_number\"\n                icon=\"chevron-right\"\n                :title=\"$t('repo.pipeline.view')\"\n                class=\"h-8 w-8\"\n                :to=\"{\n                  name: 'repo-pipeline',\n                  params: { repoId: task.repo_id, pipelineId: task.pipeline_number, stepId: task.pid },\n                }\"\n              />\n            </div>\n          </div>\n          <div class=\"flex w-full flex-wrap gap-2\">\n            <template v-for=\"(value, label) in task.labels\">\n              <Badge v-if=\"value\" :key=\"label\" :label=\"label.toString()\" :value=\"value\" />\n            </template>\n          </div>\n        </ListItem>\n      </div>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport AdminQueueStats from '~/components/admin/settings/queue/AdminQueueStats.vue';\nimport Badge from '~/components/atomic/Badge.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useInterval } from '~/compositions/useInterval';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { QueueInfo } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\n\nconst queueInfo = ref<QueueInfo>();\n\nconst tasks = computed(() => {\n  const _tasks = [];\n\n  if (queueInfo.value?.running) {\n    _tasks.push(...queueInfo.value.running.map((task) => ({ ...task, status: 'running' })));\n  }\n\n  if (queueInfo.value?.pending) {\n    _tasks.push(...queueInfo.value.pending.map((task) => ({ ...task, status: 'pending' })));\n  }\n\n  if (queueInfo.value?.waiting_on_deps) {\n    _tasks.push(...queueInfo.value.waiting_on_deps.map((task) => ({ ...task, status: 'waiting_on_deps' })));\n  }\n\n  return _tasks\n    .map((task) => ({\n      ...task,\n      labels: Object.fromEntries(Object.entries(task.labels).filter(([key]) => key !== 'org-id')),\n    }))\n    .toSorted((a, b) => a.id - b.id);\n});\n\nasync function loadQueueInfo() {\n  queueInfo.value = await apiClient.getQueueInfo();\n}\n\nasync function pauseQueue() {\n  await apiClient.pauseQueue();\n  await loadQueueInfo();\n  notifications.notify({\n    title: t('admin.settings.queue.paused'),\n    type: 'success',\n  });\n}\n\nasync function resumeQueue() {\n  await apiClient.resumeQueue();\n  await loadQueueInfo();\n  notifications.notify({\n    title: t('admin.settings.queue.resumed'),\n    type: 'success',\n  });\n}\n\nuseInterval(loadQueueInfo, 5 * 1000);\n\nuseWPTitle(computed(() => [t('admin.settings.queue.queue'), t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminRegistries.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('registries.registries')\"\n    :description=\"$t('admin.settings.registries.desc')\"\n    docs-url=\"docs/usage/registries\"\n  >\n    <template #headerActions>\n      <Button\n        v-if=\"selectedRegistry\"\n        :text=\"$t('registries.show')\"\n        start-icon=\"back\"\n        @click=\"selectedRegistry = undefined\"\n      />\n      <Button v-else :text=\"$t('registries.add')\" start-icon=\"plus\" @click=\"showAddRegistry\" />\n    </template>\n\n    <template #headerEnd>\n      <Warning class=\"mt-4 text-sm\" :text=\"$t('admin.settings.registries.warning')\" />\n    </template>\n\n    <RegistryList\n      v-if=\"!selectedRegistry\"\n      v-model=\"registries\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editRegistry\"\n      @delete=\"deleteRegistry\"\n    />\n\n    <RegistryEdit\n      v-else\n      v-model=\"selectedRegistry\"\n      :is-saving=\"isSaving\"\n      @save=\"createRegistry\"\n      @cancel=\"selectedRegistry = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Warning from '~/components/atomic/Warning.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport RegistryEdit from '~/components/registry/RegistryEdit.vue';\nimport RegistryList from '~/components/registry/RegistryList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Registry } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptyRegistry: Partial<Registry> = {\n  address: '',\n  username: '',\n  password: '',\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst selectedRegistry = ref<Partial<Registry>>();\nconst isEditingRegistry = computed(() => !!selectedRegistry.value?.id);\n\nasync function loadRegistries(page: number): Promise<Registry[] | null> {\n  return apiClient.getGlobalRegistryList({ page });\n}\n\nconst { resetPage, data: registries, loading } = usePagination(loadRegistries, () => !selectedRegistry.value);\n\nconst { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedRegistry.value) {\n    throw new Error(\"Unexpected: Can't get registry\");\n  }\n\n  if (isEditingRegistry.value) {\n    await apiClient.updateGlobalRegistry(selectedRegistry.value);\n  } else {\n    await apiClient.createGlobalRegistry(selectedRegistry.value);\n  }\n  notifications.notify({\n    title: isEditingRegistry.value ? i18n.t('registries.saved') : i18n.t('registries.created'),\n    type: 'success',\n  });\n  selectedRegistry.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {\n  await apiClient.deleteGlobalRegistry(_registry.address);\n  notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editRegistry(registry: Registry) {\n  selectedRegistry.value = deepClone(registry);\n}\n\nfunction showAddRegistry() {\n  selectedRegistry.value = deepClone(emptyRegistry);\n}\n\nuseWPTitle(computed(() => [i18n.t('registries.registries'), i18n.t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminRepos.vue",
    "content": "<template>\n  <Settings :title=\"$t('admin.settings.repos.repos')\" :description=\"$t('admin.settings.repos.desc')\">\n    <template #headerActions>\n      <Button\n        start-icon=\"heal\"\n        :is-loading=\"isRepairingRepos\"\n        :text=\"$t('admin.settings.repos.repair.repair')\"\n        @click=\"repairRepos\"\n      />\n    </template>\n\n    <div class=\"text-wp-text-100 space-y-4\">\n      <ListItem\n        v-for=\"repo in repos\"\n        :key=\"repo.id\"\n        class=\"bg-wp-background-200 dark:bg-wp-background-200! items-center gap-2\"\n      >\n        <span>{{ repo.full_name }}</span>\n        <div class=\"ml-auto flex items-center gap-2\">\n          <Badge\n            v-if=\"!repo.active\"\n            class=\"md:display-unset mr-2 hidden\"\n            :value=\"$t('admin.settings.repos.disabled')\"\n          />\n          <IconButton\n            icon=\"chevron-right\"\n            :title=\"$t('admin.settings.repos.view')\"\n            class=\"h-8 w-8\"\n            :to=\"{ name: 'repo', params: { repoId: repo.id } }\"\n          />\n          <IconButton\n            icon=\"settings-outline\"\n            :title=\"$t('admin.settings.repos.settings')\"\n            class=\"h-8 w-8\"\n            :to=\"{ name: 'repo-settings', params: { repoId: repo.id } }\"\n          />\n        </div>\n      </ListItem>\n\n      <div v-if=\"loading\" class=\"flex justify-center\">\n        <Icon name=\"spinner\" class=\"animate-spin\" />\n      </div>\n      <div v-else-if=\"repos?.length === 0\" class=\"ml-2\">{{ $t('admin.settings.repos.none') }}</div>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Repo } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nasync function loadRepos(page: number): Promise<Repo[] | null> {\n  return apiClient.getAllRepos({ page });\n}\n\nconst { data: repos, loading } = usePagination(loadRepos);\n\nconst { doSubmit: repairRepos, isLoading: isRepairingRepos } = useAsyncAction(async () => {\n  await apiClient.repairAllRepos();\n  notifications.notify({ title: i18n.t('admin.settings.repos.repair.success'), type: 'success' });\n});\n\nuseWPTitle(computed(() => [i18n.t('admin.settings.repos.repos'), i18n.t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminSecrets.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('secrets.secrets')\"\n    :description=\"$t('admin.settings.secrets.desc')\"\n    docs-url=\"docs/usage/secrets\"\n  >\n    <template #headerActions>\n      <Button v-if=\"selectedSecret\" :text=\"$t('secrets.show')\" start-icon=\"back\" @click=\"selectedSecret = undefined\" />\n      <Button v-else :text=\"$t('secrets.add')\" start-icon=\"plus\" @click=\"showAddSecret\" />\n    </template>\n\n    <template #headerEnd>\n      <Warning class=\"mt-4 text-sm\" :text=\"$t('admin.settings.secrets.warning')\" />\n    </template>\n\n    <SecretList\n      v-if=\"!selectedSecret\"\n      v-model=\"secrets\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editSecret\"\n      @delete=\"deleteSecret\"\n    />\n\n    <SecretEdit\n      v-else\n      v-model=\"selectedSecret\"\n      :is-saving=\"isSaving\"\n      @save=\"createSecret\"\n      @cancel=\"selectedSecret = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Warning from '~/components/atomic/Warning.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport SecretEdit from '~/components/secrets/SecretEdit.vue';\nimport SecretList from '~/components/secrets/SecretList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Secret } from '~/lib/api/types';\nimport { WebhookEvents } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptySecret: Partial<Secret> = {\n  name: '',\n  value: '',\n  images: [],\n  events: [WebhookEvents.Push],\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst selectedSecret = ref<Partial<Secret>>();\nconst isEditingSecret = computed(() => !!selectedSecret.value?.id);\n\nasync function loadSecrets(page: number): Promise<Secret[] | null> {\n  return apiClient.getGlobalSecretList({ page });\n}\n\nconst { resetPage, data: secrets, loading } = usePagination(loadSecrets, () => !selectedSecret.value);\n\nconst { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedSecret.value) {\n    throw new Error(\"Unexpected: Can't get secret\");\n  }\n\n  if (isEditingSecret.value) {\n    await apiClient.updateGlobalSecret(selectedSecret.value);\n  } else {\n    await apiClient.createGlobalSecret(selectedSecret.value);\n  }\n  notifications.notify({\n    title: isEditingSecret.value ? i18n.t('secrets.saved') : i18n.t('secrets.created'),\n    type: 'success',\n  });\n  selectedSecret.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {\n  await apiClient.deleteGlobalSecret(_secret.name);\n  notifications.notify({ title: i18n.t('secrets.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editSecret(secret: Secret) {\n  selectedSecret.value = deepClone(secret);\n}\n\nfunction showAddSecret() {\n  selectedSecret.value = deepClone(emptySecret);\n}\n\nuseWPTitle(computed(() => [i18n.t('secrets.secrets'), i18n.t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminSettingsWrapper.vue",
    "content": "<template>\n  <Scaffold enable-tabs>\n    <template #title>\n      {{ $t('admin.settings.settings') }}\n    </template>\n    <Tab icon=\"info\" :to=\"{ name: 'admin-settings' }\" :title=\"$t('info')\" />\n    <Tab icon=\"secret\" :to=\"{ name: 'admin-settings-secrets' }\" :title=\"$t('secrets.secrets')\" />\n    <Tab icon=\"docker\" :to=\"{ name: 'admin-settings-registries' }\" :title=\"$t('registries.registries')\" />\n    <Tab icon=\"repo\" :to=\"{ name: 'admin-settings-repos' }\" :title=\"$t('admin.settings.repos.repos')\" />\n    <Tab icon=\"user\" :to=\"{ name: 'admin-settings-users' }\" :title=\"$t('admin.settings.users.users')\" />\n    <Tab icon=\"org\" :to=\"{ name: 'admin-settings-orgs' }\" :title=\"$t('admin.settings.orgs.orgs')\" />\n    <Tab icon=\"agent\" :to=\"{ name: 'admin-settings-agents' }\" :title=\"$t('admin.settings.agents.agents')\" />\n    <Tab icon=\"tray-full\" :to=\"{ name: 'admin-settings-queue' }\" :title=\"$t('admin.settings.queue.queue')\" />\n    <Tab icon=\"forge\" :to=\"{ name: 'admin-settings-forges' }\" :title=\"$t('forges')\" />\n\n    <router-view />\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { onMounted } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useNotifications from '~/compositions/useNotifications';\n\nconst notifications = useNotifications();\nconst router = useRouter();\nconst i18n = useI18n();\nconst { user } = useAuthentication();\n\nonMounted(async () => {\n  if (!user?.admin) {\n    notifications.notify({ type: 'error', title: i18n.t('admin.settings.not_allowed') });\n    await router.replace({ name: 'home' });\n  }\n});\n</script>\n"
  },
  {
    "path": "web/src/views/admin/AdminUsers.vue",
    "content": "<template>\n  <Settings :title=\"$t('admin.settings.users.users')\" :description=\"$t('admin.settings.users.desc')\">\n    <template #headerActions>\n      <Button\n        v-if=\"selectedUser\"\n        :text=\"$t('admin.settings.users.show')\"\n        start-icon=\"back\"\n        @click=\"selectedUser = undefined\"\n      />\n      <Button v-else :text=\"$t('admin.settings.users.add')\" start-icon=\"plus\" @click=\"showAddUser\" />\n    </template>\n\n    <div v-if=\"!selectedUser\" class=\"text-wp-text-100 space-y-4\">\n      <ListItem\n        v-for=\"user in users\"\n        :key=\"user.id\"\n        class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center gap-2\"\n      >\n        <img v-if=\"user.avatar_url\" class=\"h-6 rounded-md\" :src=\"user.avatar_url\" />\n        <span>{{ user.login }}</span>\n        <Badge\n          v-if=\"user.admin\"\n          class=\"md:display-unset ml-auto hidden\"\n          :value=\"$t('admin.settings.users.admin.admin')\"\n        />\n        <div class=\"flex items-center gap-2\" :class=\"{ 'ml-auto': !user.admin, 'ml-2': user.admin }\">\n          <IconButton\n            icon=\"edit\"\n            :title=\"$t('admin.settings.users.edit_user')\"\n            class=\"md:display-unset h-8 w-8\"\n            @click=\"editUser(user)\"\n          />\n          <IconButton\n            icon=\"trash\"\n            :title=\"$t('admin.settings.users.delete_user')\"\n            class=\"hover:text-wp-error-100 h-8 w-8\"\n            :is-loading=\"isDeleting\"\n            @click=\"deleteUser(user)\"\n          />\n        </div>\n      </ListItem>\n\n      <div v-if=\"loading\" class=\"flex justify-center\">\n        <Icon name=\"spinner\" class=\"animate-spin\" />\n      </div>\n      <div v-else-if=\"users?.length === 0\" class=\"ml-2\">{{ $t('admin.settings.users.none') }}</div>\n    </div>\n    <div v-else>\n      <form @submit.prevent=\"saveUser\">\n        <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.users.login')\">\n          <TextField :id=\"id\" v-model=\"selectedUser.login\" :disabled=\"isEditingUser\" />\n        </InputField>\n\n        <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.users.email')\">\n          <TextField :id=\"id\" v-model=\"selectedUser.email\" />\n        </InputField>\n\n        <InputField v-slot=\"{ id }\" :label=\"$t('admin.settings.users.avatar_url')\">\n          <div class=\"flex gap-2\">\n            <img v-if=\"selectedUser.avatar_url\" class=\"h-8 w-8 rounded-md\" :src=\"selectedUser.avatar_url\" />\n            <TextField :id=\"id\" v-model=\"selectedUser.avatar_url\" />\n          </div>\n        </InputField>\n\n        <InputField :label=\"$t('admin.settings.users.admin.admin')\">\n          <Checkbox\n            :model-value=\"selectedUser.admin || false\"\n            :label=\"$t('admin.settings.users.admin.placeholder')\"\n            @update:model-value=\"selectedUser!.admin = $event\"\n          />\n        </InputField>\n\n        <div class=\"flex gap-2\">\n          <Button :text=\"$t('admin.settings.users.cancel')\" @click=\"selectedUser = undefined\" />\n\n          <Button\n            :is-loading=\"isSaving\"\n            type=\"submit\"\n            color=\"green\"\n            :text=\"isEditingUser ? $t('admin.settings.users.save') : $t('admin.settings.users.add')\"\n          />\n        </div>\n      </form>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { User } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\n\nconst selectedUser = ref<Partial<User>>();\nconst isEditingUser = computed(() => !!selectedUser.value?.id);\n\nasync function loadUsers(page: number): Promise<User[] | null> {\n  return apiClient.getUsers({ page });\n}\n\nconst { resetPage, data: users, loading } = usePagination(loadUsers, () => !selectedUser.value);\n\nconst { doSubmit: saveUser, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedUser.value) {\n    throw new Error(\"Unexpected: Can't get user\");\n  }\n\n  if (isEditingUser.value) {\n    await apiClient.updateUser(selectedUser.value);\n    notifications.notify({\n      title: t('admin.settings.users.saved'),\n      type: 'success',\n    });\n  } else {\n    selectedUser.value = await apiClient.createUser(selectedUser.value);\n    notifications.notify({\n      title: t('admin.settings.users.created'),\n      type: 'success',\n    });\n  }\n  selectedUser.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteUser, isLoading: isDeleting } = useAsyncAction(async (_user: User) => {\n  // eslint-disable-next-line no-alert\n  if (!confirm(t('admin.settings.users.delete_confirm'))) {\n    return;\n  }\n\n  await apiClient.deleteUser(_user);\n  notifications.notify({ title: t('admin.settings.users.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editUser(user: User) {\n  selectedUser.value = deepClone(user);\n}\n\nfunction showAddUser() {\n  selectedUser.value = { login: '' };\n}\n\nuseWPTitle(computed(() => [t('admin.settings.users.users'), t('admin.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/admin/forges/AdminForge.vue",
    "content": "<template>\n  <Settings :title=\"$t('forges')\" :description=\"$t('forges_desc')\">\n    <template #headerActions>\n      <Button :text=\"$t('show_forges')\" start-icon=\"back\" :to=\"{ name: 'admin-settings-forges' }\" />\n    </template>\n\n    <AdminForgeForm v-if=\"forge\" v-model:forge=\"forge\" :is-saving=\"isSaving\" @submit=\"saveForge\" />\n    <div v-else-if=\"loading\" class=\"flex justify-center\">\n      <Icon name=\"spinner\" class=\"animate-spin\" />\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute } from 'vue-router';\n\nimport AdminForgeForm from '~/components/admin/settings/forges/AdminForgeForm.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport type { Forge } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\nconst route = useRoute();\n\nconst forgeId = computed(() => Number.parseInt(route.params.forgeId.toString(), 10));\nconst forge = ref<Forge>();\nconst loading = ref(false);\n\nasync function load() {\n  loading.value = true;\n  forge.value = await apiClient.getForge(forgeId.value);\n  loading.value = false;\n}\n\nonMounted(load);\nwatch(forgeId, load);\n\nconst { doSubmit: saveForge, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!forge.value) {\n    throw new Error(\"Unexpected: Can't get forge\");\n  }\n\n  await apiClient.updateForge(forge.value);\n  notifications.notify({\n    title: t('forge_saved'),\n    type: 'success',\n  });\n\n  await load(); // reload\n});\n</script>\n"
  },
  {
    "path": "web/src/views/admin/forges/AdminForgeCreate.vue",
    "content": "<template>\n  <Settings :title=\"$t('forges')\" :description=\"$t('forges_desc')\">\n    <template #headerActions>\n      <Button :text=\"$t('show_forges')\" start-icon=\"back\" :to=\"{ name: 'admin-settings-forges' }\" />\n    </template>\n\n    <AdminForgeForm v-model:forge=\"forge\" :is-saving=\"isSaving\" is-new @submit=\"saveForge\" />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport AdminForgeForm from '~/components/admin/settings/forges/AdminForgeForm.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport type { Forge } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\nconst router = useRouter();\n\nconst forge = ref<Partial<Forge>>({});\n\nconst { doSubmit: saveForge, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!forge.value) {\n    throw new Error(\"Unexpected: Can't get forge\");\n  }\n\n  forge.value = await apiClient.createForge(forge.value);\n  notifications.notify({\n    title: t('forge_created'),\n    type: 'success',\n  });\n\n  await router.push({ name: 'admin-settings-forge', params: { forgeId: forge.value.id } });\n});\n</script>\n"
  },
  {
    "path": "web/src/views/admin/forges/AdminForges.vue",
    "content": "<template>\n  <Settings :title=\"$t('forges')\" :description=\"$t('forges_desc')\">\n    <template #headerActions>\n      <Button :text=\"$t('add_forge')\" start-icon=\"plus\" :to=\"{ name: 'admin-settings-forge-create' }\" />\n    </template>\n\n    <div class=\"text-wp-text-100 space-y-4\">\n      <ListItem\n        v-for=\"forge in forges\"\n        :key=\"forge.id\"\n        class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center gap-2\"\n      >\n        <span>{{ forge.url.replace(/http(s):\\/\\//, '') }}</span>\n        <Badge class=\"md:display-unset ml-auto hidden\" :value=\"forge.type\" />\n        <div class=\"flex items-center gap-2\">\n          <IconButton\n            icon=\"edit\"\n            :title=\"$t('edit_forge')\"\n            class=\"h-8 w-8\"\n            :to=\"{ name: 'admin-settings-forge', params: { forgeId: forge.id } }\"\n          />\n          <IconButton\n            icon=\"trash\"\n            :title=\"$t('delete_forge')\"\n            class=\"hover:text-wp-error-100 h-8 w-8\"\n            :is-loading=\"isDeleting\"\n            @click=\"deleteForge(forge)\"\n          />\n        </div>\n      </ListItem>\n\n      <div v-if=\"loading\" class=\"flex justify-center\">\n        <Icon name=\"spinner\" class=\"animate-spin\" />\n      </div>\n      <div v-else-if=\"forges?.length === 0\" class=\"ml-2\">{{ $t('no_forges') }}</div>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport type { Forge } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { t } = useI18n();\n\nasync function loadForges(page: number): Promise<Forge[] | null> {\n  return apiClient.getForges({ page });\n}\n\nconst { resetPage, data: forges, loading } = usePagination(loadForges);\n\nconst { doSubmit: deleteForge, isLoading: isDeleting } = useAsyncAction(async (_forge: Forge) => {\n  // eslint-disable-next-line no-alert\n  if (!confirm(t('forge_delete_confirm'))) {\n    return;\n  }\n\n  await apiClient.deleteForge(_forge);\n  notifications.notify({ title: t('forge_deleted'), type: 'success' });\n  await resetPage();\n});\n</script>\n"
  },
  {
    "path": "web/src/views/cli/Auth.vue",
    "content": "<template>\n  <div class=\"m-auto flex flex-col gap-4\">\n    <div class=\"text-wp-text-100 text-center\">\n      <WoodpeckerLogo preserveAspectRatio=\"xMinYMin slice\" class=\"m-auto mb-8 w-32\" />\n      <template v-if=\"state === 'confirm'\">\n        <h1 class=\"text-4xl font-bold\">{{ $t('login_to_cli') }}</h1>\n        <p class=\"text-2xl\">{{ $t('login_to_cli_description') }}</p>\n      </template>\n      <template v-else-if=\"state === 'success'\">\n        <h1 class=\"text-4xl font-bold\">{{ $t('cli_login_success') }}</h1>\n        <p class=\"text-2xl\">{{ $t('return_to_cli') }}</p>\n      </template>\n      <template v-else-if=\"state === 'failed'\">\n        <h1 class=\"mt-4 text-4xl font-bold\">{{ $t('cli_login_failed') }}</h1>\n        <p class=\"text-2xl\">{{ $t('return_to_cli') }}</p>\n      </template>\n      <template v-else-if=\"state === 'denied'\">\n        <h1 class=\"mt-4 text-4xl font-bold\">{{ $t('cli_login_denied') }}</h1>\n        <p class=\"text-2xl\">{{ $t('return_to_cli') }}</p>\n      </template>\n    </div>\n\n    <div v-if=\"state === 'confirm'\" class=\"flex justify-center gap-4\">\n      <Button :text=\"$t('login_to_cli')\" color=\"green\" @click=\"sendToken(false)\" />\n      <Button :text=\"$t('abort')\" color=\"red\" @click=\"abortLogin\" />\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute } from 'vue-router';\n\nimport WoodpeckerLogo from '~/assets/logo.svg?component';\nimport Button from '~/components/atomic/Button.vue';\nimport useApiClient from '~/compositions/useApiClient';\n\nconst apiClient = useApiClient();\nconst route = useRoute();\nconst { t } = useI18n();\nconst state = ref<'confirm' | 'success' | 'failed' | 'denied'>('confirm');\n\nasync function sendToken(abort = false) {\n  const port = route.query.port as string;\n  if (!port) {\n    throw new Error('Unexpected: port not found');\n  }\n\n  const address = `http://localhost:${port}`;\n\n  const token = abort ? '' : await apiClient.getToken();\n\n  const resp = await fetch(`${address}/token`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({ token }),\n  });\n\n  if (abort) {\n    state.value = 'denied';\n    window.close();\n    return;\n  }\n\n  const data = (await resp.json()) as { ok: string };\n  if (data.ok === 'true') {\n    state.value = 'success';\n  } else {\n    state.value = 'failed';\n    // eslint-disable-next-line no-alert\n    alert(t('cli_login_failed'));\n  }\n}\n\nasync function abortLogin() {\n  await sendToken(true);\n}\n</script>\n"
  },
  {
    "path": "web/src/views/org/OrgDeprecatedRedirect.vue",
    "content": "<template>\n  <div />\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted } from 'vue';\nimport { useRoute, useRouter } from 'vue-router';\n\nimport useApiClient from '~/compositions/useApiClient';\n\nconst props = defineProps<{\n  orgName: string;\n}>();\nconst apiClient = useApiClient();\nconst route = useRoute();\nconst router = useRouter();\n\nonMounted(async () => {\n  const org = await apiClient.lookupOrg(props.orgName);\n\n  const path = route.path.replace(`/org/${props.orgName}`, `/orgs/${org?.id}`);\n\n  await router.replace({ path });\n});\n</script>\n"
  },
  {
    "path": "web/src/views/org/OrgRepos.vue",
    "content": "<template>\n  <Scaffold v-if=\"org && orgPermissions\" v-model:search=\"search\">\n    <template #title>\n      {{ org.name }}\n    </template>\n\n    <template #headerActions>\n      <IconButton\n        v-if=\"orgPermissions.admin\"\n        icon=\"settings\"\n        :to=\"{ name: org.is_user ? 'user' : 'org-settings-secrets' }\"\n        :title=\"$t('settings')\"\n      />\n    </template>\n\n    <div class=\"flex flex-col gap-4\">\n      <RepoItem v-for=\"repo in reposLastActivity\" :key=\"repo.id\" :repo=\"repo\" />\n    </div>\n    <div v-if=\"(reposLastActivity || []).length <= 0\" class=\"text-center\">\n      <span class=\"text-wp-text-100 m-auto\">{{ $t('repo.user_none') }}</span>\n    </div>\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport RepoItem from '~/components/repo/RepoItem.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useRepos from '~/compositions/useRepos';\nimport { useRepoSearch } from '~/compositions/useRepoSearch';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { useRepoStore } from '~/store/repos';\n\nconst repoStore = useRepoStore();\nconst { repoWithLastPipeline, sortReposByLastActivity } = useRepos();\n\nconst org = requiredInject('org');\nconst orgPermissions = requiredInject('org-permissions');\n\nconst search = ref('');\nconst repos = computed(() =>\n  [...repoStore.repos.values()].filter((repo) => repo.org_id === org.value?.id).map(repoWithLastPipeline),\n);\nconst { searchedRepos } = useRepoSearch(repos, search);\nconst reposLastActivity = computed(() => sortReposByLastActivity(searchedRepos.value || []));\n\nonMounted(async () => {\n  await repoStore.loadRepos(); // TODO: load only org repos\n});\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repositories.title'), org.value.name]));\n</script>\n"
  },
  {
    "path": "web/src/views/org/OrgWrapper.vue",
    "content": "<template>\n  <Scaffold v-if=\"org && orgPermissions && route.meta.orgHeader\">\n    <template #title>\n      {{ org.name }}\n    </template>\n\n    <template #headerActions>\n      <IconButton\n        v-if=\"orgPermissions.admin\"\n        :to=\"{ name: org.is_user ? 'user' : 'org-settings-secrets' }\"\n        :title=\"$t('settings')\"\n        icon=\"settings\"\n      />\n    </template>\n\n    <router-view />\n  </Scaffold>\n  <router-view v-else-if=\"org && orgPermissions\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport type { Ref } from 'vue';\nimport { computed, onMounted, ref, watch } from 'vue';\nimport { useRoute } from 'vue-router';\n\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { provide } from '~/compositions/useInjectProvide';\nimport type { Org, OrgPermissions } from '~/lib/api/types';\n\nconst props = defineProps<{\n  orgId: string;\n}>();\n\nconst orgId = computed(() => Number.parseInt(props.orgId, 10));\nconst apiClient = useApiClient();\nconst route = useRoute();\n\nconst org = ref<Org>();\nconst orgPermissions = ref<OrgPermissions>();\nprovide('org', org as Ref<Org>); // can't be undefined because of v-if in template\nprovide('org-permissions', orgPermissions as Ref<OrgPermissions>); // can't be undefined because of v-if in template\n\nasync function load() {\n  org.value = await apiClient.getOrg(orgId.value);\n  orgPermissions.value = await apiClient.getOrgPermissions(org.value.id);\n}\n\nonMounted(load);\nwatch(orgId, load);\n</script>\n"
  },
  {
    "path": "web/src/views/org/settings/OrgAgents.vue",
    "content": "<template>\n  <AgentManager\n    :description=\"$t('org.settings.agents.desc')\"\n    :load-agents=\"loadAgents\"\n    :create-agent=\"createAgent\"\n    :update-agent=\"updateAgent\"\n    :delete-agent=\"deleteAgent\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport AgentManager from '~/components/agent/AgentManager.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Agent } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst org = requiredInject('org');\n\nconst loadAgents = (page: number) => apiClient.getOrgAgents(org.value.id, { page });\nconst createAgent = (agent: Partial<Agent>) => apiClient.createOrgAgent(org.value.id, agent);\nconst updateAgent = (agent: Agent) => apiClient.updateOrgAgent(org.value.id, agent.id, agent);\nconst deleteAgent = (agent: Agent) => apiClient.deleteOrgAgent(org.value.id, agent.id);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('admin.settings.agents.agents'), org.value.name]));\n</script>\n"
  },
  {
    "path": "web/src/views/org/settings/OrgRegistries.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('registries.registries')\"\n    :description=\"$t('org.settings.registries.desc')\"\n    docs-url=\"docs/usage/registries\"\n  >\n    <template #headerActions>\n      <Button\n        v-if=\"selectedRegistry\"\n        :text=\"$t('registries.show')\"\n        start-icon=\"back\"\n        @click=\"selectedRegistry = undefined\"\n      />\n      <Button v-else :text=\"$t('registries.add')\" start-icon=\"plus\" @click=\"showAddRegistry\" />\n    </template>\n\n    <RegistryList\n      v-if=\"!selectedRegistry\"\n      v-model=\"registries\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editRegistry\"\n      @delete=\"deleteRegistry\"\n    />\n\n    <RegistryEdit\n      v-else\n      v-model=\"selectedRegistry\"\n      :is-saving=\"isSaving\"\n      @save=\"createRegistry\"\n      @cancel=\"selectedRegistry = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport RegistryEdit from '~/components/registry/RegistryEdit.vue';\nimport RegistryList from '~/components/registry/RegistryList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Registry } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptyRegistry: Partial<Registry> = {\n  address: '',\n  username: '',\n  password: '',\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst org = requiredInject('org');\nconst selectedRegistry = ref<Partial<Registry>>();\nconst isEditing = computed(() => !!selectedRegistry.value?.id);\n\nasync function loadRegistries(page: number, level: 'org' | 'global'): Promise<Registry[] | null> {\n  switch (level) {\n    case 'org':\n      return apiClient.getOrgRegistryList(org.value.id, { page });\n    case 'global':\n      return apiClient.getGlobalRegistryList({ page });\n    default:\n      throw new Error(`Unexpected level: ${level}`);\n  }\n}\n\nconst {\n  resetPage,\n  data: _registries,\n  loading,\n} = usePagination(loadRegistries, () => !selectedRegistry.value, {\n  each: ['org', 'global'],\n});\nconst registries = computed(() => {\n  const registriesList: Record<string, Registry & { edit?: boolean; level: 'org' | 'global' }> = {};\n\n  for (const level of ['org', 'global']) {\n    for (const registry of _registries.value) {\n      if (\n        ((level === 'org' && registry.org_id !== 0) || (level === 'global' && registry.org_id === 0)) &&\n        !registriesList[registry.address]\n      ) {\n        registriesList[registry.address] = { ...registry, edit: registry.org_id !== 0, level };\n      }\n    }\n  }\n\n  const levelsOrder = {\n    global: 0,\n    org: 1,\n  };\n\n  return Object.values(registriesList)\n    .toSorted((a, b) => a.address.localeCompare(b.address))\n    .toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);\n});\n\nconst { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedRegistry.value) {\n    throw new Error(\"Unexpected: Can't get registry\");\n  }\n\n  if (isEditing.value) {\n    await apiClient.updateOrgRegistry(org.value.id, selectedRegistry.value);\n  } else {\n    await apiClient.createOrgRegistry(org.value.id, selectedRegistry.value);\n  }\n  notifications.notify({\n    title: isEditing.value ? i18n.t('registries.saved') : i18n.t('registries.created'),\n    type: 'success',\n  });\n  selectedRegistry.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {\n  await apiClient.deleteOrgRegistry(org.value.id, _registry.address);\n  notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editRegistry(registry: Registry) {\n  selectedRegistry.value = deepClone(registry);\n}\n\nfunction showAddRegistry() {\n  selectedRegistry.value = deepClone(emptyRegistry);\n}\n\nuseWPTitle(computed(() => [i18n.t('registries.registries'), org.value.name]));\n</script>\n"
  },
  {
    "path": "web/src/views/org/settings/OrgSecrets.vue",
    "content": "<template>\n  <Settings :title=\"$t('secrets.secrets')\" :description=\"$t('org.settings.secrets.desc')\" docs-url=\"docs/usage/secrets\">\n    <template #headerActions>\n      <Button v-if=\"selectedSecret\" :text=\"$t('secrets.show')\" start-icon=\"back\" @click=\"selectedSecret = undefined\" />\n      <Button v-else :text=\"$t('secrets.add')\" start-icon=\"plus\" @click=\"showAddSecret\" />\n    </template>\n\n    <SecretList\n      v-if=\"!selectedSecret\"\n      v-model=\"secrets\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editSecret\"\n      @delete=\"deleteSecret\"\n    />\n\n    <SecretEdit\n      v-else\n      v-model=\"selectedSecret\"\n      :is-saving=\"isSaving\"\n      @save=\"createSecret\"\n      @cancel=\"selectedSecret = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport SecretEdit from '~/components/secrets/SecretEdit.vue';\nimport SecretList from '~/components/secrets/SecretList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { WebhookEvents } from '~/lib/api/types';\nimport type { Secret } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptySecret: Partial<Secret> = {\n  name: '',\n  value: '',\n  images: [],\n  events: [WebhookEvents.Push],\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst org = requiredInject('org');\nconst selectedSecret = ref<Partial<Secret>>();\nconst isEditingSecret = computed(() => !!selectedSecret.value?.id);\n\nasync function loadSecrets(page: number): Promise<Secret[] | null> {\n  return apiClient.getOrgSecretList(org.value.id, { page });\n}\n\nconst { resetPage, data: secrets, loading } = usePagination(loadSecrets, () => !selectedSecret.value);\n\nconst { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedSecret.value) {\n    throw new Error(\"Unexpected: Can't get secret\");\n  }\n\n  if (isEditingSecret.value) {\n    await apiClient.updateOrgSecret(org.value.id, selectedSecret.value);\n  } else {\n    await apiClient.createOrgSecret(org.value.id, selectedSecret.value);\n  }\n  notifications.notify({\n    title: isEditingSecret.value ? i18n.t('secrets.saved') : i18n.t('secrets.created'),\n    type: 'success',\n  });\n  selectedSecret.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {\n  await apiClient.deleteOrgSecret(org.value.id, _secret.name);\n  notifications.notify({ title: i18n.t('secrets.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editSecret(secret: Secret) {\n  selectedSecret.value = deepClone(secret);\n}\n\nfunction showAddSecret() {\n  selectedSecret.value = deepClone(emptySecret);\n}\n\nuseWPTitle(computed(() => [i18n.t('secrets.secrets'), org.value.name]));\n</script>\n"
  },
  {
    "path": "web/src/views/org/settings/OrgSettingsWrapper.vue",
    "content": "<template>\n  <Scaffold v-if=\"org\" enable-tabs :go-back=\"goBack\">\n    <template #title>\n      <span>\n        <router-link :to=\"{ name: 'org' }\" class=\"hover:underline\">{{\n          org.name\n          /* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */\n        }}</router-link>\n        /\n        {{ $t('settings') }}\n      </span>\n    </template>\n\n    <Tab icon=\"secret\" :to=\"{ name: 'org-settings-secrets' }\" :title=\"$t('secrets.secrets')\" />\n    <Tab icon=\"docker\" :to=\"{ name: 'org-settings-registries' }\" :title=\"$t('registries.registries')\" />\n    <Tab\n      v-if=\"userRegisteredAgents\"\n      icon=\"agent\"\n      :to=\"{ name: 'org-settings-agents' }\"\n      :title=\"$t('admin.settings.agents.agents')\"\n    />\n\n    <router-view />\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { onMounted } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport useConfig from '~/compositions/useConfig';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useRouteBack } from '~/compositions/useRouteBack';\n\nconst notifications = useNotifications();\nconst router = useRouter();\nconst i18n = useI18n();\n\nconst { userRegisteredAgents } = useConfig();\n\nconst org = requiredInject('org');\nconst orgPermissions = requiredInject('org-permissions');\n\nonMounted(async () => {\n  if (!orgPermissions.value?.admin) {\n    notifications.notify({ type: 'error', title: i18n.t('org.settings.not_allowed') });\n    await router.replace({ name: 'home' });\n  }\n});\n\nconst goBack = useRouteBack({ name: 'org' });\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoBranch.vue",
    "content": "<template>\n  <div class=\"mb-4 flex w-full justify-center\">\n    <span class=\"text-wp-text-100 text-xl\">{{ $t('repo.pipeline.pipelines_for', { branch }) }}</span>\n  </div>\n  <PipelineList :pipelines=\"pipelines\" :repo=\"repo\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport PipelineList from '~/components/repo/pipeline/PipelineList.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst props = defineProps<{\n  branch: string;\n}>();\n\nconst branch = toRef(props, 'branch');\nconst repo = requiredInject('repo');\n\nconst allPipelines = requiredInject('pipelines');\nconst pipelines = computed(() =>\n  allPipelines.value.filter(\n    (b) =>\n      b.branch === branch.value &&\n      b.event !== 'pull_request' &&\n      b.event !== 'pull_request_closed' &&\n      b.event !== 'pull_request_metadata',\n  ),\n);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repo.activity'), branch.value, repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoBranches.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <ListItem\n      v-for=\"branch in branchesWithDefaultBranchFirst\"\n      :key=\"branch\"\n      class=\"text-wp-text-100\"\n      :to=\"{ name: 'repo-branch', params: { branch } }\"\n    >\n      {{ branch }}\n      <Badge v-if=\"branch === repo?.default_branch\" :value=\"$t('default')\" class=\"ml-auto\" />\n    </ListItem>\n    <div v-if=\"loading\" class=\"text-wp-text-100 flex justify-center\">\n      <Icon name=\"spinner\" />\n    </div>\n    <Panel v-else-if=\"branches.length === 0\" class=\"flex justify-center\">\n      {{ $t('empty_list', { entity: $t('repo.branches') }) }}\n    </Panel>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Badge from '~/components/atomic/Badge.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst apiClient = useApiClient();\n\nconst repo = requiredInject('repo');\n\nasync function loadBranches(page: number): Promise<string[]> {\n  return apiClient.getRepoBranches(repo.value.id, { page });\n}\n\nconst { resetPage, data: branches, loading } = usePagination(loadBranches);\n\nconst branchesWithDefaultBranchFirst = computed(() =>\n  branches.value.toSorted((a, b) => {\n    if (a === repo.value.default_branch) {\n      return -1;\n    }\n\n    if (b === repo.value.default_branch) {\n      return 1;\n    }\n\n    return 0;\n  }),\n);\n\nwatch(repo, resetPage);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repo.branches'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoDeprecatedRedirect.vue",
    "content": "<template>\n  <div />\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted } from 'vue';\nimport { useRoute, useRouter } from 'vue-router';\n\nimport useApiClient from '~/compositions/useApiClient';\n\nconst props = defineProps<{\n  repoOwner: string;\n  repoName: string;\n}>();\nconst apiClient = useApiClient();\nconst route = useRoute();\nconst router = useRouter();\n\nonMounted(async () => {\n  const repo = await apiClient.lookupRepo(props.repoOwner, props.repoName);\n\n  // {\n  //   path: ':pipelineId',\n  //   redirect: (route) => ({ name: 'repo-pipeline', params: route.params }),\n  // },\n  // {\n  //   path: 'build/:pipelineId',\n  //   redirect: (route) => ({ name: 'repo-pipeline', params: route.params }),\n  //   children: [\n  //     {\n  //       path: ':procId?',\n  //       redirect: (route) => ({ name: 'repo-pipeline', params: route.params }),\n  //     },\n  //     {\n  //       path: 'changed-files',\n  //       redirect: (route) => ({ name: 'repo-pipeline-changed-files', params: route.params }),\n  //     },\n  //     {\n  //       path: 'config',\n  //       redirect: (route) => ({ name: 'repo-pipeline-config', params: route.params }),\n  //     },\n  //   ],\n  // },\n\n  // TODO: support pipeline and build routes\n\n  const path = route.path\n    .replace(`/repos/${props.repoOwner}/${props.repoName}`, `/repos/${repo?.id}`)\n    .replace(`/${props.repoOwner}/${props.repoName}`, `/repos/${repo?.id}`);\n\n  await router.replace({ path });\n});\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoManualPipeline.vue",
    "content": "<template>\n  <Panel v-if=\"!loading\">\n    <form @submit.prevent=\"triggerManualPipeline\">\n      <span class=\"text-wp-text-100 text-xl\">{{ $t('repo.manual_pipeline.title') }}</span>\n      <InputField v-slot=\"{ id }\" :label=\"$t('repo.manual_pipeline.select_branch')\">\n        <SelectField :id=\"id\" v-model=\"payload.branch\" :options=\"branches\" required />\n      </InputField>\n      <InputField v-slot=\"{ id }\" :label=\"$t('repo.manual_pipeline.variables.title')\">\n        <span class=\"text-wp-text-alt-100 mb-2 text-sm\">{{ $t('repo.manual_pipeline.variables.desc') }}</span>\n        <KeyValueEditor\n          :id=\"id\"\n          v-model=\"payload.variables\"\n          :key-placeholder=\"$t('repo.manual_pipeline.variables.name')\"\n          :value-placeholder=\"$t('repo.manual_pipeline.variables.value')\"\n          :delete-title=\"$t('repo.manual_pipeline.variables.delete')\"\n          @update:is-valid=\"isVariablesValid = $event\"\n        />\n      </InputField>\n      <Button type=\"submit\" :text=\"$t('repo.manual_pipeline.trigger')\" :disabled=\"!isFormValid\" />\n    </form>\n  </Panel>\n  <div v-else class=\"text-wp-text-100 flex justify-center\">\n    <Icon name=\"spinner\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useNotification } from '@kyvg/vue3-notification';\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport KeyValueEditor from '~/components/form/KeyValueEditor.vue';\nimport SelectField from '~/components/form/SelectField.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { usePaginate } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\ndefineProps<{\n  open: boolean;\n}>();\n\nconst emit = defineEmits<{\n  (event: 'close'): void;\n}>();\n\nconst apiClient = useApiClient();\nconst notifications = useNotification();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\nconst repoPermissions = requiredInject('repo-permissions');\n\nconst router = useRouter();\nconst branches = ref<{ text: string; value: string }[]>([]);\nconst payload = ref<{ branch: string; variables: Record<string, string> }>({\n  branch: 'main',\n  variables: {},\n});\n\nconst isVariablesValid = ref(true);\n\nconst isFormValid = computed(() => {\n  return payload.value.branch !== '' && isVariablesValid.value;\n});\n\nconst pipelineOptions = computed(() => ({\n  ...payload.value,\n  variables: payload.value.variables,\n}));\n\nconst loading = ref(true);\nonMounted(async () => {\n  if (!repoPermissions.value.push) {\n    notifications.notify({ type: 'error', title: i18n.t('repo.settings.not_allowed') });\n    await router.replace({ name: 'home' });\n  }\n\n  const data = await usePaginate((page) => apiClient.getRepoBranches(repo.value.id, { page }));\n  branches.value = data.map((e) => ({\n    text: e,\n    value: e,\n  }));\n  loading.value = false;\n});\n\nasync function triggerManualPipeline() {\n  loading.value = true;\n  const pipeline = await apiClient.createPipeline(repo.value.id, pipelineOptions.value);\n\n  emit('close');\n\n  if (typeof pipeline == 'string') {\n    // if this is a string (http 204) there is no workflow to run with the 'manual' event\n\n    await router.push({\n      name: 'repo',\n    });\n\n    notifications.notify({ type: 'warn', title: i18n.t('repo.manual_pipeline.no_manual_workflows') });\n  } else {\n    await router.push({\n      name: 'repo-pipeline',\n      params: {\n        pipelineId: pipeline.number,\n      },\n    });\n  }\n\n  loading.value = false;\n}\n\nuseWPTitle(computed(() => [i18n.t('repo.manual_pipeline.trigger'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoPipelines.vue",
    "content": "<template>\n  <PipelineList\n    :pipelines=\"pipelines\"\n    :loading=\"pipelineStore.loading\"\n    :has-more=\"pipelineStore.hasMore\"\n    @load-more=\"loadMore\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport PipelineList from '~/components/repo/pipeline/PipelineList.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { usePipelineStore } from '~/store/pipelines';\n\n// TODO(4626): Refactor to use usePagination with server-side filtering,\n// so pipeline loading can move from RepoWrapper to individual list views.\nconst repo = requiredInject('repo');\nconst pipelines = requiredInject('pipelines');\nconst pipelineStore = usePipelineStore();\n\nconst page = ref(1);\n\nasync function loadMore() {\n  page.value += 1;\n  await pipelineStore.loadRepoPipelines(repo.value.id, page.value);\n}\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repo.activity'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoPullRequest.vue",
    "content": "<template>\n  <div class=\"mb-4 flex w-full justify-center\">\n    <span class=\"text-wp-text-100 text-xl\">{{ $t('repo.pipeline.pipelines_for_pr', { index: pullRequest }) }}</span>\n  </div>\n  <PipelineList :pipelines=\"pipelines\" :repo=\"repo\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport PipelineList from '~/components/repo/pipeline/PipelineList.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst props = defineProps<{\n  pullRequest: string;\n}>();\nconst pullRequest = toRef(props, 'pullRequest');\nconst repo = requiredInject('repo');\nif (!repo.value.pr_enabled || !repo.value.allow_pr) {\n  throw new Error('Unexpected: pull requests are disabled for repo');\n}\n\nconst allPipelines = requiredInject('pipelines');\nconst pipelines = computed(() =>\n  allPipelines.value.filter(\n    (b) =>\n      (b.event === 'pull_request' || b.event === 'pull_request_closed' || b.event === 'pull_request_metadata') &&\n      b.ref\n        .replaceAll('refs/pull/', '')\n        .replaceAll('refs/merge-requests/', '')\n        .replaceAll('refs/pull-requests/', '')\n        .replaceAll('/from', '')\n        .replaceAll('/merge', '')\n        .replaceAll('/head', '') === pullRequest.value,\n  ),\n);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repo.activity'), pullRequest.value, repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoPullRequests.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <ListItem\n      v-for=\"pullRequest in pullRequests\"\n      :key=\"pullRequest.index\"\n      class=\"text-wp-text-100\"\n      :to=\"{ name: 'repo-pull-request', params: { pullRequest: pullRequest.index } }\"\n    >\n      <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n      <span class=\"md:display-unset text-wp-text-alt-100 hidden\">#{{ pullRequest.index }}</span>\n      <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n      <span class=\"md:display-unset text-wp-text-alt-100 mx-2 hidden\">-</span>\n      <span class=\"text-wp-text-100 overflow-hidden text-ellipsis whitespace-nowrap underline md:no-underline\">{{\n        pullRequest.title\n      }}</span>\n    </ListItem>\n    <div v-if=\"loading\" class=\"text-wp-text-100 flex justify-center\">\n      <Icon name=\"spinner\" />\n    </div>\n    <Panel v-else-if=\"pullRequests.length === 0\" class=\"flex justify-center\">\n      {{ $t('empty_list', { entity: $t('repo.pull_requests') }) }}\n    </Panel>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Icon from '~/components/atomic/Icon.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { PullRequest } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\n\nconst repo = requiredInject('repo');\nif (!repo.value.pr_enabled || !repo.value.allow_pr) {\n  throw new Error('Unexpected: pull requests are disabled for repo');\n}\n\nasync function loadPullRequests(page: number): Promise<PullRequest[]> {\n  return apiClient.getRepoPullRequests(repo.value.id, { page });\n}\n\nconst { resetPage, data: pullRequests, loading } = usePagination(loadPullRequests);\n\nwatch(repo, resetPage);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('repo.pull_requests'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/RepoWrapper.vue",
    "content": "<template>\n  <Scaffold v-if=\"repo && repoPermissions && route.meta.repoHeader\" enable-tabs>\n    <template #title>\n      <span class=\"flex\">\n        <router-link :to=\"{ name: 'org', params: { orgId: repo.org_id } }\" class=\"hover:underline\">{{\n          repo.owner\n          /* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */\n        }}</router-link>\n        &nbsp;/\n        {{ repo.name }}\n      </span>\n    </template>\n    <template #headerActions>\n      <a v-if=\"badgeUrl\" :href=\"badgeUrl\" target=\"_blank\">\n        <img :src=\"badgeUrl\" />\n      </a>\n      <IconButton :href=\"repo.forge_url\" :title=\"$t('repo.open_in_forge')\" :icon=\"forgeIcon\" class=\"forge h-8 w-8\" />\n      <IconButton\n        v-if=\"repoPermissions.admin\"\n        :to=\"{ name: 'repo-settings' }\"\n        :title=\"$t('settings')\"\n        icon=\"settings\"\n      />\n    </template>\n\n    <template #tabActions>\n      <Button\n        v-if=\"repoPermissions.push && route.name !== 'repo-manual'\"\n        :text=\"$t('repo.manual_pipeline.trigger')\"\n        start-icon=\"manual-pipeline\"\n        :to=\"{ name: 'repo-manual' }\"\n      />\n      <Button\n        v-else-if=\"repoPermissions.push\"\n        :text=\"$t('repo.manual_pipeline.show_pipelines')\"\n        start-icon=\"back\"\n        :to=\"{ name: 'repo' }\"\n      />\n    </template>\n\n    <Tab icon=\"list-group\" :to=\"{ name: 'repo' }\" :title=\"$t('repo.activity')\" />\n    <Tab icon=\"branch\" :to=\"{ name: 'repo-branches' }\" match-children :title=\"$t('repo.branches')\" />\n    <Tab\n      v-if=\"repo.pr_enabled && repo.allow_pr\"\n      icon=\"pull-request\"\n      :to=\"{ name: 'repo-pull-requests' }\"\n      match-children\n      :title=\"$t('repo.pull_requests')\"\n    />\n\n    <router-view />\n  </Scaffold>\n  <router-view v-else-if=\"repo && repoPermissions\" />\n</template>\n\n<script lang=\"ts\" setup>\nimport type { Ref } from 'vue';\nimport { computed, onMounted, ref, toRef, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute, useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport type { IconNames } from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useConfig from '~/compositions/useConfig';\nimport { useForgeStore } from '~/compositions/useForgeStore';\nimport { provide } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport useRepos from '~/compositions/useRepos';\nimport type { Forge, Repo, RepoPermissions } from '~/lib/api/types';\nimport { usePipelineStore } from '~/store/pipelines';\nimport { useRepoStore } from '~/store/repos';\n\nconst props = defineProps<{\n  repoId: string;\n}>();\n\nconst _repoId = toRef(props, 'repoId');\nconst repositoryId = computed(() => Number.parseInt(_repoId.value, 10));\nconst repoStore = useRepoStore();\nconst pipelineStore = usePipelineStore();\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { isAuthenticated } = useAuthentication();\nconst route = useRoute();\nconst router = useRouter();\nconst i18n = useI18n();\nconst config = useConfig();\nconst forgeStore = useForgeStore();\nconst { updateLastAccess } = useRepos();\n\nconst repo = repoStore.getRepo(repositoryId);\nconst repoPermissions = ref<RepoPermissions>();\nconst pipelines = pipelineStore.getRepoPipelines(repositoryId);\nprovide('repo', repo as Ref<Repo>); // can't be undefined because of v-if in template\nprovide('repo-permissions', repoPermissions as Ref<RepoPermissions>); // can't be undefined because of v-if in template\nprovide('pipelines', pipelines);\nconst forge = ref<Forge>();\nconst forgeIcon = computed<IconNames>(() => {\n  if (forge.value && forge.value.type !== 'addon') {\n    return forge.value.type;\n  }\n  return 'repo';\n});\n\nasync function loadRepo() {\n  repoPermissions.value = await apiClient.getRepoPermissions(repositoryId.value);\n  if (!repoPermissions.value.pull) {\n    notifications.notify({ type: 'error', title: i18n.t('repo.not_allowed') });\n    // no access and not authenticated, redirect to login\n    if (!isAuthenticated) {\n      await router.replace({ name: 'login', query: { url: route.fullPath } });\n      return;\n    }\n    await router.replace({ name: 'home' });\n    return;\n  }\n\n  await repoStore.loadRepo(repositoryId.value);\n  await pipelineStore.loadRepoPipelines(repositoryId.value);\n\n  if (repo.value) {\n    forge.value = (await forgeStore.getForge(repo.value?.forge_id)).value;\n  }\n  updateLastAccess(repositoryId.value);\n}\n\nonMounted(() => {\n  loadRepo();\n});\n\nwatch([repositoryId], () => {\n  loadRepo();\n});\n\nconst badgeUrl = computed(() => repo.value && `${config.rootPath}/api/badges/${repo.value.id}/status.svg`);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/Pipeline.vue",
    "content": "<template>\n  <Container full-width class=\"md:min-h-xs flex grow-0 flex-col md:grow md:px-4\">\n    <div class=\"flex min-h-0 w-full grow flex-wrap-reverse md:flex-nowrap md:gap-4\">\n      <PipelineStepList\n        v-model:selected-step-id=\"selectedStepId\"\n        :class=\"{ 'hidden md:flex': pipeline!.status === 'blocked' }\"\n        :pipeline=\"pipeline!\"\n      />\n\n      <div class=\"relative flex grow basis-full items-start justify-center md:basis-auto\">\n        <div v-if=\"pipeline!.errors?.some((e) => !e.is_warning)\" class=\"mb-4 w-full md:mb-auto\">\n          <Panel>\n            <div class=\"flex flex-col items-center gap-4 text-center\">\n              <Icon name=\"status-error\" class=\"text-wp-error-100 h-16 w-16\" size=\"1.5rem\" />\n              <span class=\"text-xl\">{{ $t('repo.pipeline.we_got_some_errors') }}</span>\n              <Button color=\"red\" :text=\"$t('repo.pipeline.show_errors')\" :to=\"{ name: 'repo-pipeline-errors' }\" />\n            </div>\n          </Panel>\n        </div>\n\n        <div v-else-if=\"pipeline!.status === 'blocked'\" class=\"mb-4 w-full md:mb-auto\">\n          <Panel>\n            <div class=\"flex flex-col items-center gap-4\">\n              <Icon name=\"status-blocked\" size=\"1.5rem\" class=\"h-16 w-16\" />\n              <span class=\"text-xl\">{{ $t('repo.pipeline.protected.awaits') }}</span>\n              <div v-if=\"repoPermissions!.push\" class=\"flex flex-wrap items-center justify-center gap-2\">\n                <Button\n                  color=\"green\"\n                  :text=\"$t('repo.pipeline.protected.approve')\"\n                  :is-loading=\"isApprovingPipeline\"\n                  @click=\"approvePipeline\"\n                />\n                <Button\n                  color=\"red\"\n                  :text=\"$t('repo.pipeline.protected.decline')\"\n                  :is-loading=\"isDecliningPipeline\"\n                  @click=\"declinePipeline\"\n                />\n              </div>\n            </div>\n          </Panel>\n        </div>\n\n        <div v-else-if=\"pipeline!.status === 'declined'\" class=\"mb-4 w-full md:mb-auto\">\n          <Panel>\n            <div class=\"flex flex-col items-center gap-4\">\n              <Icon name=\"status-declined\" size=\"1.5rem\" class=\"text-wp-error-100 h-16 w-16\" />\n              <p class=\"text-xl\">{{ $t('repo.pipeline.protected.declined') }}</p>\n            </div>\n          </Panel>\n        </div>\n\n        <PipelineLog v-else-if=\"selectedStepId !== null\" v-model:step-id=\"selectedStepId\" :pipeline=\"pipeline!\" />\n      </div>\n    </div>\n  </Container>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, toRef } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute, useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport Container from '~/components/layout/Container.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport PipelineLog from '~/components/repo/pipeline/PipelineLog.vue';\nimport PipelineStepList from '~/components/repo/pipeline/PipelineStepList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { PipelineStep } from '~/lib/api/types';\n\nconst props = defineProps<{\n  stepId?: string | null;\n}>();\n\nconst apiClient = useApiClient();\nconst router = useRouter();\nconst route = useRoute();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst pipeline = requiredInject('pipeline');\nconst repo = requiredInject('repo');\nconst repoPermissions = requiredInject('repo-permissions');\n\nconst stepId = toRef(props, 'stepId');\n\nconst defaultStepId = computed(() => pipeline.value?.workflows?.[0].children?.[0].pid ?? null);\n\nconst selectedStepId = computed({\n  get() {\n    if (stepId.value !== '' && stepId.value !== null && stepId.value !== undefined) {\n      const id = Number.parseInt(stepId.value, 10);\n\n      let step = pipeline.value.workflows?.find((workflow) => workflow.pid === id)?.children[0];\n      if (step) {\n        return step.pid;\n      }\n\n      step = pipeline.value?.workflows?.reduce(\n        (prev, p) => prev || p.children?.find((c) => c.pid === id),\n        undefined as PipelineStep | undefined,\n      );\n      if (step) {\n        return step.pid;\n      }\n\n      // return fallback if step-id is provided, but step cannot be found\n      return defaultStepId.value;\n    }\n\n    // is opened on >= md-screen\n    if (window.innerWidth > 768) {\n      return defaultStepId.value;\n    }\n\n    return null;\n  },\n  set(_selectedStepId: number | null) {\n    if (_selectedStepId === null) {\n      router.replace({ params: { ...route.params, stepId: '' } });\n      return;\n    }\n\n    router.replace({ params: { ...route.params, stepId: `${_selectedStepId}` } });\n  },\n});\n\nconst { doSubmit: approvePipeline, isLoading: isApprovingPipeline } = useAsyncAction(async () => {\n  await apiClient.approvePipeline(repo.value.id, `${pipeline.value.number}`);\n  notifications.notify({ title: i18n.t('repo.pipeline.protected.approve_success'), type: 'success' });\n});\n\nconst { doSubmit: declinePipeline, isLoading: isDecliningPipeline } = useAsyncAction(async () => {\n  await apiClient.declinePipeline(repo.value.id, `${pipeline.value.number}`);\n  notifications.notify({ title: i18n.t('repo.pipeline.protected.decline_success'), type: 'success' });\n});\n\nuseWPTitle(\n  computed(() => [\n    i18n.t('repo.pipeline.tasks'),\n    i18n.t('repo.pipeline.pipeline', { pipelineId: pipeline.value.number }),\n    repo.value.full_name,\n  ]),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/PipelineChangedFiles.vue",
    "content": "<template>\n  <Panel>\n    <div class=\"w-full\">\n      <FileTree v-for=\"node in fileTree\" :key=\"node.name\" :node=\"node\" :depth=\"0\" />\n    </div>\n  </Panel>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport FileTree from '~/components/FileTree.vue';\nimport type { TreeNode } from '~/components/FileTree.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst repo = requiredInject('repo');\nconst pipeline = requiredInject('pipeline');\n\nconst { t } = useI18n();\nuseWPTitle(\n  computed(() => [\n    t('repo.pipeline.files'),\n    t('repo.pipeline.pipeline', { pipelineId: pipeline.value.number }),\n    repo.value.full_name,\n  ]),\n);\n\nfunction collapseNode(node: TreeNode): TreeNode {\n  if (!node.isDirectory) return node;\n  const collapsedChildren = node.children.map(collapseNode);\n  let currentNode = { ...node, children: collapsedChildren };\n\n  while (currentNode.children.length === 1 && currentNode.children[0].isDirectory) {\n    const onlyChild = currentNode.children[0];\n    currentNode = {\n      name: `${currentNode.name}/${onlyChild.name}`,\n      path: onlyChild.path,\n      isDirectory: true,\n      children: onlyChild.children,\n    };\n  }\n\n  return currentNode;\n}\n\nconst fileTree = computed(() =>\n  (pipeline.value.changed_files ?? [])\n    .reduce((acc, file) => {\n      const parts = file.split('/');\n      let currentLevel = acc;\n\n      parts.forEach((part, index) => {\n        const existingNode = currentLevel.find((node) => node.name === part);\n        if (existingNode) {\n          currentLevel = existingNode.children;\n        } else {\n          const newNode = {\n            name: part,\n            path: parts.slice(0, index + 1).join('/'),\n            isDirectory: index < parts.length - 1,\n            children: [],\n          };\n          currentLevel.push(newNode);\n          currentLevel = newNode.children;\n        }\n      });\n\n      return acc;\n    }, [] as TreeNode[])\n    .map(collapseNode),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/PipelineConfig.vue",
    "content": "<template>\n  <div class=\"flex flex-col gap-y-4\">\n    <Panel\n      v-for=\"pipelineConfig in pipelineConfigsDecoded\"\n      :key=\"pipelineConfig.hash\"\n      :collapsable=\"pipelineConfigsDecoded && pipelineConfigsDecoded.length > 1\"\n      collapsed-by-default\n      :title=\"pipelineConfigsDecoded && pipelineConfigsDecoded.length > 1 ? pipelineConfig.name : ''\"\n    >\n      <SyntaxHighlight\n        class=\"code-box overflow-auto font-mono whitespace-pre\"\n        language=\"yaml\"\n        :code=\"pipelineConfig.data\"\n      />\n    </Panel>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { decode } from 'js-base64';\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport SyntaxHighlight from '~/components/atomic/SyntaxHighlight';\nimport Panel from '~/components/layout/Panel.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst repo = requiredInject('repo');\nconst pipeline = requiredInject('pipeline');\nconst pipelineConfigs = requiredInject('pipeline-configs');\n\nconst pipelineConfigsDecoded = computed(\n  () =>\n    pipelineConfigs.value?.map((i) => ({\n      ...i,\n      data: decode(i.data),\n    })) ?? [],\n);\n\nconst { t } = useI18n();\nuseWPTitle(\n  computed(() => [\n    t('repo.pipeline.config'),\n    t('repo.pipeline.pipeline', { pipelineId: pipeline.value.number }),\n    repo.value.full_name,\n  ]),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/PipelineDebug.vue",
    "content": "<template>\n  <template v-if=\"repoPermissions && repoPermissions.push\">\n    <Panel>\n      <InputField :label=\"$t('repo.pipeline.debug.metadata_exec_title')\">\n        <p class=\"text-wp-text-alt-100 mb-2 text-sm\">{{ $t('repo.pipeline.debug.metadata_exec_desc') }}</p>\n        <pre class=\"code-box\">{{ cliExecWithMetadata }}</pre>\n      </InputField>\n      <div class=\"flex items-center space-x-4\">\n        <Button :is-loading=\"isLoading\" :text=\"$t('repo.pipeline.debug.download_metadata')\" @click=\"downloadMetadata\" />\n      </div>\n      <InputField v-if=\"pipeline.version\" :label=\"$t('repo.pipeline.version_header')\" class=\"pt-4\">\n        <p class=\"text-wp-text-alt-100 mb-2 text-sm\">{{ $t('repo.pipeline.version') }}</p>\n        <pre class=\"code-box\">{{ pipeline.version }}</pre>\n      </InputField>\n    </Panel>\n  </template>\n  <div v-else class=\"flex h-full items-center justify-center\">\n    <div class=\"bg-wp-error-100 dark:bg-wp-error-200 rounded-lg p-8 text-center shadow-lg\">\n      <p class=\"text-2xl font-bold text-white\">{{ $t('repo.pipeline.debug.no_permission') }}</p>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst { t } = useI18n();\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\n\nconst repo = requiredInject('repo');\nconst pipeline = requiredInject('pipeline');\nconst repoPermissions = requiredInject('repo-permissions');\n\nconst isLoading = ref(false);\n\nconst metadataFileName = computed(\n  () => `${repo?.value.full_name.replaceAll('/', '-')}-pipeline-${pipeline?.value.number}-metadata.json`,\n);\nconst cliExecWithMetadata = computed(() => `# woodpecker-cli exec --metadata-file ${metadataFileName.value}`);\n\nasync function downloadMetadata() {\n  if (!repo?.value || !pipeline?.value || !repoPermissions?.value?.push) {\n    notifications.notify({ type: 'error', title: t('repo.pipeline.debug.metadata_download_error') });\n    return;\n  }\n\n  isLoading.value = true;\n  try {\n    const metadata = await apiClient.getPipelineMetadata(repo.value.id, pipeline.value.number);\n\n    // Create a Blob with the JSON data\n    const blob = new Blob([JSON.stringify(metadata, null, 2)], { type: 'application/json' });\n\n    // Create a download link and trigger the download\n    const url = window.URL.createObjectURL(blob);\n    const link = document.createElement('a');\n    link.href = url;\n    link.download = metadataFileName.value;\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    window.URL.revokeObjectURL(url);\n\n    notifications.notify({ type: 'success', title: t('repo.pipeline.debug.metadata_download_successful') });\n  } catch (error) {\n    console.error('Error fetching metadata:', error);\n    notifications.notify({ type: 'error', title: t('repo.pipeline.debug.metadata_download_error') });\n  } finally {\n    isLoading.value = false;\n  }\n}\n\nuseWPTitle(\n  computed(() => [\n    t('repo.pipeline.debug.title'),\n    t('repo.pipeline.pipeline', { pipelineId: pipeline.value.number }),\n    repo.value.full_name,\n  ]),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/PipelineErrors.vue",
    "content": "<template>\n  <Panel>\n    <div class=\"flex flex-col gap-y-4\">\n      <template v-for=\"(error, _index) in pipeline!.errors\" :key=\"_index\">\n        <div>\n          <div class=\"grid grid-cols-[minmax(10rem,auto)_3fr]\">\n            <span class=\"flex items-center gap-x-2\">\n              <Icon\n                name=\"alert\"\n                class=\"my-1 shrink-0\"\n                :class=\"{\n                  'text-wp-state-warn-100': error.is_warning,\n                  'text-wp-error-100': !error.is_warning,\n                }\"\n              />\n              <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n              <span>\n                <code>{{ error.type }}</code>\n              </span>\n            </span>\n            <span\n              v-if=\"isLinterError(error) || isDeprecationError(error) || isBadHabitError(error)\"\n              class=\"flex items-center gap-x-2 whitespace-nowrap\"\n            >\n              <span>\n                <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n                <span v-if=\"error.data?.file\" class=\"font-bold\">{{ error.data?.file }}: </span>\n                <span>{{ error.data?.field }}</span>\n              </span>\n              <DocsLink\n                v-if=\"isDeprecationError(error) || isBadHabitError(error)\"\n                :topic=\"error.data?.field || ''\"\n                :url=\"error.data?.docs || ''\"\n              />\n            </span>\n            <span v-else />\n          </div>\n          <div class=\"col-start-2 grid grid-cols-[minmax(10rem,auto)_4fr]\">\n            <span />\n            <span>\n              <RenderMarkdown :content=\"error.message\" />\n            </span>\n          </div>\n        </div>\n      </template>\n    </div>\n  </Panel>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport DocsLink from '~/components/atomic/DocsLink.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';\nimport Panel from '~/components/layout/Panel.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { PipelineError } from '~/lib/api/types';\n\nconst repo = requiredInject('repo');\nconst pipeline = requiredInject('pipeline');\n\nfunction isLinterError(error: PipelineError): error is PipelineError<{ file?: string; field: string }> {\n  return error.type === 'linter';\n}\n\nfunction isDeprecationError(\n  error: PipelineError,\n): error is PipelineError<{ file: string; field: string; docs: string }> {\n  return error.type === 'deprecation';\n}\n\nfunction isBadHabitError(error: PipelineError): error is PipelineError<{ file?: string; field: string; docs: string }> {\n  return error.type === 'bad_habit';\n}\n\nconst { t } = useI18n();\nuseWPTitle(\n  computed(() => [\n    pipeline.value.errors?.some((e) => !e.is_warning) ? t('repo.pipeline.errors') : t('repo.pipeline.warnings'),\n    t('repo.pipeline.pipeline', { pipelineId: pipeline.value.number }),\n    repo.value.full_name,\n  ]),\n);\n</script>\n"
  },
  {
    "path": "web/src/views/repo/pipeline/PipelineWrapper.vue",
    "content": "<template>\n  <Scaffold\n    v-if=\"pipeline && repo\"\n    enable-tabs\n    :go-back=\"goBack\"\n    :fluid-content=\"route.name === 'repo-pipeline'\"\n    full-width-header\n  >\n    <template #title>\n      <span>\n        <router-link :to=\"{ name: 'org', params: { orgId: repo.org_id } }\" class=\"hover:underline\">{{\n          repo.owner\n          /* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */\n        }}</router-link>\n        /\n        <router-link :to=\"{ name: 'repo' }\" class=\"hover:underline\">{{ repo.name }}</router-link>\n      </span>\n    </template>\n\n    <template #headerActions>\n      <div class=\"flex w-full items-center justify-between gap-2\">\n        <div class=\"flex min-w-0 content-start gap-2\">\n          <PipelineStatusIcon :status=\"pipeline.status\" class=\"flex shrink-0\" />\n          <span class=\"shrink-0 text-center\">{{ $t('repo.pipeline.pipeline', { pipelineId }) }}</span>\n          <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n          <span class=\"hidden md:inline-block\">-</span>\n          <RenderMarkdown\n            class=\"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap\"\n            :title=\"message\"\n            :content=\"shortMessage\"\n            inline\n          />\n        </div>\n\n        <template v-if=\"repoPermissions!.push && pipeline.status !== 'blocked'\">\n          <div class=\"flex content-start gap-x-2\">\n            <Button\n              v-if=\"pipeline.status === 'pending' || pipeline.status === 'running'\"\n              class=\"shrink-0\"\n              :text=\"$t('repo.pipeline.actions.cancel')\"\n              :is-loading=\"isCancelingPipeline\"\n              @click=\"cancelPipeline\"\n            />\n            <Button\n              class=\"shrink-0\"\n              :text=\"$t('repo.pipeline.actions.restart')\"\n              :is-loading=\"isRestartingPipeline\"\n              @click=\"restartPipeline\"\n            />\n            <Button\n              v-if=\"pipeline.status === 'success' && repo.allow_deploy\"\n              class=\"shrink-0\"\n              :text=\"$t('repo.pipeline.actions.deploy')\"\n              @click=\"showDeployPipelinePopup = true\"\n            />\n            <DeployPipelinePopup\n              :pipeline-number=\"pipelineId\"\n              :open=\"showDeployPipelinePopup\"\n              @close=\"showDeployPipelinePopup = false\"\n            />\n          </div>\n        </template>\n      </div>\n    </template>\n\n    <template #tabActions>\n      <div class=\"flex flex-wrap gap-4 md:flex-nowrap\">\n        <div class=\"flex shrink-0 items-center gap-2\" :title=\"$t('repo.pipeline.created', { created })\">\n          <Icon name=\"since\" />\n          <span>{{ since }}</span>\n        </div>\n        <div\n          class=\"flex shrink-0 items-center gap-2\"\n          :title=\"\n            durationElapsed > 0 ? $t('repo.pipeline.duration', { duration: durationAsNumber(durationElapsed) }) : ''\n          \"\n        >\n          <Icon name=\"duration\" />\n          <span>{{ duration }}</span>\n        </div>\n        <div v-if=\"pipeline.status === 'killed' && pipeline.cancel_info\" class=\"flex shrink-0 items-center gap-2\">\n          <Icon name=\"status-killed\" />\n          <span class=\"truncate\">\n            <router-link\n              v-if=\"pipeline.cancel_info.superseded_by\"\n              :to=\"{ name: 'repo-pipeline', params: { pipelineId: pipeline.cancel_info.superseded_by } }\"\n              class=\"hover:underline\"\n            >\n              {{ $t('repo.pipeline.cancel_info.superseded_by', { pipelineId: pipeline.cancel_info.superseded_by }) }}\n            </router-link>\n            <template v-else-if=\"pipeline.cancel_info.canceled_by_user\">\n              {{ $t('repo.pipeline.cancel_info.canceled_by_user', { user: pipeline.cancel_info.canceled_by_user }) }}\n            </template>\n            <template v-else-if=\"pipeline.cancel_info.canceled_by_step\">\n              {{ $t('repo.pipeline.cancel_info.canceled_by_step', { user: pipeline.cancel_info.canceled_by_step }) }}\n            </template>\n          </span>\n        </div>\n      </div>\n    </template>\n\n    <Tab icon=\"tray-full\" :to=\"{ name: 'repo-pipeline' }\" :title=\"$t('repo.pipeline.tasks')\" />\n    <Tab\n      v-if=\"pipeline.errors && pipeline.errors.length > 0\"\n      :to=\"{ name: 'repo-pipeline-errors' }\"\n      icon=\"alert\"\n      :title=\"pipeline.errors.some((e) => !e.is_warning) ? $t('repo.pipeline.errors') : $t('repo.pipeline.warnings')\"\n      :count=\"pipeline.errors?.length\"\n      :icon-class=\"pipeline.errors.some((e) => !e.is_warning) ? 'text-wp-error-100' : 'text-wp-state-warn-100'\"\n    />\n    <Tab icon=\"file-cog-outline\" :to=\"{ name: 'repo-pipeline-config' }\" :title=\"$t('repo.pipeline.config')\" />\n    <Tab\n      v-if=\"pipeline.changed_files && pipeline.changed_files.length > 0\"\n      :to=\"{ name: 'repo-pipeline-changed-files' }\"\n      icon=\"file-edit-outline\"\n      :title=\"$t('repo.pipeline.files')\"\n      :count=\"pipeline.changed_files?.length\"\n    />\n    <Tab\n      v-if=\"repoPermissions && repoPermissions.push\"\n      icon=\"bug-outline\"\n      :to=\"{ name: 'repo-pipeline-debug' }\"\n      :title=\"$t('repo.pipeline.debug.title')\"\n    />\n\n    <router-view />\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';\nimport type { Ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRoute, useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';\nimport DeployPipelinePopup from '~/components/layout/popups/DeployPipelinePopup.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { useDate } from '~/compositions/useDate';\nimport { useFavicon } from '~/compositions/useFavicon';\nimport { provide, requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport usePipeline from '~/compositions/usePipeline';\nimport { useRouteBack } from '~/compositions/useRouteBack';\nimport type { Pipeline, PipelineConfig } from '~/lib/api/types';\nimport { usePipelineStore } from '~/store/pipelines';\n\nconst props = defineProps<{\n  repoId: string;\n  pipelineId: string;\n}>();\n\nconst apiClient = useApiClient();\nconst route = useRoute();\nconst router = useRouter();\nconst notifications = useNotifications();\nconst favicon = useFavicon();\nconst i18n = useI18n();\n\nconst pipelineStore = usePipelineStore();\nconst { durationAsNumber } = useDate();\nconst pipelineId = toRef(props, 'pipelineId');\nconst _repoId = toRef(props, 'repoId');\nconst repositoryId = computed(() => Number.parseInt(_repoId.value, 10));\nconst repo = requiredInject('repo');\nconst repoPermissions = requiredInject('repo-permissions');\n\nconst pipeline = pipelineStore.getPipeline(repositoryId, pipelineId);\nconst { since, duration, durationElapsed, created, message, shortMessage } = usePipeline(pipeline);\nprovide('pipeline', pipeline as Ref<Pipeline>); // can't be undefined because of v-if in template\n\nconst pipelineConfigs = ref<PipelineConfig[]>();\nprovide('pipeline-configs', pipelineConfigs);\n\nwatch(\n  pipeline,\n  () => {\n    favicon.updateStatus(pipeline.value?.status);\n  },\n  { immediate: true },\n);\n\nconst showDeployPipelinePopup = ref(false);\n\nasync function loadPipeline(): Promise<void> {\n  await pipelineStore.loadPipeline(repo.value.id, Number.parseInt(pipelineId.value, 10));\n\n  if (!pipeline.value?.number) {\n    throw new Error('Unexpected: Pipeline number not found');\n  }\n\n  pipelineConfigs.value = await apiClient.getPipelineConfig(repo.value.id, pipeline.value.number);\n}\n\nconst { doSubmit: cancelPipeline, isLoading: isCancelingPipeline } = useAsyncAction(async () => {\n  if (!pipeline.value?.number) {\n    throw new Error('Unexpected: Pipeline number not found');\n  }\n\n  await apiClient.cancelPipeline(repo.value.id, pipeline.value.number);\n  notifications.notify({ title: i18n.t('repo.pipeline.actions.cancel_success'), type: 'success' });\n});\n\nconst { doSubmit: restartPipeline, isLoading: isRestartingPipeline } = useAsyncAction(async () => {\n  const newPipeline = await apiClient.restartPipeline(repo.value.id, pipelineId.value, {\n    fork: true,\n  });\n  notifications.notify({ title: i18n.t('repo.pipeline.actions.restart_success'), type: 'success' });\n  await router.push({\n    name: 'repo-pipeline',\n    params: { pipelineId: newPipeline.number },\n  });\n});\n\nonMounted(loadPipeline);\nwatch([repositoryId, pipelineId], loadPipeline);\nonBeforeUnmount(() => {\n  favicon.updateStatus('default');\n});\n\nconst goBack = useRouteBack({ name: 'repo' });\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Actions.vue",
    "content": "<template>\n  <Settings :title=\"$t('repo.settings.actions.actions')\">\n    <div class=\"flex flex-wrap items-center\">\n      <Button\n        class=\"my-1 mr-4\"\n        color=\"blue\"\n        start-icon=\"heal\"\n        :is-loading=\"isRepairingRepo\"\n        :text=\"$t('repo.settings.actions.repair.repair')\"\n        @click=\"repairRepo\"\n      />\n\n      <Button\n        v-if=\"isActive\"\n        color=\"blue\"\n        class=\"my-1 mr-4\"\n        start-icon=\"turn-off\"\n        :is-loading=\"isDeactivatingRepo\"\n        :text=\"$t('repo.settings.actions.disable.disable')\"\n        @click=\"deactivateRepo\"\n      />\n      <Button\n        v-else\n        class=\"my-1 mr-4\"\n        color=\"blue\"\n        start-icon=\"turn-off\"\n        :is-loading=\"isActivatingRepo\"\n        :text=\"$t('repo.settings.actions.enable.enable')\"\n        @click=\"activateRepo\"\n      />\n\n      <Button\n        class=\"my-1 mr-4\"\n        color=\"red\"\n        start-icon=\"trash\"\n        :is-loading=\"isDeletingRepo\"\n        :text=\"$t('repo.settings.actions.delete.delete')\"\n        @click=\"deleteRepo\"\n      />\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst apiClient = useApiClient();\nconst router = useRouter();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\n\nconst { doSubmit: repairRepo, isLoading: isRepairingRepo } = useAsyncAction(async () => {\n  await apiClient.repairRepo(repo.value.id);\n  notifications.notify({ title: i18n.t('repo.settings.actions.repair.success'), type: 'success' });\n});\n\nconst { doSubmit: deleteRepo, isLoading: isDeletingRepo } = useAsyncAction(async () => {\n  // TODO: use proper dialog\n  // eslint-disable-next-line no-alert\n  if (!confirm(i18n.t('repo.settings.actions.delete.confirm'))) {\n    return;\n  }\n\n  await apiClient.deleteRepo(repo.value.id);\n  notifications.notify({ title: i18n.t('repo.settings.actions.delete.success'), type: 'success' });\n  await router.replace({ name: 'repos' });\n});\n\nconst { doSubmit: activateRepo, isLoading: isActivatingRepo } = useAsyncAction(async () => {\n  await apiClient.activateRepo(repo.value.forge_remote_id);\n  notifications.notify({ title: i18n.t('repo.settings.actions.enable.success'), type: 'success' });\n});\n\nconst { doSubmit: deactivateRepo, isLoading: isDeactivatingRepo } = useAsyncAction(async () => {\n  await apiClient.deleteRepo(repo.value.id, false);\n  notifications.notify({ title: i18n.t('repo.settings.actions.disable.success'), type: 'success' });\n  await router.replace({ name: 'repos' });\n});\n\nconst isActive = computed(() => repo?.value.active);\n\nuseWPTitle(computed(() => [i18n.t('repo.settings.actions.actions'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Badge.vue",
    "content": "<template>\n  <Settings :title=\"$t('repo.settings.badge.badge')\">\n    <template #titleActions>\n      <a v-if=\"badgeUrl\" :href=\"badgeUrl\" target=\"_blank\">\n        <img :src=\"badgeUrl\" />\n      </a>\n    </template>\n\n    <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.badge.type')\">\n      <SelectField\n        :id=\"id\"\n        v-model=\"badgeType\"\n        :options=\"[\n          {\n            value: 'url',\n            text: $t('repo.settings.badge.type_url'),\n          },\n          {\n            value: 'markdown',\n            text: $t('repo.settings.badge.type_markdown'),\n          },\n          {\n            value: 'html',\n            text: $t('repo.settings.badge.type_html'),\n          },\n        ]\"\n        required\n      />\n    </InputField>\n    <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.badge.branch')\">\n      <SelectField :id=\"id\" v-model=\"branch\" :options=\"branches\" required />\n    </InputField>\n    <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.badge.events')\">\n      <CheckboxesField\n        :id=\"id\"\n        v-model=\"events\"\n        :options=\"badgeEventsOptions\"\n        :disabled=\"isDisabled\"\n        @update:model-value=\"eventsChanged\"\n      />\n    </InputField>\n    <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.badge.workflow')\">\n      <TextField :id=\"id\" v-model=\"workflow\" />\n    </InputField>\n    <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.badge.step')\">\n      <TextField :id=\"id\" v-model=\"step\" />\n    </InputField>\n\n    <div v-if=\"badgeContent\" class=\"flex flex-col space-y-4\">\n      <div>\n        <pre class=\"code-box\">{{ badgeContent }}</pre>\n      </div>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useStorage } from '@vueuse/core';\nimport { computed, onMounted, ref, watch } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport CheckboxesField from '~/components/form/CheckboxesField.vue';\nimport type { CheckboxOption, SelectOption } from '~/components/form/form.types';\nimport InputField from '~/components/form/InputField.vue';\nimport SelectField from '~/components/form/SelectField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useConfig from '~/compositions/useConfig';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport { usePaginate } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { WebhookEvents } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst repo = requiredInject('repo');\n\nconst badgeType = useStorage('woodpecker:last-badge-type', 'markdown');\n\nconst defaultBranch = computed(() => repo.value.default_branch);\nconst branches = ref<SelectOption[]>([]);\nconst branch = ref<string>('');\nconst events = ref<string[]>([WebhookEvents.Push]);\nconst workflow = ref<string>('');\nconst step = ref<string>('');\n\nasync function loadBranches() {\n  branches.value = (await usePaginate((page) => apiClient.getRepoBranches(repo.value.id, { page })))\n    .map((b) => ({\n      value: b,\n      text: b,\n    }))\n    .filter((b) => b.value !== defaultBranch.value);\n  branches.value.unshift({\n    value: '',\n    text: defaultBranch.value,\n  });\n}\n\nconst baseUrl = `${window.location.protocol}//${window.location.hostname}${\n  window.location.port ? `:${window.location.port}` : ''\n}`;\nconst { rootPath } = useConfig();\nconst badgeUrl = computed(() => {\n  const params = [];\n\n  if (branch.value !== '') {\n    params.push(`branch=${encodeURIComponent(branch.value)}`);\n  }\n\n  if (events.value.length > 0) {\n    // dont set events parameters, if only WebhookEvents.Push is selected, as this is the default behaviour\n    if (events.value.length !== 1 || events.value.at(0) !== WebhookEvents.Push) {\n      params.push(`events=${encodeURIComponent(events.value.join(','))}`);\n    }\n  }\n\n  if (workflow.value.trim().length > 0) {\n    params.push(`workflow=${encodeURIComponent(workflow.value.trim())}`);\n\n    if (step.value.trim().length > 0) {\n      params.push(`step=${encodeURIComponent(step.value.trim())}`);\n    }\n  }\n\n  return `${rootPath}/api/badges/${repo.value.id}/status.svg${params.length > 0 ? `?${params.join('&')}` : ''}`;\n});\nconst repoUrl = computed(\n  () =>\n    `${rootPath}/repos/${repo.value.id}${branch.value !== '' ? `/branches/${encodeURIComponent(branch.value)}` : ''}`,\n);\n\nconst badgeContent = computed(() => {\n  if (badgeType.value === 'url') {\n    return `${baseUrl}${badgeUrl.value}`;\n  }\n\n  if (badgeType.value === 'markdown') {\n    return `[![status-badge](${baseUrl}${badgeUrl.value})](${baseUrl}${repoUrl.value})`;\n  }\n\n  if (badgeType.value === 'html') {\n    return `<a href=\"${baseUrl}${repoUrl.value}\" target=\"_blank\">\\n  <img src=\"${baseUrl}${badgeUrl.value.replace('&', '&amp;')}\" alt=\"status-badge\" />\\n</a>`;\n  }\n\n  return '';\n});\n\nonMounted(() => {\n  loadBranches();\n});\n\nwatch(repo, () => {\n  loadBranches();\n});\n\nconst { t } = useI18n();\n\nconst badgeEventsOptions: CheckboxOption[] = [\n  { value: WebhookEvents.Push, text: t('repo.pipeline.event.push'), description: t('default') },\n  { value: WebhookEvents.Tag, text: t('repo.pipeline.event.tag') },\n  { value: WebhookEvents.Release, text: t('repo.pipeline.event.release') },\n  { value: WebhookEvents.PullRequest, text: t('repo.pipeline.event.pr') },\n  { value: WebhookEvents.PullRequestClosed, text: t('repo.pipeline.event.pr_closed') },\n  { value: WebhookEvents.PullRequestMetadata, text: t('repo.pipeline.event.pr_metadata') },\n  { value: WebhookEvents.Deploy, text: t('repo.pipeline.event.deploy') },\n  { value: WebhookEvents.Cron, text: t('repo.pipeline.event.cron') },\n  { value: WebhookEvents.Manual, text: t('repo.pipeline.event.manual') },\n];\n\nuseWPTitle(computed(() => [t('repo.settings.badge.badge'), repo.value.full_name]));\n\nfunction eventsChanged() {\n  if (events.value.length === 0) {\n    events.value.push(WebhookEvents.Push);\n  }\n}\n\nconst isDisabled = computed(() => {\n  return (option: CheckboxOption) => {\n    if (events.value.length === 1 && events.value[0] === WebhookEvents.Push) {\n      // disable Push checkbox if only Push is selected because it's the default\n      return option.value === WebhookEvents.Push;\n    } else {\n      return false;\n    }\n  };\n});\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Crons.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('repo.settings.crons.crons')\"\n    :description=\"$t('repo.settings.crons.desc')\"\n    docs-url=\"docs/usage/cron\"\n  >\n    <template #headerActions>\n      <Button\n        v-if=\"selectedCron\"\n        start-icon=\"back\"\n        :text=\"$t('repo.settings.crons.show')\"\n        @click=\"selectedCron = undefined\"\n      />\n      <Button v-else start-icon=\"plus\" :text=\"$t('repo.settings.crons.add')\" @click=\"selectedCron = {}\" />\n    </template>\n\n    <div v-if=\"!selectedCron\" class=\"text-wp-text-100 space-y-4\">\n      <ListItem\n        v-for=\"cron in crons\"\n        :key=\"cron.id\"\n        class=\"bg-wp-background-200! dark:bg-wp-background-200! items-center\"\n      >\n        <span class=\"grid w-full grid-cols-3\">\n          <span>{{ cron.name }}</span>\n          <span v-if=\"cron.next_exec && cron.next_exec > 0\" class=\"md:display-unset col-span-2 hidden\">\n            <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n            {{ $t('repo.settings.crons.next_exec') }}: {{ date.toLocaleString(new Date(cron.next_exec * 1000)) }}\n          </span>\n          <span v-else class=\"md:display-unset col-span-2 hidden\">{{\n            $t('repo.settings.crons.not_executed_yet')\n          }}</span>\n        </span>\n        <div class=\"flex items-center gap-2\">\n          <IconButton\n            icon=\"play-outline\"\n            class=\"h-8 w-8\"\n            :title=\"$t('repo.settings.crons.run')\"\n            @click=\"runCron(cron)\"\n          />\n          <IconButton\n            icon=\"edit\"\n            class=\"h-8 w-8\"\n            :title=\"$t('repo.settings.crons.edit')\"\n            @click=\"selectedCron = cron\"\n          />\n          <IconButton\n            icon=\"trash\"\n            class=\"hover:text-wp-error-100 h-8 w-8\"\n            :is-loading=\"isDeleting\"\n            :title=\"$t('repo.settings.crons.delete')\"\n            @click=\"deleteCron(cron)\"\n          />\n        </div>\n      </ListItem>\n\n      <div v-if=\"loading\" class=\"flex justify-center\">\n        <Icon name=\"spinner\" class=\"animate-spin\" />\n      </div>\n      <div v-else-if=\"crons?.length === 0\" class=\"ml-2\">{{ $t('repo.settings.crons.none') }}</div>\n    </div>\n\n    <div v-else class=\"space-y-4\">\n      <form @submit.prevent=\"createCron\">\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.crons.name.name')\">\n          <TextField\n            :id=\"id\"\n            v-model=\"selectedCron.name\"\n            :placeholder=\"$t('repo.settings.crons.name.placeholder')\"\n            required\n          />\n        </InputField>\n\n        <Checkbox v-model=\"selectedCronEnabled\" :label=\"$t('repo.settings.crons.enabled')\" />\n\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.settings.crons.branch.title')\">\n          <TextField\n            :id=\"id\"\n            v-model=\"selectedCron.branch\"\n            :placeholder=\"$t('repo.settings.crons.branch.placeholder')\"\n          />\n        </InputField>\n\n        <InputField\n          v-slot=\"{ id }\"\n          :label=\"$t('repo.settings.crons.schedule.title')\"\n          docs-url=\"https://pkg.go.dev/github.com/gdgvda/cron#hdr-CRON_Expression_Format\"\n        >\n          <TextField\n            :id=\"id\"\n            v-model=\"selectedCron.schedule\"\n            :placeholder=\"$t('repo.settings.crons.schedule.placeholder')\"\n            required\n          />\n        </InputField>\n\n        <div v-if=\"isEditingCron\" class=\"mb-4 ml-auto\">\n          <span v-if=\"selectedCron.next_exec && selectedCron.next_exec > 0\" class=\"text-wp-text-100\">\n            <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->\n            {{ $t('repo.settings.crons.next_exec') }}:\n            {{ date.toLocaleString(new Date(selectedCron.next_exec * 1000)) }}\n          </span>\n          <span v-else class=\"text-wp-text-100\">{{ $t('repo.settings.crons.not_executed_yet') }}</span>\n        </div>\n\n        <InputField v-slot=\"{ id }\" :label=\"$t('repo.manual_pipeline.variables.title')\">\n          <span class=\"text-wp-text-alt-100 mb-2 text-sm\">{{ $t('repo.manual_pipeline.variables.desc') }}</span>\n          <KeyValueEditor\n            :id=\"id\"\n            v-model=\"selectedCronVariables\"\n            :key-placeholder=\"$t('repo.manual_pipeline.variables.name')\"\n            :value-placeholder=\"$t('repo.manual_pipeline.variables.value')\"\n            :delete-title=\"$t('repo.manual_pipeline.variables.delete')\"\n            @update:is-valid=\"isVariablesValid = $event\"\n          />\n        </InputField>\n\n        <div class=\"flex gap-2\">\n          <Button type=\"button\" color=\"gray\" :text=\"$t('cancel')\" @click=\"selectedCron = undefined\" />\n          <Button\n            type=\"submit\"\n            color=\"green\"\n            :is-loading=\"isSaving\"\n            :text=\"isEditingCron ? $t('repo.settings.crons.save') : $t('repo.settings.crons.add')\"\n            :disabled=\"!isFormValid\"\n          />\n        </div>\n      </form>\n    </div>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Icon from '~/components/atomic/Icon.vue';\nimport IconButton from '~/components/atomic/IconButton.vue';\nimport ListItem from '~/components/atomic/ListItem.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport KeyValueEditor from '~/components/form/KeyValueEditor.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { useDate } from '~/compositions/useDate';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Cron } from '~/lib/api/types';\nimport router from '~/router';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\nconst selectedCron = ref<Partial<Cron>>();\nconst isEditingCron = computed(() => !!selectedCron.value?.id);\nconst date = useDate();\n\nconst selectedCronVariables = computed<Record<string, string>>({\n  async set(_vars) {\n    selectedCron.value!.variables = _vars;\n  },\n  get() {\n    return selectedCron.value!.variables ?? {};\n  },\n});\n\nconst selectedCronEnabled = computed<boolean>({\n  async set(_enabled) {\n    selectedCron.value!.enabled = _enabled;\n  },\n  get() {\n    return selectedCron.value!.enabled !== undefined ? selectedCron.value!.enabled : true;\n  },\n});\n\nasync function loadCrons(page: number): Promise<Cron[] | null> {\n  return apiClient.getCronList(repo.value.id, { page });\n}\n\nconst isVariablesValid = ref(true);\n\nconst isFormValid = computed(() => {\n  return isVariablesValid.value;\n});\n\nconst { resetPage, data: crons, loading } = usePagination(loadCrons, () => !selectedCron.value);\n\nconst { doSubmit: createCron, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedCron.value) {\n    throw new Error(\"Unexpected: Can't get cron\");\n  }\n\n  if (isEditingCron.value) {\n    await apiClient.updateCron(repo.value.id, selectedCron.value);\n  } else {\n    await apiClient.createCron(repo.value.id, selectedCron.value);\n  }\n  notifications.notify({\n    title: isEditingCron.value ? i18n.t('repo.settings.crons.saved') : i18n.t('repo.settings.crons.created'),\n    type: 'success',\n  });\n  selectedCron.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteCron, isLoading: isDeleting } = useAsyncAction(async (_cron: Cron) => {\n  await apiClient.deleteCron(repo.value.id, _cron.id);\n  notifications.notify({ title: i18n.t('repo.settings.crons.deleted'), type: 'success' });\n  await resetPage();\n});\n\nconst { doSubmit: runCron } = useAsyncAction(async (_cron: Cron) => {\n  const pipeline = await apiClient.runCron(repo.value.id, _cron.id);\n  await router.push({\n    name: 'repo-pipeline',\n    params: {\n      pipelineId: pipeline.number,\n    },\n  });\n});\n\nuseWPTitle(computed(() => [i18n.t('repo.settings.crons.crons'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Extensions.vue",
    "content": "<template>\n  <Settings :title=\"$t('extensions')\" :description=\"$t('extensions_description')\" docs-url=\"docs/usage/extensions\">\n    <form @submit.prevent=\"saveExtensions\">\n      <InputField :label=\"$t('extensions_signatures_public_key')\">\n        <pre class=\"code-box\">{{ signaturePublicKey }}</pre>\n        <template #description>\n          {{ $t('extensions_signatures_public_key_description') }}\n        </template>\n      </InputField>\n      <InputField :label=\"$t('config_extension_endpoint')\" docs-url=\"docs/usage/extensions/configuration-extension\">\n        <TextField v-model=\"extensions.config_extension_endpoint\" :placeholder=\"$t('extension_endpoint_placeholder')\" />\n\n        <Checkbox\n          v-model=\"extensions.config_extension_exclusive\"\n          class=\"pt-3\"\n          :label=\"$t('config_extension_exclusive')\"\n          :description=\"$t('config_extension_exclusive_desc')\"\n        />\n\n        <Checkbox\n          v-model=\"extensions.config_extension_netrc\"\n          :label=\"$t('extension_netrc')\"\n          :description=\"$t('extension_netrc_desc')\"\n        />\n      </InputField>\n\n      <InputField :label=\"$t('registry_extension_endpoint')\" docs-url=\"docs/usage/extensions/registry-extension\">\n        <TextField\n          v-model=\"extensions.registry_extension_endpoint\"\n          :placeholder=\"$t('extension_endpoint_placeholder')\"\n        />\n\n        <Checkbox\n          v-model=\"extensions.registry_extension_netrc\"\n          class=\"pt-3\"\n          :label=\"$t('extension_netrc')\"\n          :description=\"$t('extension_netrc_desc')\"\n        />\n      </InputField>\n\n      <InputField :label=\"$t('secret_extension_endpoint')\" docs-url=\"docs/usage/extensions/secret-extension\">\n        <TextField v-model=\"extensions.secret_extension_endpoint\" :placeholder=\"$t('extension_endpoint_placeholder')\" />\n\n        <Checkbox\n          v-model=\"extensions.secret_extension_netrc\"\n          class=\"pt-3\"\n          :label=\"$t('extension_netrc')\"\n          :description=\"$t('extension_netrc_desc')\"\n        />\n      </InputField>\n      <Button :is-loading=\"isSaving\" color=\"green\" type=\"submit\" :text=\"$t('save')\" />\n    </form>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { inject, onMounted, ref } from 'vue';\nimport type { Ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useNotifications from '~/compositions/useNotifications';\nimport type { ExtensionSettings, Repo } from '~/lib/api/types';\n\nconst i18n = useI18n();\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\n\nconst repo = inject<Ref<Repo>>('repo');\nif (!repo) {\n  throw new Error('Missing repo');\n}\n\nconst signaturePublicKey = ref<string>();\n\nonMounted(async () => {\n  signaturePublicKey.value = await apiClient.getSignaturePublicKey();\n});\n\nconst extensions = ref<ExtensionSettings>({\n  config_extension_endpoint: repo.value.config_extension_endpoint,\n  config_extension_exclusive: repo.value.config_extension_exclusive,\n  config_extension_netrc: repo.value.config_extension_netrc,\n  registry_extension_endpoint: repo.value.registry_extension_endpoint,\n  registry_extension_netrc: repo.value.registry_extension_netrc,\n  secret_extension_endpoint: repo.value.secret_extension_endpoint,\n  secret_extension_netrc: repo.value.secret_extension_netrc,\n});\n\nconst { doSubmit: saveExtensions, isLoading: isSaving } = useAsyncAction(async () => {\n  await apiClient.updateRepo(repo.value.id, extensions.value);\n\n  // await loadRepo();\n  notifications.notify({ title: i18n.t('extensions_configuration_saved'), type: 'success' });\n});\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/General.vue",
    "content": "<template>\n  <Settings :title=\"$t('repo.settings.general.project')\">\n    <form v-if=\"repoSettings\" class=\"flex flex-col\" @submit.prevent=\"saveRepoSettings\">\n      <InputField\n        docs-url=\"docs/usage/project-settings#project-settings-1\"\n        :label=\"$t('repo.settings.general.general')\"\n      >\n        <Checkbox\n          v-model=\"repoSettings.allow_pr\"\n          :label=\"$t('repo.settings.general.allow_pr.allow')\"\n          :description=\"$t('repo.settings.general.allow_pr.desc')\"\n        />\n        <Checkbox\n          v-model=\"repoSettings.allow_deploy\"\n          :label=\"$t('repo.settings.general.allow_deploy.allow')\"\n          :description=\"$t('repo.settings.general.allow_deploy.desc')\"\n        />\n      </InputField>\n\n      <InputField\n        :label=\"$t('repo.settings.general.netrc_only_trusted.netrc_only_trusted')\"\n        docs-url=\"docs/usage/project-settings#custom-trusted-clone-plugins\"\n      >\n        <template #default=\"{ id }\">\n          <div class=\"flex flex-col gap-2\">\n            <div v-for=\"image in repoSettings.netrc_trusted\" :key=\"image\" class=\"flex gap-2\">\n              <TextField :id=\"id\" :model-value=\"image\" disabled />\n              <Button type=\"button\" color=\"gray\" start-icon=\"trash\" @click=\"removeImage(image)\" />\n            </div>\n            <div class=\"flex gap-2\">\n              <TextField :id=\"id\" v-model=\"newImage\" @keydown.enter.prevent=\"addNewImage\" />\n              <Button type=\"button\" color=\"gray\" start-icon=\"plus\" @click=\"addNewImage\" />\n            </div>\n          </div>\n        </template>\n        <template #description>\n          {{ $t('repo.settings.general.netrc_only_trusted.desc') }}\n        </template>\n      </InputField>\n\n      <InputField\n        v-if=\"user?.admin\"\n        docs-url=\"docs/usage/project-settings#project-settings-1\"\n        :label=\"$t('repo.settings.general.trusted.trusted')\"\n      >\n        <Checkbox\n          v-model=\"repoSettings.trusted.network\"\n          :label=\"$t('repo.settings.general.trusted.network.network')\"\n          :description=\"$t('repo.settings.general.trusted.network.desc')\"\n        />\n        <Checkbox\n          v-model=\"repoSettings.trusted.volumes\"\n          :label=\"$t('repo.settings.general.trusted.volumes.volumes')\"\n          :description=\"$t('repo.settings.general.trusted.volumes.desc')\"\n        />\n        <Checkbox\n          v-model=\"repoSettings.trusted.security\"\n          :label=\"$t('repo.settings.general.trusted.security.security')\"\n          :description=\"$t('repo.settings.general.trusted.security.desc')\"\n        />\n      </InputField>\n\n      <InputField :label=\"$t('require_approval.require_approval_for')\">\n        <RadioField\n          v-model=\"repoSettings.require_approval\"\n          :options=\"[\n            {\n              value: RepoRequireApproval.None,\n              text: $t('require_approval.none'),\n              description: $t('require_approval.none_desc'),\n            },\n            {\n              value: RepoRequireApproval.Forks,\n              text: $t('require_approval.forks'),\n            },\n            {\n              value: RepoRequireApproval.PullRequests,\n              text: $t('require_approval.pull_requests'),\n            },\n            {\n              value: RepoRequireApproval.AllEvents,\n              text: $t('require_approval.all_events'),\n            },\n          ]\"\n        />\n        <template #description>\n          {{ $t('require_approval.desc') }}\n        </template>\n      </InputField>\n\n      <InputField\n        v-if=\"repoSettings.require_approval !== RepoRequireApproval.None\"\n        :label=\"$t('require_approval.allowed_users.allowed_users')\"\n      >\n        <template #default=\"{ id }\">\n          <div class=\"flex flex-col gap-2\">\n            <div v-for=\"allowedUser in repoSettings.approval_allowed_users\" :key=\"allowedUser\" class=\"flex gap-2\">\n              <TextField :id=\"id\" :model-value=\"allowedUser\" disabled />\n              <Button type=\"button\" color=\"gray\" start-icon=\"trash\" @click=\"removeUser(allowedUser)\" />\n            </div>\n            <div class=\"flex gap-2\">\n              <TextField :id=\"id\" v-model=\"newUser\" @keydown.enter.prevent=\"addNewUser\" />\n              <Button type=\"button\" color=\"gray\" start-icon=\"plus\" @click=\"addNewUser\" />\n            </div>\n          </div>\n        </template>\n        <template #description>\n          {{ $t('require_approval.allowed_users.desc') }}\n        </template>\n      </InputField>\n\n      <InputField docs-url=\"docs/usage/project-settings#project-visibility\" :label=\"$t('repo.visibility.visibility')\">\n        <RadioField v-model=\"repoSettings.visibility\" :options=\"projectVisibilityOptions\" />\n      </InputField>\n\n      <InputField\n        v-slot=\"{ id }\"\n        docs-url=\"docs/usage/project-settings#timeout\"\n        :label=\"$t('repo.settings.general.timeout.timeout')\"\n      >\n        <div class=\"flex items-center\">\n          <NumberField :id=\"id\" v-model=\"repoSettings.timeout\" class=\"w-24\" />\n          <span class=\"text-wp-text-alt-100 ml-4\">{{ $t('repo.settings.general.timeout.minutes') }}</span>\n        </div>\n      </InputField>\n\n      <InputField\n        docs-url=\"docs/usage/project-settings#pipeline-path\"\n        :label=\"$t('repo.settings.general.pipeline_path.path')\"\n      >\n        <template #default=\"{ id }\">\n          <TextField\n            :id=\"id\"\n            v-model=\"repoSettings.config_file\"\n            :placeholder=\"$t('repo.settings.general.pipeline_path.default')\"\n          />\n        </template>\n\n        <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->\n        <template #description>\n          <i18n-t keypath=\"repo.settings.general.pipeline_path.desc\">\n            <span class=\"code-box-inline\">{{ $t('repo.settings.general.pipeline_path.desc_path_example') }}</span>\n            <span class=\"code-box-inline\">/</span>\n          </i18n-t>\n        </template>\n        <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->\n      </InputField>\n\n      <InputField\n        docs-url=\"docs/usage/project-settings#cancel-previous-pipelines\"\n        :label=\"$t('repo.settings.general.cancel_prev.cancel')\"\n      >\n        <CheckboxesField\n          v-model=\"repoSettings.cancel_previous_pipeline_events\"\n          :options=\"cancelPreviousPipelineEventsOptions\"\n        />\n        <template #description>\n          {{ $t('repo.settings.general.cancel_prev.desc') }}\n        </template>\n      </InputField>\n\n      <Button\n        type=\"submit\"\n        class=\"mr-auto\"\n        color=\"green\"\n        :is-loading=\"isSaving\"\n        :text=\"$t('repo.settings.general.save')\"\n      />\n    </form>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Checkbox from '~/components/form/Checkbox.vue';\nimport CheckboxesField from '~/components/form/CheckboxesField.vue';\nimport type { CheckboxOption, RadioOption } from '~/components/form/form.types';\nimport InputField from '~/components/form/InputField.vue';\nimport NumberField from '~/components/form/NumberField.vue';\nimport RadioField from '~/components/form/RadioField.vue';\nimport TextField from '~/components/form/TextField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { RepoRequireApproval, RepoVisibility, WebhookEvents } from '~/lib/api/types';\nimport type { RepoSettings } from '~/lib/api/types';\nimport { useRepoStore } from '~/store/repos';\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst { user } = useAuthentication();\nconst repoStore = useRepoStore();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\nconst repoSettings = ref<RepoSettings>();\n\nfunction loadRepoSettings() {\n  repoSettings.value = {\n    config_file: repo.value.config_file,\n    timeout: repo.value.timeout,\n    visibility: repo.value.visibility,\n    require_approval: repo.value.require_approval,\n    trusted: repo.value.trusted,\n    approval_allowed_users: repo.value.approval_allowed_users || [],\n    allow_pr: repo.value.allow_pr,\n    allow_deploy: repo.value.allow_deploy,\n    cancel_previous_pipeline_events: repo.value.cancel_previous_pipeline_events || [],\n    netrc_trusted: repo.value.netrc_trusted || [],\n  };\n}\n\nasync function loadRepo() {\n  await repoStore.loadRepo(repo.value.id);\n  loadRepoSettings();\n}\n\nconst { doSubmit: saveRepoSettings, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!repoSettings.value) {\n    throw new Error('Unexpected: Repo-Settings should be set');\n  }\n\n  await apiClient.updateRepo(repo.value.id, repoSettings.value);\n  await loadRepo();\n  notifications.notify({ title: i18n.t('repo.settings.general.success'), type: 'success' });\n});\n\nonMounted(() => {\n  loadRepoSettings();\n});\n\nconst projectVisibilityOptions: RadioOption[] = [\n  {\n    value: RepoVisibility.Public,\n    text: i18n.t('repo.visibility.public.public'),\n    description: i18n.t('repo.visibility.public.desc'),\n  },\n  {\n    value: RepoVisibility.Internal,\n    text: i18n.t('repo.visibility.internal.internal'),\n    description: i18n.t('repo.visibility.internal.desc'),\n  },\n  {\n    value: RepoVisibility.Private,\n    text: i18n.t('repo.visibility.private.private'),\n    description: i18n.t('repo.visibility.private.desc'),\n  },\n];\n\nconst cancelPreviousPipelineEventsOptions: CheckboxOption[] = [\n  { value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },\n  { value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },\n  {\n    value: WebhookEvents.PullRequest,\n    text: i18n.t('repo.pipeline.event.pr'),\n  },\n  { value: WebhookEvents.Deploy, text: i18n.t('repo.pipeline.event.deploy') },\n];\n\nconst newImage = ref('');\nfunction addNewImage() {\n  if (!newImage.value) {\n    return;\n  }\n  repoSettings.value?.netrc_trusted.push(newImage.value);\n  newImage.value = '';\n}\nfunction removeImage(image: string) {\n  if (!repoSettings.value) {\n    throw new Error('Unexpected: repoSettings should be set');\n  }\n\n  repoSettings.value.netrc_trusted = repoSettings.value.netrc_trusted.filter((i) => i !== image);\n}\n\nconst newUser = ref('');\nfunction addNewUser() {\n  if (!newUser.value) {\n    return;\n  }\n  repoSettings.value?.approval_allowed_users.push(newUser.value);\n  newUser.value = '';\n}\nfunction removeUser(user: string) {\n  if (!repoSettings.value) {\n    throw new Error('Unexpected: repoSettings should be set');\n  }\n\n  repoSettings.value.approval_allowed_users = repoSettings.value.approval_allowed_users.filter((i) => i !== user);\n}\n\nuseWPTitle(computed(() => [i18n.t('repo.settings.general.project'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Registries.vue",
    "content": "<template>\n  <Settings :title=\"$t('registries.credentials')\" :description=\"$t('registries.desc')\" docs-url=\"docs/usage/registries\">\n    <template #headerActions>\n      <Button\n        v-if=\"selectedRegistry\"\n        :text=\"$t('registries.show')\"\n        start-icon=\"back\"\n        @click=\"selectedRegistry = undefined\"\n      />\n      <Button v-else :text=\"$t('registries.add')\" start-icon=\"plus\" @click=\"showAddRegistry\" />\n    </template>\n\n    <RegistryList\n      v-if=\"!selectedRegistry\"\n      v-model=\"registries\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editRegistry\"\n      @delete=\"deleteRegistry\"\n    />\n\n    <RegistryEdit\n      v-else\n      v-model=\"selectedRegistry\"\n      :is-saving=\"isSaving\"\n      @save=\"createRegistry\"\n      @cancel=\"selectedRegistry = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport RegistryEdit from '~/components/registry/RegistryEdit.vue';\nimport RegistryList from '~/components/registry/RegistryList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Registry } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptyRegistry: Partial<Registry> = {\n  address: '',\n  username: '',\n  password: '',\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\nconst selectedRegistry = ref<Partial<Registry>>();\nconst isEditingRegistry = computed(() => !!selectedRegistry.value?.id);\n\nasync function loadRegistries(page: number, level: 'repo' | 'org' | 'global'): Promise<Registry[] | null> {\n  switch (level) {\n    case 'repo':\n      return apiClient.getRegistryList(repo.value.id, { page });\n    case 'org':\n      return apiClient.getOrgRegistryList(repo.value.org_id, { page });\n    case 'global':\n      return apiClient.getGlobalRegistryList({ page });\n    default:\n      throw new Error(`Unexpected level: ${level}`);\n  }\n}\n\nconst {\n  resetPage,\n  data: _registries,\n  loading,\n} = usePagination(loadRegistries, () => !selectedRegistry.value, {\n  each: ['repo', 'org', 'global'],\n});\nconst registries = computed(() => {\n  const registriesList: Record<string, Registry & { edit?: boolean; level: 'repo' | 'org' | 'global' }> = {};\n\n  for (const level of ['repo', 'org', 'global']) {\n    for (const registry of _registries.value) {\n      if (\n        ((level === 'repo' && registry.repo_id !== 0 && registry.org_id === 0) ||\n          (level === 'org' && registry.repo_id === 0 && registry.org_id !== 0) ||\n          (level === 'global' && registry.repo_id === 0 && registry.org_id === 0)) &&\n        !registriesList[registry.address]\n      ) {\n        registriesList[registry.address] = { ...registry, edit: registry.repo_id !== 0, level };\n      }\n    }\n  }\n\n  const levelsOrder = {\n    global: 0,\n    org: 1,\n    repo: 2,\n  };\n\n  return Object.values(registriesList)\n    .toSorted((a, b) => a.address.localeCompare(b.address))\n    .toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);\n});\n\nconst { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedRegistry.value) {\n    throw new Error(\"Unexpected: Can't get registry\");\n  }\n\n  if (isEditingRegistry.value) {\n    await apiClient.updateRegistry(repo.value.id, selectedRegistry.value);\n  } else {\n    await apiClient.createRegistry(repo.value.id, selectedRegistry.value);\n  }\n  notifications.notify({\n    title: isEditingRegistry.value ? i18n.t('registries.saved') : i18n.t('registries.created'),\n    type: 'success',\n  });\n  selectedRegistry.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {\n  const registryAddress = encodeURIComponent(_registry.address);\n  await apiClient.deleteRegistry(repo.value.id, registryAddress);\n  notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editRegistry(registry: Registry) {\n  selectedRegistry.value = deepClone(registry);\n}\n\nfunction showAddRegistry() {\n  selectedRegistry.value = deepClone(emptyRegistry);\n}\n\nuseWPTitle(computed(() => [i18n.t('registries.registries'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/RepoSettings.vue",
    "content": "<template>\n  <Scaffold enable-tabs :go-back=\"goBack\">\n    <template #title>\n      <span>\n        <router-link :to=\"{ name: 'org', params: { orgId: repo.org_id } }\" class=\"hover:underline\">{{\n          repo!.owner\n          /* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */\n        }}</router-link>\n        /\n        <router-link :to=\"{ name: 'repo' }\" class=\"hover:underline\">{{\n          repo!.name\n          /* eslint-disable-next-line @intlify/vue-i18n/no-raw-text */\n        }}</router-link>\n        /\n        {{ $t('settings') }}\n      </span>\n    </template>\n\n    <Tab icon=\"settings-outline\" :to=\"{ name: 'repo-settings' }\" :title=\"$t('repo.settings.general.general')\" />\n    <Tab icon=\"secret\" :to=\"{ name: 'repo-settings-secrets' }\" :title=\"$t('secrets.secrets')\" />\n    <Tab icon=\"docker\" :to=\"{ name: 'repo-settings-registries' }\" :title=\"$t('registries.registries')\" />\n    <Tab icon=\"cron\" :to=\"{ name: 'repo-settings-crons' }\" :title=\"$t('repo.settings.crons.crons')\" />\n    <Tab icon=\"tag\" :to=\"{ name: 'repo-settings-badge' }\" :title=\"$t('repo.settings.badge.badge')\" />\n    <Tab icon=\"puzzle\" :to=\"{ name: 'repo-settings-extensions' }\" :title=\"$t('extensions')\" />\n    <Tab icon=\"toolbox\" :to=\"{ name: 'repo-settings-actions' }\" :title=\"$t('repo.settings.actions.actions')\" />\n\n    <router-view />\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport { onMounted } from 'vue';\nimport { useI18n } from 'vue-i18n';\nimport { useRouter } from 'vue-router';\n\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { useRouteBack } from '~/compositions/useRouteBack';\n\nconst notifications = useNotifications();\nconst router = useRouter();\nconst i18n = useI18n();\n\nconst repoPermissions = requiredInject('repo-permissions');\nconst repo = requiredInject('repo');\n\nonMounted(async () => {\n  if (!repoPermissions.value.admin) {\n    notifications.notify({ type: 'error', title: i18n.t('repo.settings.not_allowed') });\n    await router.replace({ name: 'home' });\n  }\n});\n\nconst goBack = useRouteBack({ name: 'repo' });\n</script>\n"
  },
  {
    "path": "web/src/views/repo/settings/Secrets.vue",
    "content": "<template>\n  <Settings :title=\"$t('secrets.secrets')\" :description=\"$t('secrets.desc')\" docs-url=\"docs/usage/secrets\">\n    <template #headerActions>\n      <Button v-if=\"selectedSecret\" :text=\"$t('secrets.show')\" start-icon=\"back\" @click=\"selectedSecret = undefined\" />\n      <Button v-else :text=\"$t('secrets.add')\" start-icon=\"plus\" @click=\"showAddSecret\" />\n    </template>\n\n    <SecretList\n      v-if=\"!selectedSecret\"\n      :model-value=\"secrets\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editSecret\"\n      @delete=\"deleteSecret\"\n    />\n\n    <SecretEdit\n      v-else\n      v-model=\"selectedSecret\"\n      :is-saving=\"isSaving\"\n      @save=\"createSecret\"\n      @cancel=\"selectedSecret = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport SecretEdit from '~/components/secrets/SecretEdit.vue';\nimport SecretList from '~/components/secrets/SecretList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport { requiredInject } from '~/compositions/useInjectProvide';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { WebhookEvents } from '~/lib/api/types';\nimport type { Secret } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptySecret: Partial<Secret> = {\n  name: '',\n  value: '',\n  images: [],\n  events: [WebhookEvents.Push],\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst repo = requiredInject('repo');\nconst selectedSecret = ref<Partial<Secret>>();\nconst isEditingSecret = computed(() => !!selectedSecret.value?.id);\n\nasync function loadSecrets(page: number, level: 'repo' | 'org' | 'global'): Promise<Secret[] | null> {\n  switch (level) {\n    case 'repo':\n      return apiClient.getSecretList(repo.value.id, { page });\n    case 'org':\n      return apiClient.getOrgSecretList(repo.value.org_id, { page });\n    case 'global':\n      return apiClient.getGlobalSecretList({ page });\n    default:\n      throw new Error(`Unexpected level: ${level}`);\n  }\n}\n\nconst {\n  resetPage,\n  data: _secrets,\n  loading,\n} = usePagination(loadSecrets, () => !selectedSecret.value, {\n  each: ['repo', 'org', 'global'],\n});\nconst secrets = computed(() => {\n  const secretsList: Record<string, Secret & { edit?: boolean; level: 'repo' | 'org' | 'global' }> = {};\n\n  for (const level of ['repo', 'org', 'global']) {\n    for (const secret of _secrets.value) {\n      if (\n        ((level === 'repo' && secret.repo_id !== 0 && secret.org_id === 0) ||\n          (level === 'org' && secret.repo_id === 0 && secret.org_id !== 0) ||\n          (level === 'global' && secret.repo_id === 0 && secret.org_id === 0)) &&\n        !secretsList[secret.name]\n      ) {\n        secretsList[secret.name] = { ...secret, edit: secret.repo_id !== 0, level };\n      }\n    }\n  }\n\n  const levelsOrder = {\n    global: 0,\n    org: 1,\n    repo: 2,\n  };\n\n  return Object.values(secretsList)\n    .toSorted((a, b) => a.name.localeCompare(b.name))\n    .toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);\n});\n\nconst { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedSecret.value) {\n    throw new Error(\"Unexpected: Can't get secret\");\n  }\n\n  if (isEditingSecret.value) {\n    await apiClient.updateSecret(repo.value.id, selectedSecret.value);\n  } else {\n    await apiClient.createSecret(repo.value.id, selectedSecret.value);\n  }\n  notifications.notify({\n    title: isEditingSecret.value ? i18n.t('secrets.saved') : i18n.t('secrets.created'),\n    type: 'success',\n  });\n  selectedSecret.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {\n  await apiClient.deleteSecret(repo.value.id, _secret.name);\n  notifications.notify({ title: i18n.t('secrets.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editSecret(secret: Secret) {\n  selectedSecret.value = deepClone(secret);\n}\n\nfunction showAddSecret() {\n  selectedSecret.value = deepClone(emptySecret);\n}\n\nuseWPTitle(computed(() => [i18n.t('secrets.secrets'), repo.value.full_name]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserAgents.vue",
    "content": "<template>\n  <AgentManager\n    :description=\"$t('user.settings.agents.desc')\"\n    :load-agents=\"loadAgents\"\n    :create-agent=\"createAgent\"\n    :update-agent=\"updateAgent\"\n    :delete-agent=\"deleteAgent\"\n  />\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport AgentManager from '~/components/agent/AgentManager.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Agent } from '~/lib/api/types';\n\nconst apiClient = useApiClient();\nconst { user } = useAuthentication();\n\nif (!user) {\n  throw new Error('Unexpected: User should be authenticated');\n}\n\nconst loadAgents = (page: number) => apiClient.getOrgAgents(user.org_id, { page });\nconst createAgent = (agent: Partial<Agent>) => apiClient.createOrgAgent(user.org_id, agent);\nconst updateAgent = (agent: Agent) => apiClient.updateOrgAgent(user.org_id, agent.id, agent);\nconst deleteAgent = (agent: Agent) => apiClient.deleteOrgAgent(user.org_id, agent.id);\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('admin.settings.agents.agents'), t('user.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserCLIAndAPI.vue",
    "content": "<template>\n  <Settings :title=\"$t('user.settings.cli_and_api.cli_and_api')\" :description=\"$t('user.settings.cli_and_api.desc')\">\n    <InputField :label=\"$t('user.settings.cli_and_api.cli_usage')\">\n      <template #headerActions>\n        <a :href=\"cliDownload\" target=\"_blank\" class=\"text-wp-link-100 hover:text-wp-link-200 ml-4\">{{\n          $t('user.settings.cli_and_api.download_cli')\n        }}</a>\n      </template>\n      <pre class=\"code-box\">{{ usageWithCli }}</pre>\n    </InputField>\n\n    <InputField :label=\"$t('user.settings.cli_and_api.token')\">\n      <template #titleActions>\n        <Button class=\"ml-auto\" :text=\"$t('user.settings.cli_and_api.reset_token')\" @click=\"resetToken\" />\n      </template>\n      <pre class=\"code-box\">{{ token }}</pre>\n    </InputField>\n\n    <InputField :label=\"$t('user.settings.cli_and_api.api_usage')\">\n      <template #headerActions>\n        <a\n          v-if=\"enableSwagger\"\n          :href=\"`${address}/swagger/index.html`\"\n          target=\"_blank\"\n          class=\"text-wp-link-100 hover:text-wp-link-200 ml-4\"\n        >\n          {{ $t('user.settings.cli_and_api.swagger_ui') }}\n        </a>\n      </template>\n      <pre class=\"code-box\">{{ usageWithCurl }}</pre>\n    </InputField>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport InputField from '~/components/form/InputField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport useConfig from '~/compositions/useConfig';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst { rootPath, enableSwagger } = useConfig();\n\nconst apiClient = useApiClient();\nconst token = ref<string | undefined>();\n\nonMounted(async () => {\n  token.value = await apiClient.getToken();\n});\n\nconst address = `${window.location.protocol}//${window.location.host}${rootPath}`; // port is included in location.host\n\nconst usageWithCurl = computed(() => {\n  let usage = `export WOODPECKER_SERVER=\"${address}\"\\n`;\n  usage += `export WOODPECKER_TOKEN=\"${token.value}\"\\n`;\n  usage += `\\n`;\n  usage += `# curl -i \\${WOODPECKER_SERVER}/api/user -H \"Authorization: Bearer \\${WOODPECKER_TOKEN}\"`;\n  return usage;\n});\n\nconst usageWithCli = `# woodpecker setup --server ${address}`;\n\nconst cliDownload = 'https://github.com/woodpecker-ci/woodpecker/releases';\n\nconst resetToken = async () => {\n  token.value = await apiClient.resetToken();\n  window.location.href = `${address}/logout`;\n};\n\nconst { t } = useI18n();\nuseWPTitle(computed(() => [t('user.settings.cli_and_api.cli_and_api'), t('user.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserGeneral.vue",
    "content": "<template>\n  <Settings :title=\"$t('user.settings.general.general')\">\n    <InputField :label=\"$t('user.settings.general.language')\">\n      <template #default=\"{ id }\">\n        <SelectField :id=\"id\" v-model=\"selectedLocale\" class=\"mt-2\" :options=\"localeOptions\" />\n      </template>\n      <template #description>\n        <i18n-t keypath=\"help_translating\" tag=\"p\">\n          <a\n            rel=\"noopener noreferrer\"\n            href=\"https://translate.woodpecker-ci.org/projects/woodpecker-ci/ui/\"\n            target=\"_blank\"\n            class=\"underline\"\n          >\n            {{ $t('weblate') }}\n          </a>\n        </i18n-t>\n      </template>\n    </InputField>\n\n    <InputField v-slot=\"{ id }\" :label=\"$t('user.settings.general.theme.theme')\">\n      <SelectField\n        :id=\"id\"\n        v-model=\"storeTheme\"\n        :options=\"[\n          { value: 'auto', text: $t('user.settings.general.theme.auto') },\n          { value: 'light', text: $t('user.settings.general.theme.light') },\n          { value: 'dark', text: $t('user.settings.general.theme.dark') },\n        ]\"\n      />\n    </InputField>\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useStorage } from '@vueuse/core';\nimport { SUPPORTED_LOCALES } from 'virtual:vue-i18n-supported-locales';\nimport { computed } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport InputField from '~/components/form/InputField.vue';\nimport SelectField from '~/components/form/SelectField.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport { setI18nLanguage } from '~/compositions/useI18n';\nimport { useTheme } from '~/compositions/useTheme';\nimport { useWPTitle } from '~/compositions/useWPTitle';\n\nconst { locale, t } = useI18n();\nconst { storeTheme } = useTheme();\n\nconst localeOptions = computed(() =>\n  SUPPORTED_LOCALES.map((supportedLocale) => ({\n    value: supportedLocale,\n    text: new Intl.DisplayNames(supportedLocale, { type: 'language' }).of(supportedLocale) || supportedLocale,\n  })),\n);\n\nconst storedLocale = useStorage('woodpecker:locale', locale.value);\nconst selectedLocale = computed<string>({\n  async set(_selectedLocale) {\n    await setI18nLanguage(_selectedLocale);\n    storedLocale.value = _selectedLocale;\n  },\n  get() {\n    return storedLocale.value;\n  },\n});\n\nuseWPTitle(computed(() => [t('user.settings.general.general'), t('user.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserRegistries.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('registries.registries')\"\n    :description=\"$t('user.settings.registries.desc')\"\n    docs-url=\"docs/usage/registries\"\n  >\n    <template #headerActions>\n      <Button\n        v-if=\"selectedRegistry\"\n        :text=\"$t('registries.show')\"\n        start-icon=\"back\"\n        @click=\"selectedRegistry = undefined\"\n      />\n      <Button v-else :text=\"$t('registries.add')\" start-icon=\"plus\" @click=\"showAddRegistry\" />\n    </template>\n\n    <RegistryList\n      v-if=\"!selectedRegistry\"\n      v-model=\"registries\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editRegistry\"\n      @delete=\"deleteRegistry\"\n    />\n\n    <RegistryEdit\n      v-else\n      v-model=\"selectedRegistry\"\n      :is-saving=\"isSaving\"\n      @save=\"createRegistry\"\n      @cancel=\"selectedRegistry = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport RegistryEdit from '~/components/registry/RegistryEdit.vue';\nimport RegistryList from '~/components/registry/RegistryList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport type { Registry } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptyRegistry: Partial<Registry> = {\n  address: '',\n  username: '',\n  password: '',\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst { user } = useAuthentication();\nif (!user) {\n  throw new Error('Unexpected: Unauthenticated');\n}\nconst selectedRegistry = ref<Partial<Registry>>();\nconst isEditingRegistry = computed(() => !!selectedRegistry.value?.id);\n\nasync function loadRegistries(page: number): Promise<Registry[] | null> {\n  if (!user) {\n    throw new Error('Unexpected: Unauthenticated');\n  }\n\n  return apiClient.getOrgRegistryList(user.org_id, { page });\n}\n\nconst { resetPage, data: registries, loading } = usePagination(loadRegistries, () => !selectedRegistry.value);\n\nconst { doSubmit: createRegistry, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedRegistry.value) {\n    throw new Error(\"Unexpected: Can't get registry\");\n  }\n\n  if (isEditingRegistry.value) {\n    await apiClient.updateOrgRegistry(user.org_id, selectedRegistry.value);\n  } else {\n    await apiClient.createOrgRegistry(user.org_id, selectedRegistry.value);\n  }\n  notifications.notify({\n    title: isEditingRegistry.value ? i18n.t('registries.saved') : i18n.t('registries.created'),\n    type: 'success',\n  });\n  selectedRegistry.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteRegistry, isLoading: isDeleting } = useAsyncAction(async (_registry: Registry) => {\n  await apiClient.deleteOrgRegistry(user.org_id, _registry.address);\n  notifications.notify({ title: i18n.t('registries.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editRegistry(registry: Registry) {\n  selectedRegistry.value = deepClone(registry);\n}\n\nfunction showAddRegistry() {\n  selectedRegistry.value = deepClone(emptyRegistry);\n}\n\nuseWPTitle(computed(() => [i18n.t('registries.registries'), i18n.t('user.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserSecrets.vue",
    "content": "<template>\n  <Settings\n    :title=\"$t('secrets.secrets')\"\n    :description=\"$t('user.settings.secrets.desc')\"\n    docs-url=\"docs/usage/secrets\"\n  >\n    <template #headerActions>\n      <Button v-if=\"selectedSecret\" :text=\"$t('secrets.show')\" start-icon=\"back\" @click=\"selectedSecret = undefined\" />\n      <Button v-else :text=\"$t('secrets.add')\" start-icon=\"plus\" @click=\"showAddSecret\" />\n    </template>\n\n    <SecretList\n      v-if=\"!selectedSecret\"\n      v-model=\"secrets\"\n      :is-deleting=\"isDeleting\"\n      :loading=\"loading\"\n      @edit=\"editSecret\"\n      @delete=\"deleteSecret\"\n    />\n\n    <SecretEdit\n      v-else\n      v-model=\"selectedSecret\"\n      :is-saving=\"isSaving\"\n      @save=\"createSecret\"\n      @cancel=\"selectedSecret = undefined\"\n    />\n  </Settings>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport { useI18n } from 'vue-i18n';\n\nimport Button from '~/components/atomic/Button.vue';\nimport Settings from '~/components/layout/Settings.vue';\nimport SecretEdit from '~/components/secrets/SecretEdit.vue';\nimport SecretList from '~/components/secrets/SecretList.vue';\nimport useApiClient from '~/compositions/useApiClient';\nimport { useAsyncAction } from '~/compositions/useAsyncAction';\nimport useAuthentication from '~/compositions/useAuthentication';\nimport useNotifications from '~/compositions/useNotifications';\nimport { usePagination } from '~/compositions/usePaginate';\nimport { useWPTitle } from '~/compositions/useWPTitle';\nimport { WebhookEvents } from '~/lib/api/types';\nimport type { Secret } from '~/lib/api/types';\nimport { deepClone } from '~/lib/utils';\n\nconst emptySecret: Partial<Secret> = {\n  name: '',\n  value: '',\n  images: [],\n  events: [WebhookEvents.Push],\n};\n\nconst apiClient = useApiClient();\nconst notifications = useNotifications();\nconst i18n = useI18n();\n\nconst { user } = useAuthentication();\nif (!user) {\n  throw new Error('Unexpected: Unauthenticated');\n}\nconst selectedSecret = ref<Partial<Secret>>();\nconst isEditingSecret = computed(() => !!selectedSecret.value?.id);\n\nasync function loadSecrets(page: number): Promise<Secret[] | null> {\n  if (!user) {\n    throw new Error('Unexpected: Unauthenticated');\n  }\n\n  return apiClient.getOrgSecretList(user.org_id, { page });\n}\n\nconst { resetPage, data: secrets, loading } = usePagination(loadSecrets, () => !selectedSecret.value);\n\nconst { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {\n  if (!selectedSecret.value) {\n    throw new Error(\"Unexpected: Can't get secret\");\n  }\n\n  if (isEditingSecret.value) {\n    await apiClient.updateOrgSecret(user.org_id, selectedSecret.value);\n  } else {\n    await apiClient.createOrgSecret(user.org_id, selectedSecret.value);\n  }\n  notifications.notify({\n    title: isEditingSecret.value ? i18n.t('secrets.saved') : i18n.t('secrets.created'),\n    type: 'success',\n  });\n  selectedSecret.value = undefined;\n  await resetPage();\n});\n\nconst { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {\n  await apiClient.deleteOrgSecret(user.org_id, _secret.name);\n  notifications.notify({ title: i18n.t('secrets.deleted'), type: 'success' });\n  await resetPage();\n});\n\nfunction editSecret(secret: Secret) {\n  selectedSecret.value = deepClone(secret);\n}\n\nfunction showAddSecret() {\n  selectedSecret.value = deepClone(emptySecret);\n}\n\nuseWPTitle(computed(() => [i18n.t('secrets.secrets'), i18n.t('user.settings.settings')]));\n</script>\n"
  },
  {
    "path": "web/src/views/user/UserWrapper.vue",
    "content": "<template>\n  <Scaffold enable-tabs>\n    <template #title>{{ $t('user.settings.settings') }}</template>\n    <template #headerActions><Button :text=\"$t('logout')\" :to=\"`${address}/logout`\" /></template>\n\n    <Tab icon=\"settings-outline\" :to=\"{ name: 'user' }\" :title=\"$t('user.settings.general.general')\" />\n    <Tab icon=\"secret\" :to=\"{ name: 'user-secrets' }\" :title=\"$t('secrets.secrets')\" />\n    <Tab icon=\"docker\" :to=\"{ name: 'user-registries' }\" :title=\"$t('registries.registries')\" />\n    <Tab icon=\"console\" :to=\"{ name: 'user-cli-and-api' }\" :title=\"$t('user.settings.cli_and_api.cli_and_api')\" />\n    <Tab\n      v-if=\"userRegisteredAgents\"\n      icon=\"agent\"\n      :to=\"{ name: 'user-agents' }\"\n      :title=\"$t('admin.settings.agents.agents')\"\n    />\n\n    <router-view />\n  </Scaffold>\n</template>\n\n<script lang=\"ts\" setup>\nimport Button from '~/components/atomic/Button.vue';\nimport Scaffold from '~/components/layout/scaffold/Scaffold.vue';\nimport Tab from '~/components/layout/scaffold/Tab.vue';\nimport useConfig from '~/compositions/useConfig';\n\nconst { userRegisteredAgents } = useConfig();\n\nconst address = `${window.location.protocol}//${window.location.host}${useConfig().rootPath}`; // port is included in location.host\n</script>\n"
  },
  {
    "path": "web/src/vite-env.d.ts",
    "content": "// / <reference types=\"vite/client\" />\n\ndeclare module 'virtual:vue-i18n-supported-locales' {\n  export const SUPPORTED_LOCALES: string[];\n}\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"module\": \"esnext\",\n    \"types\": [\"vite-svg-loader\", \"vite/client\"],\n    \"sourceMap\": true,\n    \"useDefineForClassFields\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"lib\": [\"esnext\", \"dom\"],\n    \"paths\": {\n      \"~/*\": [\"./src/*\"]\n    },\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"src/**/*.vue\", \"src/**/*.json\", \"vite.config.ts\"],\n  \"exclude\": [\"node_modules\", \"**/__tests__/**/*\", \"**/dist/**/*\"]\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import { readdirSync } from 'node:fs';\nimport path from 'node:path';\nimport process from 'node:process';\nimport VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';\nimport tailwindcss from '@tailwindcss/vite';\nimport vue from '@vitejs/plugin-vue';\nimport dotenv from 'dotenv';\nimport type { Plugin } from 'vite';\nimport prismjs from 'vite-plugin-prismjs';\nimport svgLoader from 'vite-svg-loader';\nimport type { ViteUserConfig } from 'vitest/config';\nimport { defineConfig } from 'vitest/config';\n\ndotenv.config({ path: path.resolve(__dirname, '../.env'), quiet: true });\n\nconst getEnvString = (envVar: string | undefined) => (envVar != null && envVar !== '' ? envVar : undefined);\nconst viteUserSessCookie = getEnvString(process.env.VITE_DEV_USER_SESS_COOKIE);\nconst viteDevProxy = getEnvString(process.env.VITE_DEV_PROXY);\n\nfunction woodpeckerInfoPlugin(): Plugin {\n  return {\n    name: 'woodpecker-info',\n    configureServer() {\n      if (viteDevProxy !== undefined) {\n        console.log(\n          [\n            `Using dev server with proxy to existing Woodpecker server running at: ${viteDevProxy}`,\n            '\\n  🚀 Access the UI at http://localhost:8010/',\n          ].join('\\n'),\n        );\n        return;\n      }\n\n      console.log(\n        [\n          '1) Please add `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file.',\n          '2) Start the Woodpecker server',\n          '3) If you want to run the vite dev server (`pnpm start`) within a container please set `VITE_DEV_SERVER_HOST=0.0.0.0`.',\n          `\\n  🚀 Access the UI at http://localhost:8000/`,\n        ].join('\\n'),\n      );\n    },\n  };\n}\n\nfunction externalCSSPlugin(): Plugin {\n  return {\n    name: 'external-css',\n    transformIndexHtml: {\n      order: 'post',\n      handler() {\n        return [\n          {\n            tag: 'link',\n            attrs: { rel: 'stylesheet', type: 'text/css', href: '/assets/custom.css' },\n            injectTo: 'head',\n          },\n        ];\n      },\n    },\n  };\n}\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    vue(),\n    VueI18nPlugin({\n      include: path.resolve(__dirname, 'src/assets/locales/**'),\n    }),\n    (() => {\n      const virtualModuleId = 'virtual:vue-i18n-supported-locales';\n      const resolvedVirtualModuleId = `\\0${virtualModuleId}`;\n\n      const filenames = readdirSync('src/assets/locales/').map((filename) => filename.replace('.json', ''));\n\n      return {\n        name: 'vue-i18n-supported-locales',\n\n        resolveId(id) {\n          if (id === virtualModuleId) {\n            return resolvedVirtualModuleId;\n          }\n        },\n\n        load(id) {\n          if (id === resolvedVirtualModuleId) {\n            return `export const SUPPORTED_LOCALES = ${JSON.stringify(filenames)}`;\n          }\n        },\n      };\n    })(),\n    svgLoader(),\n    externalCSSPlugin(),\n    woodpeckerInfoPlugin(),\n    prismjs({\n      languages: ['yaml'],\n    }),\n    tailwindcss(),\n  ],\n  resolve: {\n    alias: {\n      '~/': `${path.resolve(__dirname, 'src')}/`,\n    },\n  },\n  logLevel: 'warn',\n  server: {\n    allowedHosts: true,\n    host: process.env.VITE_DEV_SERVER_HOST ?? '127.0.0.1',\n    port: 8010,\n    proxy:\n      viteDevProxy !== undefined\n        ? {\n            '/api': {\n              target: viteDevProxy,\n              changeOrigin: true,\n              headers: {\n                cookie: viteUserSessCookie !== undefined ? `user_sess=${viteUserSessCookie}` : '',\n              },\n            },\n            '/web-config.js': {\n              target: viteDevProxy,\n              changeOrigin: true,\n              headers: {\n                cookie: viteUserSessCookie !== undefined ? `user_sess=${viteUserSessCookie}` : '',\n              },\n            },\n            '/authorize': {\n              target: viteDevProxy,\n              changeOrigin: true,\n            },\n          }\n        : undefined,\n  },\n  test: {\n    globals: true,\n    environment: 'jsdom',\n  },\n} as ViteUserConfig);\n"
  },
  {
    "path": "web/web.go",
    "content": "// Copyright 2023 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !external_web\n\npackage web\n\nimport (\n\t\"embed\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n)\n\n//go:embed dist/*\nvar webFiles embed.FS\n\nfunc HTTPFS() (http.FileSystem, error) {\n\thttpFS, err := fs.Sub(webFiles, \"dist\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn http.FS(httpFS), nil\n}\n\nfunc Lookup(path string) (buf []byte, err error) {\n\thttpFS, err := HTTPFS()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfile, err := httpFS.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tbuf, err = io.ReadAll(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn buf, nil\n}\n"
  },
  {
    "path": "web/web_external.go",
    "content": "// Copyright 2025 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build external_web\n\npackage web\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n)\n\nvar webUIRoot string // do not forget to set it at build time\n\nfunc HTTPFS() (http.FileSystem, error) {\n\tif stat, err := os.Stat(webUIRoot); err != nil {\n\t\treturn nil, fmt.Errorf(\"compiled in WebUI root path '%s' does not exist: %w\", webUIRoot, err)\n\t} else if !stat.IsDir() {\n\t\treturn nil, fmt.Errorf(\"compiled in WebUI root path '%s' exist but is no directory\", webUIRoot)\n\t}\n\treturn http.Dir(webUIRoot), nil\n}\n\nfunc Lookup(path string) (buf []byte, err error) {\n\thttpFS, err := HTTPFS()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfile, err := httpFS.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\n\tbuf, err = io.ReadAll(file)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn buf, nil\n}\n"
  },
  {
    "path": "woodpecker-go/LICENSE",
    "content": "Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2021 Woodpecker Authors\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "woodpecker-go/README.md",
    "content": "# woodpecker-go\n\n```Go\nimport (\n  \"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n  \"golang.org/x/oauth2\"\n)\n\nconst (\n  token = \"dummyToken\"\n  host  = \"http://woodpecker.company.tld\"\n)\n\nfunc main() {\n  // create an http client with oauth authentication.\n  config := new(oauth2.Config)\n  authenticator := config.Client(\n    oauth2.NoContext,\n    &oauth2.Token{\n      AccessToken: token,\n    },\n  )\n\n  // create the woodpecker client with authenticator\n  client := woodpecker.NewClient(host, authenticator)\n\n  // gets the current user\n  user, err := client.Self()\n  fmt.Println(user, err)\n\n  // gets the named repository information\n  repo, err := client.RepoLookup(\"woodpecker-ci/woodpecker\")\n  fmt.Println(repo, err)\n}\n```\n"
  },
  {
    "path": "woodpecker-go/woodpecker/agent.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport \"fmt\"\n\nconst (\n\tpathAgents     = \"%s/api/agents\"\n\tpathAgent      = \"%s/api/agents/%d\"\n\tpathAgentTasks = \"%s/api/agents/%d/tasks\"\n)\n\n// AgentCreate creates a new agent.\nfunc (c *client) AgentCreate(in *Agent) (*Agent, error) {\n\tout := new(Agent)\n\turi := fmt.Sprintf(pathAgents, c.addr)\n\treturn out, c.post(uri, in, out)\n}\n\n// AgentList returns a list of all registered agents.\nfunc (c *client) AgentList() ([]*Agent, error) {\n\tout := make([]*Agent, 0, 5)\n\turi := fmt.Sprintf(pathAgents, c.addr)\n\treturn out, c.get(uri, &out)\n}\n\n// Agent returns an agent by id.\nfunc (c *client) Agent(agentID int64) (*Agent, error) {\n\tout := new(Agent)\n\turi := fmt.Sprintf(pathAgent, c.addr, agentID)\n\treturn out, c.get(uri, out)\n}\n\n// AgentUpdate updates the agent with the provided Agent struct.\nfunc (c *client) AgentUpdate(in *Agent) (*Agent, error) {\n\tout := new(Agent)\n\turi := fmt.Sprintf(pathAgent, c.addr, in.ID)\n\treturn out, c.patch(uri, in, out)\n}\n\n// AgentDelete deletes the agent with the given id.\nfunc (c *client) AgentDelete(agentID int64) error {\n\turi := fmt.Sprintf(pathAgent, c.addr, agentID)\n\treturn c.delete(uri)\n}\n\n// AgentTasksList returns a list of all tasks for the agent with the given id.\nfunc (c *client) AgentTasksList(agentID int64) ([]*Task, error) {\n\tout := make([]*Task, 0, 5)\n\turi := fmt.Sprintf(pathAgentTasks, c.addr, agentID)\n\treturn out, c.get(uri, &out)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/agent_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestClient_AgentCreate(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tinput    *Agent\n\t\texpected *Agent\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusCreated)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"name\":\"new_agent\",\"backend\":\"local\",\"capacity\":2,\"version\":\"1.0.0\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tinput:    &Agent{Name: \"new_agent\", Backend: \"local\", Capacity: 2, Version: \"1.0.0\"},\n\t\t\texpected: &Agent{ID: 1, Name: \"new_agent\", Backend: \"local\", Capacity: 2, Version: \"1.0.0\"},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid input\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\tinput:    &Agent{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tinput:    &Agent{Name: \"new_agent\", Backend: \"local\", Capacity: 2, Version: \"1.0.0\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tagent, err := client.AgentCreate(tt.input)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, agent, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestClient_AgentList(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\texpected []*Agent\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": 1,\n\t\t\t\t\t\t\"name\": \"agent-1\",\n\t\t\t\t\t\t\"backend\": \"local\",\n\t\t\t\t\t\t\"capacity\": 2,\n\t\t\t\t\t\t\"version\": \"1.0.0\"\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": 2,\n\t\t\t\t\t\t\"name\": \"agent-2\",\n\t\t\t\t\t\t\"backend\": \"kubernetes\",\n\t\t\t\t\t\t\"capacity\": 4,\n\t\t\t\t\t\t\"version\": \"1.0.0\"\n\t\t\t\t\t}\n\t\t\t\t]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: []*Agent{\n\t\t\t\t{\n\t\t\t\t\tID:       1,\n\t\t\t\t\tName:     \"agent-1\",\n\t\t\t\t\tBackend:  \"local\",\n\t\t\t\t\tCapacity: 2,\n\t\t\t\t\tVersion:  \"1.0.0\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tID:       2,\n\t\t\t\t\tName:     \"agent-2\",\n\t\t\t\t\tBackend:  \"kubernetes\",\n\t\t\t\t\tCapacity: 4,\n\t\t\t\t\tVersion:  \"1.0.0\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tagents, err := client.AgentList()\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, agents)\n\t\t})\n\t}\n}\n\nfunc TestClient_Agent(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tagentID  int64\n\t\texpected *Agent\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"name\":\"agent-1\",\"backend\":\"local\",\"capacity\":2,\"version\":\"1.0.0\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tagentID:  1,\n\t\t\texpected: &Agent{ID: 1, Name: \"agent-1\", Backend: \"local\", Capacity: 2, Version: \"1.0.0\"},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tagentID:  999,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tagentID:  1,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tagentID:  1,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tagent, err := client.Agent(tt.agentID)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, agent)\n\t\t})\n\t}\n}\n\nfunc TestClient_AgentUpdate(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tinput    *Agent\n\t\texpected *Agent\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"name\":\"updated_agent\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tinput:    &Agent{ID: 1, Name: \"existing_agent\"},\n\t\t\texpected: &Agent{ID: 1, Name: \"updated_agent\"},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tinput:    &Agent{ID: 999, Name: \"nonexistent_agent\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid input\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\tinput:    &Agent{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tinput:    &Agent{ID: 1, Name: \"existing_agent\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tagent, err := client.AgentUpdate(tt.input)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, agent, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestClient_AgentDelete(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\thandler http.HandlerFunc\n\t\tagentID int64\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t\tagentID: 1,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tagentID: 999,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tagentID: 1,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\terr := client.AgentDelete(tt.agentID)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t}\n}\n\nfunc TestClient_AgentTasksList(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tagentID  int64\n\t\texpected []*Task\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"4696\",\n\t\t\t\t\t\t\"data\": \"\",\n\t\t\t\t\t\t\"labels\": {\n\t\t\t\t\t\t\t\"platform\": \"linux/amd64\",\n\t\t\t\t\t\t\t\"repo\": \"woodpecker-ci/woodpecker\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"4697\",\n\t\t\t\t\t\t\"data\": \"\",\n\t\t\t\t\t\t\"labels\": {\n\t\t\t\t\t\t\t\"platform\": \"linux/arm64\",\n\t\t\t\t\t\t\t\"repo\": \"woodpecker-ci/woodpecker\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tagentID: 1,\n\t\t\texpected: []*Task{\n\t\t\t\t{\n\t\t\t\t\tID: \"4696\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"platform\": \"linux/amd64\",\n\t\t\t\t\t\t\"repo\":     \"woodpecker-ci/woodpecker\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tID: \"4697\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"platform\": \"linux/arm64\",\n\t\t\t\t\t\t\"repo\":     \"woodpecker-ci/woodpecker\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tagentID:  999,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tagentID:  1,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodGet {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tagentID:  1,\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\ttasks, err := client.AgentTasksList(tt.agentID)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, tasks)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/client.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/httputil\"\n)\n\nconst (\n\tpathLogLevel = \"%s/api/log-level\"\n\n\t//nolint:godot\n\t// TODO: implement endpoints\n\t// pathFeed           = \"%s/api/user/feed\"\n\t// pathVersion        = \"%s/version\"\n)\n\ntype ClientError struct {\n\tStatusCode int\n\tMessage    string\n}\n\nfunc (e *ClientError) Error() string {\n\treturn fmt.Sprintf(\"client error %d: %s\", e.StatusCode, e.Message)\n}\n\ntype client struct {\n\tclient *http.Client\n\taddr   string\n}\n\n// New returns a client at the specified url.\nfunc New(uri string) Client {\n\twrappedClient := httputil.WrapClient(http.DefaultClient, \"go-client\")\n\treturn &client{wrappedClient, strings.TrimSuffix(uri, \"/\")}\n}\n\n// NewClient returns a client at the specified url.\nfunc NewClient(uri string, cli *http.Client) Client {\n\twrappedClient := httputil.WrapClient(cli, \"go-client\")\n\treturn &client{wrappedClient, strings.TrimSuffix(uri, \"/\")}\n}\n\n// SetClient sets the http.Client.\nfunc (c *client) SetClient(client *http.Client) {\n\tc.client = client\n}\n\n// SetAddress sets the server address.\nfunc (c *client) SetAddress(addr string) {\n\tc.addr = addr\n}\n\n// LogLevel returns the current logging level.\nfunc (c *client) LogLevel() (*LogLevel, error) {\n\tout := new(LogLevel)\n\turi := fmt.Sprintf(pathLogLevel, c.addr)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// SetLogLevel sets the logging level of the server.\nfunc (c *client) SetLogLevel(in *LogLevel) (*LogLevel, error) {\n\tout := new(LogLevel)\n\turi := fmt.Sprintf(pathLogLevel, c.addr)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n//\n// HTTP request helper functions.\n//\n\n// Helper function for making an http GET request.\nfunc (c *client) get(rawURL string, out any) error {\n\treturn c.do(rawURL, http.MethodGet, nil, out)\n}\n\n// Helper function for making an http POST request.\nfunc (c *client) post(rawURL string, in, out any) error {\n\treturn c.do(rawURL, http.MethodPost, in, out)\n}\n\n// Helper function for making an http PATCH request.\nfunc (c *client) patch(rawURL string, in, out any) error {\n\treturn c.do(rawURL, http.MethodPatch, in, out)\n}\n\n// Helper function for making an http DELETE request.\nfunc (c *client) delete(rawURL string) error {\n\treturn c.do(rawURL, http.MethodDelete, nil, nil)\n}\n\n// Helper function to make an http request.\nfunc (c *client) do(rawURL, method string, in, out any) error {\n\tbody, err := c.open(rawURL, method, in)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer body.Close()\n\tif out != nil {\n\t\treturn json.NewDecoder(body).Decode(out)\n\t}\n\treturn nil\n}\n\n// Helper function to open an http request.\nfunc (c *client) open(rawURL, method string, in any) (io.ReadCloser, error) {\n\turi, err := url.Parse(rawURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq, err := http.NewRequest(method, uri.String(), nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif in != nil {\n\t\tdecoded, decodeErr := json.Marshal(in)\n\t\tif decodeErr != nil {\n\t\t\treturn nil, decodeErr\n\t\t}\n\t\tbuf := bytes.NewBuffer(decoded)\n\t\treq.Body = io.NopCloser(buf)\n\t\treq.ContentLength = int64(len(decoded))\n\t\treq.Header.Set(\"Content-Length\", strconv.Itoa(len(decoded)))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t}\n\tresp, err := c.client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif resp.StatusCode > http.StatusPartialContent {\n\t\tdefer resp.Body.Close()\n\t\tout, _ := io.ReadAll(resp.Body)\n\t\treturn nil, &ClientError{\n\t\t\tStatusCode: resp.StatusCode,\n\t\t\tMessage:    string(out),\n\t\t}\n\t}\n\treturn resp.Body, nil\n}\n\n// mapValues converts a map to `url.Values`.\nfunc mapValues(params map[string]string) url.Values {\n\tvalues := url.Values{}\n\tfor key, val := range params {\n\t\tvalues.Add(key, val)\n\t}\n\treturn values\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/client_test.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc Test_LogLevel(t *testing.T) {\n\tlogLevel := \"warn\"\n\tfixtureHandler := func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method == http.MethodPost {\n\t\t\tvar ll LogLevel\n\t\t\trequire.NoError(t, json.NewDecoder(r.Body).Decode(&ll))\n\t\t\tlogLevel = ll.Level\n\t\t}\n\n\t\t_, err := fmt.Fprintf(w, `{\n\t\t\t\"log-level\": \"%s\"\n\t}`, logLevel)\n\t\tassert.NoError(t, err)\n\t}\n\n\tts := httptest.NewServer(http.HandlerFunc(fixtureHandler))\n\tdefer ts.Close()\n\n\tclient := NewClient(ts.URL, http.DefaultClient)\n\n\tcurLvl, err := client.LogLevel()\n\tassert.NoError(t, err)\n\tassert.True(t, strings.EqualFold(curLvl.Level, logLevel))\n\n\tnewLvl, err := client.SetLogLevel(&LogLevel{Level: \"trace\"})\n\tassert.NoError(t, err)\n\tassert.True(t, strings.EqualFold(newLvl.Level, logLevel))\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/const.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\n// Event values.\nconst (\n\tEventPush         = \"push\"\n\tEventPull         = \"pull_request\"\n\tEventPullClosed   = \"pull_request_closed\"\n\tEventPullMetadata = \"pull_request_metadata\"\n\tEventTag          = \"tag\"\n\tEventRelease      = \"release\"\n\tEventDeploy       = \"deployment\"\n\tEventCron         = \"cron\"\n\tEventManual       = \"manual\"\n)\n\n// Status values.\nconst (\n\tStatusBlocked = \"blocked\"\n\tStatusSkipped = \"skipped\"\n\tStatusPending = \"pending\"\n\tStatusRunning = \"running\"\n\tStatusSuccess = \"success\"\n\tStatusFailure = \"failure\"\n\tStatusKilled  = \"killed\"\n\tStatusError   = \"error\"\n)\n\n// LogEntryType identifies the type of line in the logs.\ntype LogEntryType int\n\nconst (\n\tLogEntryStdout LogEntryType = iota\n\tLogEntryStderr\n\tLogEntryExitCode\n\tLogEntryMetadata\n\tLogEntryProgress\n)\n\n// StepType identifies the type of step.\ntype StepType string\n\nconst (\n\tStepTypeClone    StepType = \"clone\"\n\tStepTypeService  StepType = \"service\"\n\tStepTypePlugin   StepType = \"plugin\"\n\tStepTypeCommands StepType = \"commands\"\n\tStepTypeCache    StepType = \"cache\"\n)\n\nconst defaultForgeID = 1\n"
  },
  {
    "path": "woodpecker-go/woodpecker/global_registry.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\nconst (\n\tpathGlobalRegistries = \"%s/api/registries\"\n\tpathGlobalRegistry   = \"%s/api/registries/%s\"\n)\n\n// GlobalRegistry returns an global registry by name.\nfunc (c *client) GlobalRegistry(registry string) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathGlobalRegistry, c.addr, registry)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// GlobalRegistryList returns a list of all global registries.\nfunc (c *client) GlobalRegistryList(opt RegistryListOptions) ([]*Registry, error) {\n\tvar out []*Registry\n\turi, _ := url.Parse(fmt.Sprintf(pathGlobalRegistries, c.addr))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// GlobalRegistryCreate creates a global registry.\nfunc (c *client) GlobalRegistryCreate(in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathGlobalRegistries, c.addr)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// GlobalRegistryUpdate updates a global registry.\nfunc (c *client) GlobalRegistryUpdate(in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathGlobalRegistry, c.addr, in.Address)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// GlobalRegistryDelete deletes a global registry.\nfunc (c *client) GlobalRegistryDelete(registry string) error {\n\turi := fmt.Sprintf(pathGlobalRegistry, c.addr, registry)\n\treturn c.delete(uri)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/global_secret.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\nconst (\n\tpathGlobalSecrets = \"%s/api/secrets\"\n\tpathGlobalSecret  = \"%s/api/secrets/%s\"\n)\n\n// GlobalSecret returns an global secret by name.\nfunc (c *client) GlobalSecret(secret string) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathGlobalSecret, c.addr, secret)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// GlobalSecretList returns a list of all global secrets.\nfunc (c *client) GlobalSecretList(opt SecretListOptions) ([]*Secret, error) {\n\tvar out []*Secret\n\turi, _ := url.Parse(fmt.Sprintf(pathGlobalSecrets, c.addr))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// GlobalSecretCreate creates a global secret.\nfunc (c *client) GlobalSecretCreate(in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathGlobalSecrets, c.addr)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// GlobalSecretUpdate updates a global secret.\nfunc (c *client) GlobalSecretUpdate(in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathGlobalSecret, c.addr, in.Name)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// GlobalSecretDelete deletes a global secret.\nfunc (c *client) GlobalSecretDelete(secret string) error {\n\turi := fmt.Sprintf(pathGlobalSecret, c.addr, secret)\n\treturn c.delete(uri)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/httputil/useragent.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\n// UserAgentRoundTripper is an http.RoundTripper that sets a custom User-Agent header\n// on all outgoing requests.\ntype UserAgentRoundTripper struct {\n\tbase      http.RoundTripper\n\tuserAgent string\n}\n\n// NewUserAgentRoundTripper creates a new RoundTripper that adds the Woodpecker User-Agent\n// to all requests. If base is nil, http.DefaultTransport is used.\nfunc NewUserAgentRoundTripper(base http.RoundTripper, component string) *UserAgentRoundTripper {\n\tif base == nil {\n\t\tbase = http.DefaultTransport\n\t}\n\n\tuserAgent := fmt.Sprintf(\"Woodpecker/%s\", version.String())\n\tif component != \"\" {\n\t\tuserAgent = fmt.Sprintf(\"%s (%s)\", userAgent, component)\n\t}\n\n\treturn &UserAgentRoundTripper{\n\t\tbase:      base,\n\t\tuserAgent: userAgent,\n\t}\n}\n\n// RoundTrip implements the http.RoundTripper interface.\nfunc (rt *UserAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\t// Clone the request to avoid modifying the original\n\treqClone := req.Clone(req.Context())\n\n\t// Set the User-Agent header if not already set\n\tif reqClone.Header.Get(\"User-Agent\") == \"\" {\n\t\treqClone.Header.Set(\"User-Agent\", rt.userAgent)\n\t}\n\n\t// Execute the request using the base transport\n\treturn rt.base.RoundTrip(reqClone)\n}\n\n// WrapClient wraps an existing http.Client with the UserAgentRoundTripper.\n// If client is nil, a new client with default settings is created.\nfunc WrapClient(client *http.Client, component string) *http.Client {\n\tif client == nil {\n\t\tclient = &http.Client{}\n\t}\n\n\tclient.Transport = NewUserAgentRoundTripper(client.Transport, component)\n\treturn client\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/httputil/useragent_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage httputil\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"go.woodpecker-ci.org/woodpecker/v3/version\"\n)\n\nfunc TestNewUserAgentRoundTripper(t *testing.T) {\n\tt.Run(\"with custom component\", func(t *testing.T) {\n\t\trt := NewUserAgentRoundTripper(nil, \"test-component\")\n\t\tassert.NotNil(t, rt)\n\t\tassert.NotNil(t, rt.base)\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s (test-component)\", version.String())\n\t\tassert.Equal(t, expectedUA, rt.userAgent)\n\t})\n\n\tt.Run(\"without component\", func(t *testing.T) {\n\t\trt := NewUserAgentRoundTripper(nil, \"\")\n\t\tassert.NotNil(t, rt)\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s\", version.String())\n\t\tassert.Equal(t, expectedUA, rt.userAgent)\n\t})\n\n\tt.Run(\"with custom base transport\", func(t *testing.T) {\n\t\tcustomTransport := &http.Transport{}\n\t\trt := NewUserAgentRoundTripper(customTransport, \"custom\")\n\t\tassert.Equal(t, customTransport, rt.base)\n\t})\n}\n\nfunc TestUserAgentRoundTripper_RoundTrip(t *testing.T) {\n\t// Create a test server to capture requests\n\tvar capturedUserAgent string\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcapturedUserAgent = r.Header.Get(\"User-Agent\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"OK\"))\n\t}))\n\tdefer server.Close()\n\n\tt.Run(\"sets user-agent when not present\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"agent\"),\n\t\t}\n\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\texpectedUA := fmt.Sprintf(\"Woodpecker/%s (agent)\", version.String())\n\t\tassert.Equal(t, expectedUA, capturedUserAgent)\n\t})\n\n\tt.Run(\"preserves existing user-agent\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"agent\"),\n\t\t}\n\n\t\tcustomUA := \"CustomUserAgent/1.0\"\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\t\treq.Header.Set(\"User-Agent\", customUA)\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\tassert.Equal(t, customUA, capturedUserAgent)\n\t})\n\n\tt.Run(\"does not modify original request\", func(t *testing.T) {\n\t\tclient := &http.Client{\n\t\t\tTransport: NewUserAgentRoundTripper(nil, \"test\"),\n\t\t}\n\n\t\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\t\tassert.NoError(t, err)\n\n\t\toriginalUserAgent := req.Header.Get(\"User-Agent\")\n\n\t\tresp, err := client.Do(req)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, resp)\n\t\tdefer resp.Body.Close()\n\n\t\t// Original request should remain unchanged\n\t\tassert.Equal(t, originalUserAgent, req.Header.Get(\"User-Agent\"))\n\t})\n}\n\nfunc TestWrapClient(t *testing.T) {\n\tt.Run(\"wraps existing client\", func(t *testing.T) {\n\t\toriginalClient := &http.Client{}\n\t\twrappedClient := WrapClient(originalClient, \"cli\")\n\n\t\tassert.Equal(t, originalClient, wrappedClient)\n\t\tassert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport)\n\t})\n\n\tt.Run(\"creates new client when nil\", func(t *testing.T) {\n\t\twrappedClient := WrapClient(nil, \"server\")\n\n\t\tassert.NotNil(t, wrappedClient)\n\t\tassert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport)\n\t})\n\n\tt.Run(\"preserves existing transport\", func(t *testing.T) {\n\t\tcustomTransport := &http.Transport{}\n\t\toriginalClient := &http.Client{\n\t\t\tTransport: customTransport,\n\t\t}\n\n\t\twrappedClient := WrapClient(originalClient, \"test\")\n\n\t\trt, ok := wrappedClient.Transport.(*UserAgentRoundTripper)\n\t\tassert.True(t, ok)\n\t\tassert.Equal(t, customTransport, rt.base)\n\t})\n}\n\nfunc TestIntegration_UserAgentInRealRequest(t *testing.T) {\n\t// Test with a real HTTP server\n\tvar receivedHeaders http.Header\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\treceivedHeaders = r.Header.Clone()\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tclient := WrapClient(nil, \"integration-test\")\n\n\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\tassert.NoError(t, err)\n\n\tresp, err := client.Do(req)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, resp)\n\tdefer resp.Body.Close()\n\n\tuserAgent := receivedHeaders.Get(\"User-Agent\")\n\tassert.NotEmpty(t, userAgent)\n\tassert.Contains(t, userAgent, \"Woodpecker/\")\n\tassert.Contains(t, userAgent, \"(integration-test)\")\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/interface.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"net/http\"\n)\n\n// Client is used to communicate with a Woodpecker server.\ntype Client interface {\n\t// SetClient sets the http.Client.\n\tSetClient(*http.Client)\n\n\t// SetAddress sets the server address.\n\tSetAddress(string)\n\n\t// Self returns the currently authenticated user.\n\tSelf() (*User, error)\n\n\t// User returns a user by login.\n\t// It is recommended to specify forgeID (default is 1).\n\tUser(login string, forgeID ...int64) (*User, error)\n\n\t// UserList returns a list of all registered users.\n\tUserList(opt UserListOptions) ([]*User, error)\n\n\t// UserPost creates a new user account.\n\tUserPost(*User) (*User, error)\n\n\t// UserPatch updates a user account.\n\tUserPatch(*User) (*User, error)\n\n\t// UserDel deletes a user account.\n\t// It is recommended to specify forgeID (default is 1).\n\tUserDel(login string, forgeID ...int64) error\n\n\t// Repo returns a repository by name.\n\tRepo(repoID int64) (*Repo, error)\n\n\t// RepoLookup returns a repository id by the owner and name.\n\tRepoLookup(repoFullName string) (*Repo, error)\n\n\t// RepoList returns a list of all repositories to which the user has explicit\n\t// access in the host system.\n\tRepoList(opt RepoListOptions) ([]*Repo, error)\n\n\t// RepoPost activates a repository.\n\tRepoPost(opt RepoPostOptions) (*Repo, error)\n\n\t// RepoPatch updates a repository.\n\tRepoPatch(repoID int64, repo *RepoPatch) (*Repo, error)\n\n\t// RepoMove moves the repository\n\tRepoMove(repoID int64, opt RepoMoveOptions) error\n\n\t// RepoChown updates a repository owner.\n\tRepoChown(repoID int64) (*Repo, error)\n\n\t// RepoRepair repairs the repository hooks.\n\tRepoRepair(repoID int64) error\n\n\t// RepoDel deletes a repository.\n\tRepoDel(repoID int64) error\n\n\t// Pipeline returns a repository pipeline by number.\n\tPipeline(repoID, pipeline int64) (*Pipeline, error)\n\n\t// PipelineLast returns the latest repository pipeline.\n\tPipelineLast(repoID int64, opt PipelineLastOptions) (*Pipeline, error)\n\n\t// PipelineList returns a list of recent pipelines for the\n\t// the specified repository.\n\tPipelineList(repoID int64, opt PipelineListOptions) ([]*Pipeline, error)\n\n\tPipelineDelete(repoID, pipeline int64) error\n\n\t// PipelineQueue returns a list of enqueued pipelines.\n\tPipelineQueue() ([]*Feed, error)\n\n\t// PipelineCreate returns creates a pipeline on specified branch.\n\tPipelineCreate(repoID int64, opts *PipelineOptions) (*Pipeline, error)\n\n\t// PipelineStart re-starts a stopped pipeline.\n\tPipelineStart(repoID, num int64, opt PipelineStartOptions) (*Pipeline, error)\n\n\t// PipelineStop stops the given pipeline.\n\tPipelineStop(repoID, pipeline int64) error\n\n\t// PipelineApprove approves a blocked pipeline.\n\tPipelineApprove(repoID, pipeline int64) (*Pipeline, error)\n\n\t// PipelineDecline declines a blocked pipeline.\n\tPipelineDecline(repoID, pipeline int64) (*Pipeline, error)\n\n\t// PipelineMetadata returns metadata for a pipeline.\n\tPipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error)\n\n\t// StepLogEntries returns the LogEntries for the given pipeline step\n\tStepLogEntries(repoID, pipeline, stepID int64) ([]*LogEntry, error)\n\n\t// Deploy triggers a deployment for an existing pipeline using the specified\n\t// target environment.\n\tDeploy(repoID, pipeline int64, opt DeployOptions) (*Pipeline, error)\n\n\t// LogsPurge purges the pipeline logs for the specified pipeline.\n\tLogsPurge(repoID, pipeline int64) error\n\n\t// StepLogsPurge purges the pipeline logs for the specified step.\n\tStepLogsPurge(repoID, pipelineNumber, stepID int64) error\n\n\t// Registry returns a registry by hostname.\n\tRegistry(repoID int64, hostname string) (*Registry, error)\n\n\t// RegistryList returns a list of all repository registries.\n\tRegistryList(repoID int64, opt RegistryListOptions) ([]*Registry, error)\n\n\t// RegistryCreate creates a registry.\n\tRegistryCreate(repoID int64, registry *Registry) (*Registry, error)\n\n\t// RegistryUpdate updates a registry.\n\tRegistryUpdate(repoID int64, registry *Registry) (*Registry, error)\n\n\t// RegistryDelete deletes a registry.\n\tRegistryDelete(repoID int64, hostname string) error\n\n\t// OrgRegistry returns an organization registry by address.\n\tOrgRegistry(orgID int64, registry string) (*Registry, error)\n\n\t// OrgRegistryList returns a list of all organization registries.\n\tOrgRegistryList(orgID int64, opt RegistryListOptions) ([]*Registry, error)\n\n\t// OrgRegistryCreate creates an organization registry.\n\tOrgRegistryCreate(orgID int64, registry *Registry) (*Registry, error)\n\n\t// OrgRegistryUpdate updates an organization registry.\n\tOrgRegistryUpdate(orgID int64, registry *Registry) (*Registry, error)\n\n\t// OrgRegistryDelete deletes an organization registry.\n\tOrgRegistryDelete(orgID int64, registry string) error\n\n\t// GlobalRegistry returns an global registry by address.\n\tGlobalRegistry(registry string) (*Registry, error)\n\n\t// GlobalRegistryList returns a list of all global registries.\n\tGlobalRegistryList(opt RegistryListOptions) ([]*Registry, error)\n\n\t// GlobalRegistryCreate creates a global registry.\n\tGlobalRegistryCreate(registry *Registry) (*Registry, error)\n\n\t// GlobalRegistryUpdate updates a global registry.\n\tGlobalRegistryUpdate(registry *Registry) (*Registry, error)\n\n\t// GlobalRegistryDelete deletes a global registry.\n\tGlobalRegistryDelete(registry string) error\n\n\t// Secret returns a secret by name.\n\tSecret(repoID int64, secret string) (*Secret, error)\n\n\t// SecretList returns a list of all repository secrets.\n\tSecretList(repoID int64, opt SecretListOptions) ([]*Secret, error)\n\n\t// SecretCreate creates a secret.\n\tSecretCreate(repoID int64, secret *Secret) (*Secret, error)\n\n\t// SecretUpdate updates a secret.\n\tSecretUpdate(repoID int64, secret *Secret) (*Secret, error)\n\n\t// SecretDelete deletes a secret.\n\tSecretDelete(repoID int64, secret string) error\n\n\t// Org returns an organization by name.\n\tOrg(orgID int64) (*Org, error)\n\n\t// OrgLookup returns an organization id by name.\n\tOrgLookup(orgName string) (*Org, error)\n\n\t// OrgList returns a list of all organizations.\n\tOrgList(opt ListOptions) ([]*Org, error)\n\n\t// OrgSecret returns an organization secret by name.\n\tOrgSecret(orgID int64, secret string) (*Secret, error)\n\n\t// OrgSecretList returns a list of all organization secrets.\n\tOrgSecretList(orgID int64, opt SecretListOptions) ([]*Secret, error)\n\n\t// OrgSecretCreate creates an organization secret.\n\tOrgSecretCreate(orgID int64, secret *Secret) (*Secret, error)\n\n\t// OrgSecretUpdate updates an organization secret.\n\tOrgSecretUpdate(orgID int64, secret *Secret) (*Secret, error)\n\n\t// OrgSecretDelete deletes an organization secret.\n\tOrgSecretDelete(orgID int64, secret string) error\n\n\t// GlobalSecret returns an global secret by name.\n\tGlobalSecret(secret string) (*Secret, error)\n\n\t// GlobalSecretList returns a list of all global secrets.\n\tGlobalSecretList(opt SecretListOptions) ([]*Secret, error)\n\n\t// GlobalSecretCreate creates a global secret.\n\tGlobalSecretCreate(secret *Secret) (*Secret, error)\n\n\t// GlobalSecretUpdate updates a global secret.\n\tGlobalSecretUpdate(secret *Secret) (*Secret, error)\n\n\t// GlobalSecretDelete deletes a global secret.\n\tGlobalSecretDelete(secret string) error\n\n\t// QueueInfo returns the queue state.\n\tQueueInfo() (*Info, error)\n\n\t// LogLevel returns the current logging level.\n\tLogLevel() (*LogLevel, error)\n\n\t// SetLogLevel sets the server's logging level.\n\tSetLogLevel(logLevel *LogLevel) (*LogLevel, error)\n\n\t// CronList list all cron jobs of a repo.\n\tCronList(repoID int64, opt CronListOptions) ([]*Cron, error)\n\n\t// CronGet get a specific cron job of a repo by id.\n\tCronGet(repoID, cronID int64) (*Cron, error)\n\n\t// CronDelete delete a specific cron job of a repo by id.\n\tCronDelete(repoID, cronID int64) error\n\n\t// CronCreate create a new cron job in a repo.\n\tCronCreate(repoID int64, cron *Cron) (*Cron, error)\n\n\t// CronUpdate update an existing cron job of a repo.\n\tCronUpdate(repoID int64, cron *Cron) (*Cron, error)\n\n\t// AgentList returns a list of all registered agents.\n\tAgentList() ([]*Agent, error)\n\n\t// Agent returns an agent by id.\n\tAgent(int64) (*Agent, error)\n\n\t// AgentCreate creates a new agent.\n\tAgentCreate(*Agent) (*Agent, error)\n\n\t// AgentUpdate updates an existing agent.\n\tAgentUpdate(*Agent) (*Agent, error)\n\n\t// AgentDelete deletes an agent.\n\tAgentDelete(int64) error\n\n\t// AgentTasksList returns a list of all tasks executed by an agent.\n\tAgentTasksList(int64) ([]*Task, error)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/list_options.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\n// ListOptions represents the options for the Woodpecker API pagination.\ntype ListOptions struct {\n\tPage    int\n\tPerPage int\n}\n\n// getURLQuery returns the query string for the ListOptions.\nfunc (o ListOptions) getURLQuery() url.Values {\n\tquery := make(url.Values)\n\tif o.Page > 0 {\n\t\tquery.Add(\"page\", fmt.Sprintf(\"%d\", o.Page))\n\t}\n\tif o.PerPage > 0 {\n\t\tquery.Add(\"perPage\", fmt.Sprintf(\"%d\", o.PerPage))\n\t}\n\n\treturn query\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/list_options_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestListOptions_getURLQuery(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\topts     ListOptions\n\t\texpected url.Values\n\t}{\n\t\t{\n\t\t\tname:     \"no options\",\n\t\t\topts:     ListOptions{},\n\t\t\texpected: url.Values{},\n\t\t},\n\t\t{\n\t\t\tname:     \"with page\",\n\t\t\topts:     ListOptions{Page: 2},\n\t\t\texpected: url.Values{\"page\": {\"2\"}},\n\t\t},\n\t\t{\n\t\t\tname:     \"with per page\",\n\t\t\topts:     ListOptions{PerPage: 10},\n\t\t\texpected: url.Values{\"perPage\": {\"10\"}},\n\t\t},\n\t\t{\n\t\t\tname:     \"with page and per page\",\n\t\t\topts:     ListOptions{Page: 3, PerPage: 20},\n\t\t\texpected: url.Values{\"page\": {\"3\"}, \"perPage\": {\"20\"}},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tactual := tt.opts.getURLQuery()\n\t\t\tassert.Equal(t, tt.expected, actual)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/mocks/mock_Client.go",
    "content": "// Code generated by mockery; DO NOT EDIT.\n// github.com/vektra/mockery\n// template: testify\n\npackage mocks\n\nimport (\n\t\"net/http\"\n\n\tmock \"github.com/stretchr/testify/mock\"\n\t\"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker\"\n)\n\n// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.\n// The first argument is typically a *testing.T value.\nfunc NewMockClient(t interface {\n\tmock.TestingT\n\tCleanup(func())\n}) *MockClient {\n\tmock := &MockClient{}\n\tmock.Mock.Test(t)\n\n\tt.Cleanup(func() { mock.AssertExpectations(t) })\n\n\treturn mock\n}\n\n// MockClient is an autogenerated mock type for the Client type\ntype MockClient struct {\n\tmock.Mock\n}\n\ntype MockClient_Expecter struct {\n\tmock *mock.Mock\n}\n\nfunc (_m *MockClient) EXPECT() *MockClient_Expecter {\n\treturn &MockClient_Expecter{mock: &_m.Mock}\n}\n\n// Agent provides a mock function for the type MockClient\nfunc (_mock *MockClient) Agent(n int64) (*woodpecker.Agent, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Agent\")\n\t}\n\n\tvar r0 *woodpecker.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*woodpecker.Agent, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *woodpecker.Agent); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Agent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Agent'\ntype MockClient_Agent_Call struct {\n\t*mock.Call\n}\n\n// Agent is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockClient_Expecter) Agent(n interface{}) *MockClient_Agent_Call {\n\treturn &MockClient_Agent_Call{Call: _e.mock.On(\"Agent\", n)}\n}\n\nfunc (_c *MockClient_Agent_Call) Run(run func(n int64)) *MockClient_Agent_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Agent_Call) Return(agent *woodpecker.Agent, err error) *MockClient_Agent_Call {\n\t_c.Call.Return(agent, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Agent_Call) RunAndReturn(run func(n int64) (*woodpecker.Agent, error)) *MockClient_Agent_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) AgentCreate(agent *woodpecker.Agent) (*woodpecker.Agent, error) {\n\tret := _mock.Called(agent)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentCreate\")\n\t}\n\n\tvar r0 *woodpecker.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Agent) (*woodpecker.Agent, error)); ok {\n\t\treturn returnFunc(agent)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Agent) *woodpecker.Agent); ok {\n\t\tr0 = returnFunc(agent)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Agent) error); ok {\n\t\tr1 = returnFunc(agent)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_AgentCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentCreate'\ntype MockClient_AgentCreate_Call struct {\n\t*mock.Call\n}\n\n// AgentCreate is a helper method to define mock.On call\n//   - agent *woodpecker.Agent\nfunc (_e *MockClient_Expecter) AgentCreate(agent interface{}) *MockClient_AgentCreate_Call {\n\treturn &MockClient_AgentCreate_Call{Call: _e.mock.On(\"AgentCreate\", agent)}\n}\n\nfunc (_c *MockClient_AgentCreate_Call) Run(run func(agent *woodpecker.Agent)) *MockClient_AgentCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Agent\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Agent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentCreate_Call) Return(agent1 *woodpecker.Agent, err error) *MockClient_AgentCreate_Call {\n\t_c.Call.Return(agent1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentCreate_Call) RunAndReturn(run func(agent *woodpecker.Agent) (*woodpecker.Agent, error)) *MockClient_AgentCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) AgentDelete(n int64) error {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) error); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_AgentDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentDelete'\ntype MockClient_AgentDelete_Call struct {\n\t*mock.Call\n}\n\n// AgentDelete is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockClient_Expecter) AgentDelete(n interface{}) *MockClient_AgentDelete_Call {\n\treturn &MockClient_AgentDelete_Call{Call: _e.mock.On(\"AgentDelete\", n)}\n}\n\nfunc (_c *MockClient_AgentDelete_Call) Run(run func(n int64)) *MockClient_AgentDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentDelete_Call) Return(err error) *MockClient_AgentDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentDelete_Call) RunAndReturn(run func(n int64) error) *MockClient_AgentDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentList provides a mock function for the type MockClient\nfunc (_mock *MockClient) AgentList() ([]*woodpecker.Agent, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentList\")\n\t}\n\n\tvar r0 []*woodpecker.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*woodpecker.Agent, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*woodpecker.Agent); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_AgentList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentList'\ntype MockClient_AgentList_Call struct {\n\t*mock.Call\n}\n\n// AgentList is a helper method to define mock.On call\nfunc (_e *MockClient_Expecter) AgentList() *MockClient_AgentList_Call {\n\treturn &MockClient_AgentList_Call{Call: _e.mock.On(\"AgentList\")}\n}\n\nfunc (_c *MockClient_AgentList_Call) Run(run func()) *MockClient_AgentList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentList_Call) Return(agents []*woodpecker.Agent, err error) *MockClient_AgentList_Call {\n\t_c.Call.Return(agents, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentList_Call) RunAndReturn(run func() ([]*woodpecker.Agent, error)) *MockClient_AgentList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentTasksList provides a mock function for the type MockClient\nfunc (_mock *MockClient) AgentTasksList(n int64) ([]*woodpecker.Task, error) {\n\tret := _mock.Called(n)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentTasksList\")\n\t}\n\n\tvar r0 []*woodpecker.Task\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) ([]*woodpecker.Task, error)); ok {\n\t\treturn returnFunc(n)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) []*woodpecker.Task); ok {\n\t\tr0 = returnFunc(n)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Task)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(n)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_AgentTasksList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentTasksList'\ntype MockClient_AgentTasksList_Call struct {\n\t*mock.Call\n}\n\n// AgentTasksList is a helper method to define mock.On call\n//   - n int64\nfunc (_e *MockClient_Expecter) AgentTasksList(n interface{}) *MockClient_AgentTasksList_Call {\n\treturn &MockClient_AgentTasksList_Call{Call: _e.mock.On(\"AgentTasksList\", n)}\n}\n\nfunc (_c *MockClient_AgentTasksList_Call) Run(run func(n int64)) *MockClient_AgentTasksList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentTasksList_Call) Return(tasks []*woodpecker.Task, err error) *MockClient_AgentTasksList_Call {\n\t_c.Call.Return(tasks, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentTasksList_Call) RunAndReturn(run func(n int64) ([]*woodpecker.Task, error)) *MockClient_AgentTasksList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// AgentUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) AgentUpdate(agent *woodpecker.Agent) (*woodpecker.Agent, error) {\n\tret := _mock.Called(agent)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for AgentUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Agent\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Agent) (*woodpecker.Agent, error)); ok {\n\t\treturn returnFunc(agent)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Agent) *woodpecker.Agent); ok {\n\t\tr0 = returnFunc(agent)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Agent)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Agent) error); ok {\n\t\tr1 = returnFunc(agent)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_AgentUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentUpdate'\ntype MockClient_AgentUpdate_Call struct {\n\t*mock.Call\n}\n\n// AgentUpdate is a helper method to define mock.On call\n//   - agent *woodpecker.Agent\nfunc (_e *MockClient_Expecter) AgentUpdate(agent interface{}) *MockClient_AgentUpdate_Call {\n\treturn &MockClient_AgentUpdate_Call{Call: _e.mock.On(\"AgentUpdate\", agent)}\n}\n\nfunc (_c *MockClient_AgentUpdate_Call) Run(run func(agent *woodpecker.Agent)) *MockClient_AgentUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Agent\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Agent)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentUpdate_Call) Return(agent1 *woodpecker.Agent, err error) *MockClient_AgentUpdate_Call {\n\t_c.Call.Return(agent1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_AgentUpdate_Call) RunAndReturn(run func(agent *woodpecker.Agent) (*woodpecker.Agent, error)) *MockClient_AgentUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) CronCreate(repoID int64, cron *woodpecker.Cron) (*woodpecker.Cron, error) {\n\tret := _mock.Called(repoID, cron)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronCreate\")\n\t}\n\n\tvar r0 *woodpecker.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Cron) (*woodpecker.Cron, error)); ok {\n\t\treturn returnFunc(repoID, cron)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Cron) *woodpecker.Cron); ok {\n\t\tr0 = returnFunc(repoID, cron)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Cron) error); ok {\n\t\tr1 = returnFunc(repoID, cron)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_CronCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronCreate'\ntype MockClient_CronCreate_Call struct {\n\t*mock.Call\n}\n\n// CronCreate is a helper method to define mock.On call\n//   - repoID int64\n//   - cron *woodpecker.Cron\nfunc (_e *MockClient_Expecter) CronCreate(repoID interface{}, cron interface{}) *MockClient_CronCreate_Call {\n\treturn &MockClient_CronCreate_Call{Call: _e.mock.On(\"CronCreate\", repoID, cron)}\n}\n\nfunc (_c *MockClient_CronCreate_Call) Run(run func(repoID int64, cron *woodpecker.Cron)) *MockClient_CronCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Cron\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Cron)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_CronCreate_Call) Return(cron1 *woodpecker.Cron, err error) *MockClient_CronCreate_Call {\n\t_c.Call.Return(cron1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_CronCreate_Call) RunAndReturn(run func(repoID int64, cron *woodpecker.Cron) (*woodpecker.Cron, error)) *MockClient_CronCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) CronDelete(repoID int64, cronID int64) error {\n\tret := _mock.Called(repoID, cronID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) error); ok {\n\t\tr0 = returnFunc(repoID, cronID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_CronDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronDelete'\ntype MockClient_CronDelete_Call struct {\n\t*mock.Call\n}\n\n// CronDelete is a helper method to define mock.On call\n//   - repoID int64\n//   - cronID int64\nfunc (_e *MockClient_Expecter) CronDelete(repoID interface{}, cronID interface{}) *MockClient_CronDelete_Call {\n\treturn &MockClient_CronDelete_Call{Call: _e.mock.On(\"CronDelete\", repoID, cronID)}\n}\n\nfunc (_c *MockClient_CronDelete_Call) Run(run func(repoID int64, cronID int64)) *MockClient_CronDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_CronDelete_Call) Return(err error) *MockClient_CronDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_CronDelete_Call) RunAndReturn(run func(repoID int64, cronID int64) error) *MockClient_CronDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronGet provides a mock function for the type MockClient\nfunc (_mock *MockClient) CronGet(repoID int64, cronID int64) (*woodpecker.Cron, error) {\n\tret := _mock.Called(repoID, cronID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronGet\")\n\t}\n\n\tvar r0 *woodpecker.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) (*woodpecker.Cron, error)); ok {\n\t\treturn returnFunc(repoID, cronID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) *woodpecker.Cron); ok {\n\t\tr0 = returnFunc(repoID, cronID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(repoID, cronID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_CronGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronGet'\ntype MockClient_CronGet_Call struct {\n\t*mock.Call\n}\n\n// CronGet is a helper method to define mock.On call\n//   - repoID int64\n//   - cronID int64\nfunc (_e *MockClient_Expecter) CronGet(repoID interface{}, cronID interface{}) *MockClient_CronGet_Call {\n\treturn &MockClient_CronGet_Call{Call: _e.mock.On(\"CronGet\", repoID, cronID)}\n}\n\nfunc (_c *MockClient_CronGet_Call) Run(run func(repoID int64, cronID int64)) *MockClient_CronGet_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_CronGet_Call) Return(cron *woodpecker.Cron, err error) *MockClient_CronGet_Call {\n\t_c.Call.Return(cron, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_CronGet_Call) RunAndReturn(run func(repoID int64, cronID int64) (*woodpecker.Cron, error)) *MockClient_CronGet_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronList provides a mock function for the type MockClient\nfunc (_mock *MockClient) CronList(repoID int64, opt woodpecker.CronListOptions) ([]*woodpecker.Cron, error) {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronList\")\n\t}\n\n\tvar r0 []*woodpecker.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.CronListOptions) ([]*woodpecker.Cron, error)); ok {\n\t\treturn returnFunc(repoID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.CronListOptions) []*woodpecker.Cron); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.CronListOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_CronList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronList'\ntype MockClient_CronList_Call struct {\n\t*mock.Call\n}\n\n// CronList is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.CronListOptions\nfunc (_e *MockClient_Expecter) CronList(repoID interface{}, opt interface{}) *MockClient_CronList_Call {\n\treturn &MockClient_CronList_Call{Call: _e.mock.On(\"CronList\", repoID, opt)}\n}\n\nfunc (_c *MockClient_CronList_Call) Run(run func(repoID int64, opt woodpecker.CronListOptions)) *MockClient_CronList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.CronListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.CronListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_CronList_Call) Return(crons []*woodpecker.Cron, err error) *MockClient_CronList_Call {\n\t_c.Call.Return(crons, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_CronList_Call) RunAndReturn(run func(repoID int64, opt woodpecker.CronListOptions) ([]*woodpecker.Cron, error)) *MockClient_CronList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// CronUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) CronUpdate(repoID int64, cron *woodpecker.Cron) (*woodpecker.Cron, error) {\n\tret := _mock.Called(repoID, cron)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for CronUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Cron\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Cron) (*woodpecker.Cron, error)); ok {\n\t\treturn returnFunc(repoID, cron)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Cron) *woodpecker.Cron); ok {\n\t\tr0 = returnFunc(repoID, cron)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Cron)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Cron) error); ok {\n\t\tr1 = returnFunc(repoID, cron)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_CronUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronUpdate'\ntype MockClient_CronUpdate_Call struct {\n\t*mock.Call\n}\n\n// CronUpdate is a helper method to define mock.On call\n//   - repoID int64\n//   - cron *woodpecker.Cron\nfunc (_e *MockClient_Expecter) CronUpdate(repoID interface{}, cron interface{}) *MockClient_CronUpdate_Call {\n\treturn &MockClient_CronUpdate_Call{Call: _e.mock.On(\"CronUpdate\", repoID, cron)}\n}\n\nfunc (_c *MockClient_CronUpdate_Call) Run(run func(repoID int64, cron *woodpecker.Cron)) *MockClient_CronUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Cron\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Cron)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_CronUpdate_Call) Return(cron1 *woodpecker.Cron, err error) *MockClient_CronUpdate_Call {\n\t_c.Call.Return(cron1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_CronUpdate_Call) RunAndReturn(run func(repoID int64, cron *woodpecker.Cron) (*woodpecker.Cron, error)) *MockClient_CronUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Deploy provides a mock function for the type MockClient\nfunc (_mock *MockClient) Deploy(repoID int64, pipeline int64, opt woodpecker.DeployOptions) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, pipeline, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Deploy\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, woodpecker.DeployOptions) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, pipeline, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, woodpecker.DeployOptions) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, pipeline, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64, woodpecker.DeployOptions) error); ok {\n\t\tr1 = returnFunc(repoID, pipeline, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Deploy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deploy'\ntype MockClient_Deploy_Call struct {\n\t*mock.Call\n}\n\n// Deploy is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\n//   - opt woodpecker.DeployOptions\nfunc (_e *MockClient_Expecter) Deploy(repoID interface{}, pipeline interface{}, opt interface{}) *MockClient_Deploy_Call {\n\treturn &MockClient_Deploy_Call{Call: _e.mock.On(\"Deploy\", repoID, pipeline, opt)}\n}\n\nfunc (_c *MockClient_Deploy_Call) Run(run func(repoID int64, pipeline int64, opt woodpecker.DeployOptions)) *MockClient_Deploy_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 woodpecker.DeployOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(woodpecker.DeployOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Deploy_Call) Return(pipeline1 *woodpecker.Pipeline, err error) *MockClient_Deploy_Call {\n\t_c.Call.Return(pipeline1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Deploy_Call) RunAndReturn(run func(repoID int64, pipeline int64, opt woodpecker.DeployOptions) (*woodpecker.Pipeline, error)) *MockClient_Deploy_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistry provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalRegistry(registry string) (*woodpecker.Registry, error) {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistry\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistry'\ntype MockClient_GlobalRegistry_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistry is a helper method to define mock.On call\n//   - registry string\nfunc (_e *MockClient_Expecter) GlobalRegistry(registry interface{}) *MockClient_GlobalRegistry_Call {\n\treturn &MockClient_GlobalRegistry_Call{Call: _e.mock.On(\"GlobalRegistry\", registry)}\n}\n\nfunc (_c *MockClient_GlobalRegistry_Call) Run(run func(registry string)) *MockClient_GlobalRegistry_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistry_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_GlobalRegistry_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistry_Call) RunAndReturn(run func(registry string) (*woodpecker.Registry, error)) *MockClient_GlobalRegistry_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalRegistryCreate(registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryCreate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryCreate'\ntype MockClient_GlobalRegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryCreate is a helper method to define mock.On call\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) GlobalRegistryCreate(registry interface{}) *MockClient_GlobalRegistryCreate_Call {\n\treturn &MockClient_GlobalRegistryCreate_Call{Call: _e.mock.On(\"GlobalRegistryCreate\", registry)}\n}\n\nfunc (_c *MockClient_GlobalRegistryCreate_Call) Run(run func(registry *woodpecker.Registry)) *MockClient_GlobalRegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryCreate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_GlobalRegistryCreate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryCreate_Call) RunAndReturn(run func(registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_GlobalRegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalRegistryDelete(registry string) error {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_GlobalRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryDelete'\ntype MockClient_GlobalRegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryDelete is a helper method to define mock.On call\n//   - registry string\nfunc (_e *MockClient_Expecter) GlobalRegistryDelete(registry interface{}) *MockClient_GlobalRegistryDelete_Call {\n\treturn &MockClient_GlobalRegistryDelete_Call{Call: _e.mock.On(\"GlobalRegistryDelete\", registry)}\n}\n\nfunc (_c *MockClient_GlobalRegistryDelete_Call) Run(run func(registry string)) *MockClient_GlobalRegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryDelete_Call) Return(err error) *MockClient_GlobalRegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryDelete_Call) RunAndReturn(run func(registry string) error) *MockClient_GlobalRegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryList provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalRegistryList(opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryList\")\n\t}\n\n\tvar r0 []*woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RegistryListOptions) []*woodpecker.Registry); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.RegistryListOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList'\ntype MockClient_GlobalRegistryList_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryList is a helper method to define mock.On call\n//   - opt woodpecker.RegistryListOptions\nfunc (_e *MockClient_Expecter) GlobalRegistryList(opt interface{}) *MockClient_GlobalRegistryList_Call {\n\treturn &MockClient_GlobalRegistryList_Call{Call: _e.mock.On(\"GlobalRegistryList\", opt)}\n}\n\nfunc (_c *MockClient_GlobalRegistryList_Call) Run(run func(opt woodpecker.RegistryListOptions)) *MockClient_GlobalRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.RegistryListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.RegistryListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryList_Call) Return(registrys []*woodpecker.Registry, err error) *MockClient_GlobalRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryList_Call) RunAndReturn(run func(opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)) *MockClient_GlobalRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalRegistryUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalRegistryUpdate(registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalRegistryUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryUpdate'\ntype MockClient_GlobalRegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// GlobalRegistryUpdate is a helper method to define mock.On call\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) GlobalRegistryUpdate(registry interface{}) *MockClient_GlobalRegistryUpdate_Call {\n\treturn &MockClient_GlobalRegistryUpdate_Call{Call: _e.mock.On(\"GlobalRegistryUpdate\", registry)}\n}\n\nfunc (_c *MockClient_GlobalRegistryUpdate_Call) Run(run func(registry *woodpecker.Registry)) *MockClient_GlobalRegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Registry\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryUpdate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_GlobalRegistryUpdate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalRegistryUpdate_Call) RunAndReturn(run func(registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_GlobalRegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecret provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalSecret(secret string) (*woodpecker.Secret, error) {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecret\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecret'\ntype MockClient_GlobalSecret_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecret is a helper method to define mock.On call\n//   - secret string\nfunc (_e *MockClient_Expecter) GlobalSecret(secret interface{}) *MockClient_GlobalSecret_Call {\n\treturn &MockClient_GlobalSecret_Call{Call: _e.mock.On(\"GlobalSecret\", secret)}\n}\n\nfunc (_c *MockClient_GlobalSecret_Call) Run(run func(secret string)) *MockClient_GlobalSecret_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecret_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_GlobalSecret_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecret_Call) RunAndReturn(run func(secret string) (*woodpecker.Secret, error)) *MockClient_GlobalSecret_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalSecretCreate(secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretCreate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretCreate'\ntype MockClient_GlobalSecretCreate_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretCreate is a helper method to define mock.On call\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) GlobalSecretCreate(secret interface{}) *MockClient_GlobalSecretCreate_Call {\n\treturn &MockClient_GlobalSecretCreate_Call{Call: _e.mock.On(\"GlobalSecretCreate\", secret)}\n}\n\nfunc (_c *MockClient_GlobalSecretCreate_Call) Run(run func(secret *woodpecker.Secret)) *MockClient_GlobalSecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretCreate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_GlobalSecretCreate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretCreate_Call) RunAndReturn(run func(secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_GlobalSecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalSecretDelete(secret string) error {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string) error); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_GlobalSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretDelete'\ntype MockClient_GlobalSecretDelete_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretDelete is a helper method to define mock.On call\n//   - secret string\nfunc (_e *MockClient_Expecter) GlobalSecretDelete(secret interface{}) *MockClient_GlobalSecretDelete_Call {\n\treturn &MockClient_GlobalSecretDelete_Call{Call: _e.mock.On(\"GlobalSecretDelete\", secret)}\n}\n\nfunc (_c *MockClient_GlobalSecretDelete_Call) Run(run func(secret string)) *MockClient_GlobalSecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretDelete_Call) Return(err error) *MockClient_GlobalSecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretDelete_Call) RunAndReturn(run func(secret string) error) *MockClient_GlobalSecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretList provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalSecretList(opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretList\")\n\t}\n\n\tvar r0 []*woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.SecretListOptions) []*woodpecker.Secret); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.SecretListOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretList'\ntype MockClient_GlobalSecretList_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretList is a helper method to define mock.On call\n//   - opt woodpecker.SecretListOptions\nfunc (_e *MockClient_Expecter) GlobalSecretList(opt interface{}) *MockClient_GlobalSecretList_Call {\n\treturn &MockClient_GlobalSecretList_Call{Call: _e.mock.On(\"GlobalSecretList\", opt)}\n}\n\nfunc (_c *MockClient_GlobalSecretList_Call) Run(run func(opt woodpecker.SecretListOptions)) *MockClient_GlobalSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.SecretListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.SecretListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretList_Call) Return(secrets []*woodpecker.Secret, err error) *MockClient_GlobalSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretList_Call) RunAndReturn(run func(opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)) *MockClient_GlobalSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// GlobalSecretUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) GlobalSecretUpdate(secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for GlobalSecretUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_GlobalSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretUpdate'\ntype MockClient_GlobalSecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// GlobalSecretUpdate is a helper method to define mock.On call\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) GlobalSecretUpdate(secret interface{}) *MockClient_GlobalSecretUpdate_Call {\n\treturn &MockClient_GlobalSecretUpdate_Call{Call: _e.mock.On(\"GlobalSecretUpdate\", secret)}\n}\n\nfunc (_c *MockClient_GlobalSecretUpdate_Call) Run(run func(secret *woodpecker.Secret)) *MockClient_GlobalSecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.Secret\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretUpdate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_GlobalSecretUpdate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_GlobalSecretUpdate_Call) RunAndReturn(run func(secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_GlobalSecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogLevel provides a mock function for the type MockClient\nfunc (_mock *MockClient) LogLevel() (*woodpecker.LogLevel, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogLevel\")\n\t}\n\n\tvar r0 *woodpecker.LogLevel\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (*woodpecker.LogLevel, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() *woodpecker.LogLevel); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.LogLevel)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_LogLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogLevel'\ntype MockClient_LogLevel_Call struct {\n\t*mock.Call\n}\n\n// LogLevel is a helper method to define mock.On call\nfunc (_e *MockClient_Expecter) LogLevel() *MockClient_LogLevel_Call {\n\treturn &MockClient_LogLevel_Call{Call: _e.mock.On(\"LogLevel\")}\n}\n\nfunc (_c *MockClient_LogLevel_Call) Run(run func()) *MockClient_LogLevel_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_LogLevel_Call) Return(logLevel *woodpecker.LogLevel, err error) *MockClient_LogLevel_Call {\n\t_c.Call.Return(logLevel, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_LogLevel_Call) RunAndReturn(run func() (*woodpecker.LogLevel, error)) *MockClient_LogLevel_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// LogsPurge provides a mock function for the type MockClient\nfunc (_mock *MockClient) LogsPurge(repoID int64, pipeline int64) error {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for LogsPurge\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) error); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_LogsPurge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogsPurge'\ntype MockClient_LogsPurge_Call struct {\n\t*mock.Call\n}\n\n// LogsPurge is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) LogsPurge(repoID interface{}, pipeline interface{}) *MockClient_LogsPurge_Call {\n\treturn &MockClient_LogsPurge_Call{Call: _e.mock.On(\"LogsPurge\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_LogsPurge_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_LogsPurge_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_LogsPurge_Call) Return(err error) *MockClient_LogsPurge_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_LogsPurge_Call) RunAndReturn(run func(repoID int64, pipeline int64) error) *MockClient_LogsPurge_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Org provides a mock function for the type MockClient\nfunc (_mock *MockClient) Org(orgID int64) (*woodpecker.Org, error) {\n\tret := _mock.Called(orgID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Org\")\n\t}\n\n\tvar r0 *woodpecker.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*woodpecker.Org, error)); ok {\n\t\treturn returnFunc(orgID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *woodpecker.Org); ok {\n\t\tr0 = returnFunc(orgID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(orgID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Org_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Org'\ntype MockClient_Org_Call struct {\n\t*mock.Call\n}\n\n// Org is a helper method to define mock.On call\n//   - orgID int64\nfunc (_e *MockClient_Expecter) Org(orgID interface{}) *MockClient_Org_Call {\n\treturn &MockClient_Org_Call{Call: _e.mock.On(\"Org\", orgID)}\n}\n\nfunc (_c *MockClient_Org_Call) Run(run func(orgID int64)) *MockClient_Org_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Org_Call) Return(org *woodpecker.Org, err error) *MockClient_Org_Call {\n\t_c.Call.Return(org, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Org_Call) RunAndReturn(run func(orgID int64) (*woodpecker.Org, error)) *MockClient_Org_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgList provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgList(opt woodpecker.ListOptions) ([]*woodpecker.Org, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgList\")\n\t}\n\n\tvar r0 []*woodpecker.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.ListOptions) ([]*woodpecker.Org, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.ListOptions) []*woodpecker.Org); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.ListOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgList'\ntype MockClient_OrgList_Call struct {\n\t*mock.Call\n}\n\n// OrgList is a helper method to define mock.On call\n//   - opt woodpecker.ListOptions\nfunc (_e *MockClient_Expecter) OrgList(opt interface{}) *MockClient_OrgList_Call {\n\treturn &MockClient_OrgList_Call{Call: _e.mock.On(\"OrgList\", opt)}\n}\n\nfunc (_c *MockClient_OrgList_Call) Run(run func(opt woodpecker.ListOptions)) *MockClient_OrgList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.ListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.ListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgList_Call) Return(orgs []*woodpecker.Org, err error) *MockClient_OrgList_Call {\n\t_c.Call.Return(orgs, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgList_Call) RunAndReturn(run func(opt woodpecker.ListOptions) ([]*woodpecker.Org, error)) *MockClient_OrgList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgLookup provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgLookup(orgName string) (*woodpecker.Org, error) {\n\tret := _mock.Called(orgName)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgLookup\")\n\t}\n\n\tvar r0 *woodpecker.Org\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*woodpecker.Org, error)); ok {\n\t\treturn returnFunc(orgName)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *woodpecker.Org); ok {\n\t\tr0 = returnFunc(orgName)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Org)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(orgName)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgLookup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgLookup'\ntype MockClient_OrgLookup_Call struct {\n\t*mock.Call\n}\n\n// OrgLookup is a helper method to define mock.On call\n//   - orgName string\nfunc (_e *MockClient_Expecter) OrgLookup(orgName interface{}) *MockClient_OrgLookup_Call {\n\treturn &MockClient_OrgLookup_Call{Call: _e.mock.On(\"OrgLookup\", orgName)}\n}\n\nfunc (_c *MockClient_OrgLookup_Call) Run(run func(orgName string)) *MockClient_OrgLookup_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgLookup_Call) Return(org *woodpecker.Org, err error) *MockClient_OrgLookup_Call {\n\t_c.Call.Return(org, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgLookup_Call) RunAndReturn(run func(orgName string) (*woodpecker.Org, error)) *MockClient_OrgLookup_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistry provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgRegistry(orgID int64, registry string) (*woodpecker.Registry, error) {\n\tret := _mock.Called(orgID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistry\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(orgID, registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(orgID, registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(orgID, registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistry'\ntype MockClient_OrgRegistry_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistry is a helper method to define mock.On call\n//   - orgID int64\n//   - registry string\nfunc (_e *MockClient_Expecter) OrgRegistry(orgID interface{}, registry interface{}) *MockClient_OrgRegistry_Call {\n\treturn &MockClient_OrgRegistry_Call{Call: _e.mock.On(\"OrgRegistry\", orgID, registry)}\n}\n\nfunc (_c *MockClient_OrgRegistry_Call) Run(run func(orgID int64, registry string)) *MockClient_OrgRegistry_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistry_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_OrgRegistry_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistry_Call) RunAndReturn(run func(orgID int64, registry string) (*woodpecker.Registry, error)) *MockClient_OrgRegistry_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgRegistryCreate(orgID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(orgID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryCreate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(orgID, registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(orgID, registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(orgID, registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryCreate'\ntype MockClient_OrgRegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryCreate is a helper method to define mock.On call\n//   - orgID int64\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) OrgRegistryCreate(orgID interface{}, registry interface{}) *MockClient_OrgRegistryCreate_Call {\n\treturn &MockClient_OrgRegistryCreate_Call{Call: _e.mock.On(\"OrgRegistryCreate\", orgID, registry)}\n}\n\nfunc (_c *MockClient_OrgRegistryCreate_Call) Run(run func(orgID int64, registry *woodpecker.Registry)) *MockClient_OrgRegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryCreate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_OrgRegistryCreate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryCreate_Call) RunAndReturn(run func(orgID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_OrgRegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgRegistryDelete(orgID int64, registry string) error {\n\tret := _mock.Called(orgID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(orgID, registry)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_OrgRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryDelete'\ntype MockClient_OrgRegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryDelete is a helper method to define mock.On call\n//   - orgID int64\n//   - registry string\nfunc (_e *MockClient_Expecter) OrgRegistryDelete(orgID interface{}, registry interface{}) *MockClient_OrgRegistryDelete_Call {\n\treturn &MockClient_OrgRegistryDelete_Call{Call: _e.mock.On(\"OrgRegistryDelete\", orgID, registry)}\n}\n\nfunc (_c *MockClient_OrgRegistryDelete_Call) Run(run func(orgID int64, registry string)) *MockClient_OrgRegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryDelete_Call) Return(err error) *MockClient_OrgRegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryDelete_Call) RunAndReturn(run func(orgID int64, registry string) error) *MockClient_OrgRegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryList provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgRegistryList(orgID int64, opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error) {\n\tret := _mock.Called(orgID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryList\")\n\t}\n\n\tvar r0 []*woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(orgID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.RegistryListOptions) []*woodpecker.Registry); ok {\n\t\tr0 = returnFunc(orgID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.RegistryListOptions) error); ok {\n\t\tr1 = returnFunc(orgID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryList'\ntype MockClient_OrgRegistryList_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryList is a helper method to define mock.On call\n//   - orgID int64\n//   - opt woodpecker.RegistryListOptions\nfunc (_e *MockClient_Expecter) OrgRegistryList(orgID interface{}, opt interface{}) *MockClient_OrgRegistryList_Call {\n\treturn &MockClient_OrgRegistryList_Call{Call: _e.mock.On(\"OrgRegistryList\", orgID, opt)}\n}\n\nfunc (_c *MockClient_OrgRegistryList_Call) Run(run func(orgID int64, opt woodpecker.RegistryListOptions)) *MockClient_OrgRegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.RegistryListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.RegistryListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryList_Call) Return(registrys []*woodpecker.Registry, err error) *MockClient_OrgRegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryList_Call) RunAndReturn(run func(orgID int64, opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)) *MockClient_OrgRegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgRegistryUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgRegistryUpdate(orgID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(orgID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgRegistryUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(orgID, registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(orgID, registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(orgID, registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryUpdate'\ntype MockClient_OrgRegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// OrgRegistryUpdate is a helper method to define mock.On call\n//   - orgID int64\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) OrgRegistryUpdate(orgID interface{}, registry interface{}) *MockClient_OrgRegistryUpdate_Call {\n\treturn &MockClient_OrgRegistryUpdate_Call{Call: _e.mock.On(\"OrgRegistryUpdate\", orgID, registry)}\n}\n\nfunc (_c *MockClient_OrgRegistryUpdate_Call) Run(run func(orgID int64, registry *woodpecker.Registry)) *MockClient_OrgRegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryUpdate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_OrgRegistryUpdate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgRegistryUpdate_Call) RunAndReturn(run func(orgID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_OrgRegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecret provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgSecret(orgID int64, secret string) (*woodpecker.Secret, error) {\n\tret := _mock.Called(orgID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecret\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(orgID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(orgID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(orgID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecret'\ntype MockClient_OrgSecret_Call struct {\n\t*mock.Call\n}\n\n// OrgSecret is a helper method to define mock.On call\n//   - orgID int64\n//   - secret string\nfunc (_e *MockClient_Expecter) OrgSecret(orgID interface{}, secret interface{}) *MockClient_OrgSecret_Call {\n\treturn &MockClient_OrgSecret_Call{Call: _e.mock.On(\"OrgSecret\", orgID, secret)}\n}\n\nfunc (_c *MockClient_OrgSecret_Call) Run(run func(orgID int64, secret string)) *MockClient_OrgSecret_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecret_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_OrgSecret_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecret_Call) RunAndReturn(run func(orgID int64, secret string) (*woodpecker.Secret, error)) *MockClient_OrgSecret_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgSecretCreate(orgID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(orgID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretCreate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(orgID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(orgID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(orgID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretCreate'\ntype MockClient_OrgSecretCreate_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretCreate is a helper method to define mock.On call\n//   - orgID int64\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) OrgSecretCreate(orgID interface{}, secret interface{}) *MockClient_OrgSecretCreate_Call {\n\treturn &MockClient_OrgSecretCreate_Call{Call: _e.mock.On(\"OrgSecretCreate\", orgID, secret)}\n}\n\nfunc (_c *MockClient_OrgSecretCreate_Call) Run(run func(orgID int64, secret *woodpecker.Secret)) *MockClient_OrgSecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretCreate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_OrgSecretCreate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretCreate_Call) RunAndReturn(run func(orgID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_OrgSecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgSecretDelete(orgID int64, secret string) error {\n\tret := _mock.Called(orgID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(orgID, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_OrgSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretDelete'\ntype MockClient_OrgSecretDelete_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretDelete is a helper method to define mock.On call\n//   - orgID int64\n//   - secret string\nfunc (_e *MockClient_Expecter) OrgSecretDelete(orgID interface{}, secret interface{}) *MockClient_OrgSecretDelete_Call {\n\treturn &MockClient_OrgSecretDelete_Call{Call: _e.mock.On(\"OrgSecretDelete\", orgID, secret)}\n}\n\nfunc (_c *MockClient_OrgSecretDelete_Call) Run(run func(orgID int64, secret string)) *MockClient_OrgSecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretDelete_Call) Return(err error) *MockClient_OrgSecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretDelete_Call) RunAndReturn(run func(orgID int64, secret string) error) *MockClient_OrgSecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretList provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgSecretList(orgID int64, opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error) {\n\tret := _mock.Called(orgID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretList\")\n\t}\n\n\tvar r0 []*woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(orgID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.SecretListOptions) []*woodpecker.Secret); ok {\n\t\tr0 = returnFunc(orgID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.SecretListOptions) error); ok {\n\t\tr1 = returnFunc(orgID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretList'\ntype MockClient_OrgSecretList_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretList is a helper method to define mock.On call\n//   - orgID int64\n//   - opt woodpecker.SecretListOptions\nfunc (_e *MockClient_Expecter) OrgSecretList(orgID interface{}, opt interface{}) *MockClient_OrgSecretList_Call {\n\treturn &MockClient_OrgSecretList_Call{Call: _e.mock.On(\"OrgSecretList\", orgID, opt)}\n}\n\nfunc (_c *MockClient_OrgSecretList_Call) Run(run func(orgID int64, opt woodpecker.SecretListOptions)) *MockClient_OrgSecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.SecretListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.SecretListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretList_Call) Return(secrets []*woodpecker.Secret, err error) *MockClient_OrgSecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretList_Call) RunAndReturn(run func(orgID int64, opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)) *MockClient_OrgSecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// OrgSecretUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) OrgSecretUpdate(orgID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(orgID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for OrgSecretUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(orgID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(orgID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(orgID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_OrgSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretUpdate'\ntype MockClient_OrgSecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// OrgSecretUpdate is a helper method to define mock.On call\n//   - orgID int64\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) OrgSecretUpdate(orgID interface{}, secret interface{}) *MockClient_OrgSecretUpdate_Call {\n\treturn &MockClient_OrgSecretUpdate_Call{Call: _e.mock.On(\"OrgSecretUpdate\", orgID, secret)}\n}\n\nfunc (_c *MockClient_OrgSecretUpdate_Call) Run(run func(orgID int64, secret *woodpecker.Secret)) *MockClient_OrgSecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretUpdate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_OrgSecretUpdate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_OrgSecretUpdate_Call) RunAndReturn(run func(orgID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_OrgSecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Pipeline provides a mock function for the type MockClient\nfunc (_mock *MockClient) Pipeline(repoID int64, pipeline int64) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Pipeline\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, pipeline)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Pipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pipeline'\ntype MockClient_Pipeline_Call struct {\n\t*mock.Call\n}\n\n// Pipeline is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) Pipeline(repoID interface{}, pipeline interface{}) *MockClient_Pipeline_Call {\n\treturn &MockClient_Pipeline_Call{Call: _e.mock.On(\"Pipeline\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_Pipeline_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_Pipeline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Pipeline_Call) Return(pipeline1 *woodpecker.Pipeline, err error) *MockClient_Pipeline_Call {\n\t_c.Call.Return(pipeline1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Pipeline_Call) RunAndReturn(run func(repoID int64, pipeline int64) (*woodpecker.Pipeline, error)) *MockClient_Pipeline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineApprove provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineApprove(repoID int64, pipeline int64) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineApprove\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, pipeline)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineApprove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineApprove'\ntype MockClient_PipelineApprove_Call struct {\n\t*mock.Call\n}\n\n// PipelineApprove is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) PipelineApprove(repoID interface{}, pipeline interface{}) *MockClient_PipelineApprove_Call {\n\treturn &MockClient_PipelineApprove_Call{Call: _e.mock.On(\"PipelineApprove\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_PipelineApprove_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_PipelineApprove_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineApprove_Call) Return(pipeline1 *woodpecker.Pipeline, err error) *MockClient_PipelineApprove_Call {\n\t_c.Call.Return(pipeline1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineApprove_Call) RunAndReturn(run func(repoID int64, pipeline int64) (*woodpecker.Pipeline, error)) *MockClient_PipelineApprove_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineCreate(repoID int64, opts *woodpecker.PipelineOptions) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, opts)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineCreate\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.PipelineOptions) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, opts)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.PipelineOptions) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, opts)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.PipelineOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opts)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineCreate'\ntype MockClient_PipelineCreate_Call struct {\n\t*mock.Call\n}\n\n// PipelineCreate is a helper method to define mock.On call\n//   - repoID int64\n//   - opts *woodpecker.PipelineOptions\nfunc (_e *MockClient_Expecter) PipelineCreate(repoID interface{}, opts interface{}) *MockClient_PipelineCreate_Call {\n\treturn &MockClient_PipelineCreate_Call{Call: _e.mock.On(\"PipelineCreate\", repoID, opts)}\n}\n\nfunc (_c *MockClient_PipelineCreate_Call) Run(run func(repoID int64, opts *woodpecker.PipelineOptions)) *MockClient_PipelineCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.PipelineOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.PipelineOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineCreate_Call) Return(pipeline *woodpecker.Pipeline, err error) *MockClient_PipelineCreate_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineCreate_Call) RunAndReturn(run func(repoID int64, opts *woodpecker.PipelineOptions) (*woodpecker.Pipeline, error)) *MockClient_PipelineCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineDecline provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineDecline(repoID int64, pipeline int64) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineDecline\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, pipeline)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok {\n\t\tr1 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineDecline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineDecline'\ntype MockClient_PipelineDecline_Call struct {\n\t*mock.Call\n}\n\n// PipelineDecline is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) PipelineDecline(repoID interface{}, pipeline interface{}) *MockClient_PipelineDecline_Call {\n\treturn &MockClient_PipelineDecline_Call{Call: _e.mock.On(\"PipelineDecline\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_PipelineDecline_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_PipelineDecline_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineDecline_Call) Return(pipeline1 *woodpecker.Pipeline, err error) *MockClient_PipelineDecline_Call {\n\t_c.Call.Return(pipeline1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineDecline_Call) RunAndReturn(run func(repoID int64, pipeline int64) (*woodpecker.Pipeline, error)) *MockClient_PipelineDecline_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineDelete(repoID int64, pipeline int64) error {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) error); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_PipelineDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineDelete'\ntype MockClient_PipelineDelete_Call struct {\n\t*mock.Call\n}\n\n// PipelineDelete is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) PipelineDelete(repoID interface{}, pipeline interface{}) *MockClient_PipelineDelete_Call {\n\treturn &MockClient_PipelineDelete_Call{Call: _e.mock.On(\"PipelineDelete\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_PipelineDelete_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_PipelineDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineDelete_Call) Return(err error) *MockClient_PipelineDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineDelete_Call) RunAndReturn(run func(repoID int64, pipeline int64) error) *MockClient_PipelineDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineLast provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineLast(repoID int64, opt woodpecker.PipelineLastOptions) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineLast\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.PipelineLastOptions) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.PipelineLastOptions) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.PipelineLastOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineLast_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineLast'\ntype MockClient_PipelineLast_Call struct {\n\t*mock.Call\n}\n\n// PipelineLast is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.PipelineLastOptions\nfunc (_e *MockClient_Expecter) PipelineLast(repoID interface{}, opt interface{}) *MockClient_PipelineLast_Call {\n\treturn &MockClient_PipelineLast_Call{Call: _e.mock.On(\"PipelineLast\", repoID, opt)}\n}\n\nfunc (_c *MockClient_PipelineLast_Call) Run(run func(repoID int64, opt woodpecker.PipelineLastOptions)) *MockClient_PipelineLast_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.PipelineLastOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.PipelineLastOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineLast_Call) Return(pipeline *woodpecker.Pipeline, err error) *MockClient_PipelineLast_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineLast_Call) RunAndReturn(run func(repoID int64, opt woodpecker.PipelineLastOptions) (*woodpecker.Pipeline, error)) *MockClient_PipelineLast_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineList provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineList(repoID int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineList\")\n\t}\n\n\tvar r0 []*woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.PipelineListOptions) []*woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.PipelineListOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineList'\ntype MockClient_PipelineList_Call struct {\n\t*mock.Call\n}\n\n// PipelineList is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.PipelineListOptions\nfunc (_e *MockClient_Expecter) PipelineList(repoID interface{}, opt interface{}) *MockClient_PipelineList_Call {\n\treturn &MockClient_PipelineList_Call{Call: _e.mock.On(\"PipelineList\", repoID, opt)}\n}\n\nfunc (_c *MockClient_PipelineList_Call) Run(run func(repoID int64, opt woodpecker.PipelineListOptions)) *MockClient_PipelineList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.PipelineListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.PipelineListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineList_Call) Return(pipelines []*woodpecker.Pipeline, err error) *MockClient_PipelineList_Call {\n\t_c.Call.Return(pipelines, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineList_Call) RunAndReturn(run func(repoID int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error)) *MockClient_PipelineList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineMetadata provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) {\n\tret := _mock.Called(repoID, pipelineNumber)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineMetadata\")\n\t}\n\n\tvar r0 []byte\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int) ([]byte, error)); ok {\n\t\treturn returnFunc(repoID, pipelineNumber)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int) []byte); ok {\n\t\tr0 = returnFunc(repoID, pipelineNumber)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]byte)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int) error); ok {\n\t\tr1 = returnFunc(repoID, pipelineNumber)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineMetadata'\ntype MockClient_PipelineMetadata_Call struct {\n\t*mock.Call\n}\n\n// PipelineMetadata is a helper method to define mock.On call\n//   - repoID int64\n//   - pipelineNumber int\nfunc (_e *MockClient_Expecter) PipelineMetadata(repoID interface{}, pipelineNumber interface{}) *MockClient_PipelineMetadata_Call {\n\treturn &MockClient_PipelineMetadata_Call{Call: _e.mock.On(\"PipelineMetadata\", repoID, pipelineNumber)}\n}\n\nfunc (_c *MockClient_PipelineMetadata_Call) Run(run func(repoID int64, pipelineNumber int)) *MockClient_PipelineMetadata_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineMetadata_Call) Return(bytes []byte, err error) *MockClient_PipelineMetadata_Call {\n\t_c.Call.Return(bytes, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineMetadata_Call) RunAndReturn(run func(repoID int64, pipelineNumber int) ([]byte, error)) *MockClient_PipelineMetadata_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineQueue provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineQueue() ([]*woodpecker.Feed, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineQueue\")\n\t}\n\n\tvar r0 []*woodpecker.Feed\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() ([]*woodpecker.Feed, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() []*woodpecker.Feed); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Feed)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineQueue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineQueue'\ntype MockClient_PipelineQueue_Call struct {\n\t*mock.Call\n}\n\n// PipelineQueue is a helper method to define mock.On call\nfunc (_e *MockClient_Expecter) PipelineQueue() *MockClient_PipelineQueue_Call {\n\treturn &MockClient_PipelineQueue_Call{Call: _e.mock.On(\"PipelineQueue\")}\n}\n\nfunc (_c *MockClient_PipelineQueue_Call) Run(run func()) *MockClient_PipelineQueue_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineQueue_Call) Return(feeds []*woodpecker.Feed, err error) *MockClient_PipelineQueue_Call {\n\t_c.Call.Return(feeds, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineQueue_Call) RunAndReturn(run func() ([]*woodpecker.Feed, error)) *MockClient_PipelineQueue_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineStart provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineStart(repoID int64, num int64, opt woodpecker.PipelineStartOptions) (*woodpecker.Pipeline, error) {\n\tret := _mock.Called(repoID, num, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineStart\")\n\t}\n\n\tvar r0 *woodpecker.Pipeline\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, woodpecker.PipelineStartOptions) (*woodpecker.Pipeline, error)); ok {\n\t\treturn returnFunc(repoID, num, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, woodpecker.PipelineStartOptions) *woodpecker.Pipeline); ok {\n\t\tr0 = returnFunc(repoID, num, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Pipeline)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64, woodpecker.PipelineStartOptions) error); ok {\n\t\tr1 = returnFunc(repoID, num, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_PipelineStart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineStart'\ntype MockClient_PipelineStart_Call struct {\n\t*mock.Call\n}\n\n// PipelineStart is a helper method to define mock.On call\n//   - repoID int64\n//   - num int64\n//   - opt woodpecker.PipelineStartOptions\nfunc (_e *MockClient_Expecter) PipelineStart(repoID interface{}, num interface{}, opt interface{}) *MockClient_PipelineStart_Call {\n\treturn &MockClient_PipelineStart_Call{Call: _e.mock.On(\"PipelineStart\", repoID, num, opt)}\n}\n\nfunc (_c *MockClient_PipelineStart_Call) Run(run func(repoID int64, num int64, opt woodpecker.PipelineStartOptions)) *MockClient_PipelineStart_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 woodpecker.PipelineStartOptions\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(woodpecker.PipelineStartOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineStart_Call) Return(pipeline *woodpecker.Pipeline, err error) *MockClient_PipelineStart_Call {\n\t_c.Call.Return(pipeline, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineStart_Call) RunAndReturn(run func(repoID int64, num int64, opt woodpecker.PipelineStartOptions) (*woodpecker.Pipeline, error)) *MockClient_PipelineStart_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// PipelineStop provides a mock function for the type MockClient\nfunc (_mock *MockClient) PipelineStop(repoID int64, pipeline int64) error {\n\tret := _mock.Called(repoID, pipeline)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for PipelineStop\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64) error); ok {\n\t\tr0 = returnFunc(repoID, pipeline)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_PipelineStop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineStop'\ntype MockClient_PipelineStop_Call struct {\n\t*mock.Call\n}\n\n// PipelineStop is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\nfunc (_e *MockClient_Expecter) PipelineStop(repoID interface{}, pipeline interface{}) *MockClient_PipelineStop_Call {\n\treturn &MockClient_PipelineStop_Call{Call: _e.mock.On(\"PipelineStop\", repoID, pipeline)}\n}\n\nfunc (_c *MockClient_PipelineStop_Call) Run(run func(repoID int64, pipeline int64)) *MockClient_PipelineStop_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineStop_Call) Return(err error) *MockClient_PipelineStop_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_PipelineStop_Call) RunAndReturn(run func(repoID int64, pipeline int64) error) *MockClient_PipelineStop_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// QueueInfo provides a mock function for the type MockClient\nfunc (_mock *MockClient) QueueInfo() (*woodpecker.Info, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for QueueInfo\")\n\t}\n\n\tvar r0 *woodpecker.Info\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (*woodpecker.Info, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() *woodpecker.Info); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Info)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_QueueInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueueInfo'\ntype MockClient_QueueInfo_Call struct {\n\t*mock.Call\n}\n\n// QueueInfo is a helper method to define mock.On call\nfunc (_e *MockClient_Expecter) QueueInfo() *MockClient_QueueInfo_Call {\n\treturn &MockClient_QueueInfo_Call{Call: _e.mock.On(\"QueueInfo\")}\n}\n\nfunc (_c *MockClient_QueueInfo_Call) Run(run func()) *MockClient_QueueInfo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_QueueInfo_Call) Return(info *woodpecker.Info, err error) *MockClient_QueueInfo_Call {\n\t_c.Call.Return(info, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_QueueInfo_Call) RunAndReturn(run func() (*woodpecker.Info, error)) *MockClient_QueueInfo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Registry provides a mock function for the type MockClient\nfunc (_mock *MockClient) Registry(repoID int64, hostname string) (*woodpecker.Registry, error) {\n\tret := _mock.Called(repoID, hostname)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Registry\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(repoID, hostname)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(repoID, hostname)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(repoID, hostname)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Registry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Registry'\ntype MockClient_Registry_Call struct {\n\t*mock.Call\n}\n\n// Registry is a helper method to define mock.On call\n//   - repoID int64\n//   - hostname string\nfunc (_e *MockClient_Expecter) Registry(repoID interface{}, hostname interface{}) *MockClient_Registry_Call {\n\treturn &MockClient_Registry_Call{Call: _e.mock.On(\"Registry\", repoID, hostname)}\n}\n\nfunc (_c *MockClient_Registry_Call) Run(run func(repoID int64, hostname string)) *MockClient_Registry_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Registry_Call) Return(registry *woodpecker.Registry, err error) *MockClient_Registry_Call {\n\t_c.Call.Return(registry, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Registry_Call) RunAndReturn(run func(repoID int64, hostname string) (*woodpecker.Registry, error)) *MockClient_Registry_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) RegistryCreate(repoID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(repoID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryCreate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(repoID, registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(repoID, registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(repoID, registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryCreate'\ntype MockClient_RegistryCreate_Call struct {\n\t*mock.Call\n}\n\n// RegistryCreate is a helper method to define mock.On call\n//   - repoID int64\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) RegistryCreate(repoID interface{}, registry interface{}) *MockClient_RegistryCreate_Call {\n\treturn &MockClient_RegistryCreate_Call{Call: _e.mock.On(\"RegistryCreate\", repoID, registry)}\n}\n\nfunc (_c *MockClient_RegistryCreate_Call) Run(run func(repoID int64, registry *woodpecker.Registry)) *MockClient_RegistryCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryCreate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_RegistryCreate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryCreate_Call) RunAndReturn(run func(repoID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_RegistryCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) RegistryDelete(repoID int64, hostname string) error {\n\tret := _mock.Called(repoID, hostname)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(repoID, hostname)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_RegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryDelete'\ntype MockClient_RegistryDelete_Call struct {\n\t*mock.Call\n}\n\n// RegistryDelete is a helper method to define mock.On call\n//   - repoID int64\n//   - hostname string\nfunc (_e *MockClient_Expecter) RegistryDelete(repoID interface{}, hostname interface{}) *MockClient_RegistryDelete_Call {\n\treturn &MockClient_RegistryDelete_Call{Call: _e.mock.On(\"RegistryDelete\", repoID, hostname)}\n}\n\nfunc (_c *MockClient_RegistryDelete_Call) Run(run func(repoID int64, hostname string)) *MockClient_RegistryDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryDelete_Call) Return(err error) *MockClient_RegistryDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryDelete_Call) RunAndReturn(run func(repoID int64, hostname string) error) *MockClient_RegistryDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryList provides a mock function for the type MockClient\nfunc (_mock *MockClient) RegistryList(repoID int64, opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error) {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryList\")\n\t}\n\n\tvar r0 []*woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(repoID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.RegistryListOptions) []*woodpecker.Registry); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.RegistryListOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryList'\ntype MockClient_RegistryList_Call struct {\n\t*mock.Call\n}\n\n// RegistryList is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.RegistryListOptions\nfunc (_e *MockClient_Expecter) RegistryList(repoID interface{}, opt interface{}) *MockClient_RegistryList_Call {\n\treturn &MockClient_RegistryList_Call{Call: _e.mock.On(\"RegistryList\", repoID, opt)}\n}\n\nfunc (_c *MockClient_RegistryList_Call) Run(run func(repoID int64, opt woodpecker.RegistryListOptions)) *MockClient_RegistryList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.RegistryListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.RegistryListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryList_Call) Return(registrys []*woodpecker.Registry, err error) *MockClient_RegistryList_Call {\n\t_c.Call.Return(registrys, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryList_Call) RunAndReturn(run func(repoID int64, opt woodpecker.RegistryListOptions) ([]*woodpecker.Registry, error)) *MockClient_RegistryList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RegistryUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) RegistryUpdate(repoID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error) {\n\tret := _mock.Called(repoID, registry)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RegistryUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Registry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) (*woodpecker.Registry, error)); ok {\n\t\treturn returnFunc(repoID, registry)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Registry) *woodpecker.Registry); ok {\n\t\tr0 = returnFunc(repoID, registry)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Registry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Registry) error); ok {\n\t\tr1 = returnFunc(repoID, registry)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryUpdate'\ntype MockClient_RegistryUpdate_Call struct {\n\t*mock.Call\n}\n\n// RegistryUpdate is a helper method to define mock.On call\n//   - repoID int64\n//   - registry *woodpecker.Registry\nfunc (_e *MockClient_Expecter) RegistryUpdate(repoID interface{}, registry interface{}) *MockClient_RegistryUpdate_Call {\n\treturn &MockClient_RegistryUpdate_Call{Call: _e.mock.On(\"RegistryUpdate\", repoID, registry)}\n}\n\nfunc (_c *MockClient_RegistryUpdate_Call) Run(run func(repoID int64, registry *woodpecker.Registry)) *MockClient_RegistryUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Registry\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Registry)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryUpdate_Call) Return(registry1 *woodpecker.Registry, err error) *MockClient_RegistryUpdate_Call {\n\t_c.Call.Return(registry1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RegistryUpdate_Call) RunAndReturn(run func(repoID int64, registry *woodpecker.Registry) (*woodpecker.Registry, error)) *MockClient_RegistryUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Repo provides a mock function for the type MockClient\nfunc (_mock *MockClient) Repo(repoID int64) (*woodpecker.Repo, error) {\n\tret := _mock.Called(repoID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Repo\")\n\t}\n\n\tvar r0 *woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(repoID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *woodpecker.Repo); ok {\n\t\tr0 = returnFunc(repoID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(repoID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Repo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repo'\ntype MockClient_Repo_Call struct {\n\t*mock.Call\n}\n\n// Repo is a helper method to define mock.On call\n//   - repoID int64\nfunc (_e *MockClient_Expecter) Repo(repoID interface{}) *MockClient_Repo_Call {\n\treturn &MockClient_Repo_Call{Call: _e.mock.On(\"Repo\", repoID)}\n}\n\nfunc (_c *MockClient_Repo_Call) Run(run func(repoID int64)) *MockClient_Repo_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Repo_Call) Return(repo *woodpecker.Repo, err error) *MockClient_Repo_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Repo_Call) RunAndReturn(run func(repoID int64) (*woodpecker.Repo, error)) *MockClient_Repo_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoChown provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoChown(repoID int64) (*woodpecker.Repo, error) {\n\tret := _mock.Called(repoID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoChown\")\n\t}\n\n\tvar r0 *woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) (*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(repoID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64) *woodpecker.Repo); ok {\n\t\tr0 = returnFunc(repoID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64) error); ok {\n\t\tr1 = returnFunc(repoID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RepoChown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoChown'\ntype MockClient_RepoChown_Call struct {\n\t*mock.Call\n}\n\n// RepoChown is a helper method to define mock.On call\n//   - repoID int64\nfunc (_e *MockClient_Expecter) RepoChown(repoID interface{}) *MockClient_RepoChown_Call {\n\treturn &MockClient_RepoChown_Call{Call: _e.mock.On(\"RepoChown\", repoID)}\n}\n\nfunc (_c *MockClient_RepoChown_Call) Run(run func(repoID int64)) *MockClient_RepoChown_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoChown_Call) Return(repo *woodpecker.Repo, err error) *MockClient_RepoChown_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoChown_Call) RunAndReturn(run func(repoID int64) (*woodpecker.Repo, error)) *MockClient_RepoChown_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoDel provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoDel(repoID int64) error {\n\tret := _mock.Called(repoID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoDel\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) error); ok {\n\t\tr0 = returnFunc(repoID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_RepoDel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoDel'\ntype MockClient_RepoDel_Call struct {\n\t*mock.Call\n}\n\n// RepoDel is a helper method to define mock.On call\n//   - repoID int64\nfunc (_e *MockClient_Expecter) RepoDel(repoID interface{}) *MockClient_RepoDel_Call {\n\treturn &MockClient_RepoDel_Call{Call: _e.mock.On(\"RepoDel\", repoID)}\n}\n\nfunc (_c *MockClient_RepoDel_Call) Run(run func(repoID int64)) *MockClient_RepoDel_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoDel_Call) Return(err error) *MockClient_RepoDel_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoDel_Call) RunAndReturn(run func(repoID int64) error) *MockClient_RepoDel_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoList provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoList(opt woodpecker.RepoListOptions) ([]*woodpecker.Repo, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoList\")\n\t}\n\n\tvar r0 []*woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RepoListOptions) ([]*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RepoListOptions) []*woodpecker.Repo); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.RepoListOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RepoList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoList'\ntype MockClient_RepoList_Call struct {\n\t*mock.Call\n}\n\n// RepoList is a helper method to define mock.On call\n//   - opt woodpecker.RepoListOptions\nfunc (_e *MockClient_Expecter) RepoList(opt interface{}) *MockClient_RepoList_Call {\n\treturn &MockClient_RepoList_Call{Call: _e.mock.On(\"RepoList\", opt)}\n}\n\nfunc (_c *MockClient_RepoList_Call) Run(run func(opt woodpecker.RepoListOptions)) *MockClient_RepoList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.RepoListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.RepoListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoList_Call) Return(repos []*woodpecker.Repo, err error) *MockClient_RepoList_Call {\n\t_c.Call.Return(repos, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoList_Call) RunAndReturn(run func(opt woodpecker.RepoListOptions) ([]*woodpecker.Repo, error)) *MockClient_RepoList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoLookup provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoLookup(repoFullName string) (*woodpecker.Repo, error) {\n\tret := _mock.Called(repoFullName)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoLookup\")\n\t}\n\n\tvar r0 *woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string) (*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(repoFullName)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string) *woodpecker.Repo); ok {\n\t\tr0 = returnFunc(repoFullName)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string) error); ok {\n\t\tr1 = returnFunc(repoFullName)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RepoLookup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoLookup'\ntype MockClient_RepoLookup_Call struct {\n\t*mock.Call\n}\n\n// RepoLookup is a helper method to define mock.On call\n//   - repoFullName string\nfunc (_e *MockClient_Expecter) RepoLookup(repoFullName interface{}) *MockClient_RepoLookup_Call {\n\treturn &MockClient_RepoLookup_Call{Call: _e.mock.On(\"RepoLookup\", repoFullName)}\n}\n\nfunc (_c *MockClient_RepoLookup_Call) Run(run func(repoFullName string)) *MockClient_RepoLookup_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoLookup_Call) Return(repo *woodpecker.Repo, err error) *MockClient_RepoLookup_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoLookup_Call) RunAndReturn(run func(repoFullName string) (*woodpecker.Repo, error)) *MockClient_RepoLookup_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoMove provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoMove(repoID int64, opt woodpecker.RepoMoveOptions) error {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoMove\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.RepoMoveOptions) error); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_RepoMove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoMove'\ntype MockClient_RepoMove_Call struct {\n\t*mock.Call\n}\n\n// RepoMove is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.RepoMoveOptions\nfunc (_e *MockClient_Expecter) RepoMove(repoID interface{}, opt interface{}) *MockClient_RepoMove_Call {\n\treturn &MockClient_RepoMove_Call{Call: _e.mock.On(\"RepoMove\", repoID, opt)}\n}\n\nfunc (_c *MockClient_RepoMove_Call) Run(run func(repoID int64, opt woodpecker.RepoMoveOptions)) *MockClient_RepoMove_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.RepoMoveOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.RepoMoveOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoMove_Call) Return(err error) *MockClient_RepoMove_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoMove_Call) RunAndReturn(run func(repoID int64, opt woodpecker.RepoMoveOptions) error) *MockClient_RepoMove_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoPatch provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoPatch(repoID int64, repo *woodpecker.RepoPatch) (*woodpecker.Repo, error) {\n\tret := _mock.Called(repoID, repo)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoPatch\")\n\t}\n\n\tvar r0 *woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.RepoPatch) (*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(repoID, repo)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.RepoPatch) *woodpecker.Repo); ok {\n\t\tr0 = returnFunc(repoID, repo)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.RepoPatch) error); ok {\n\t\tr1 = returnFunc(repoID, repo)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RepoPatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoPatch'\ntype MockClient_RepoPatch_Call struct {\n\t*mock.Call\n}\n\n// RepoPatch is a helper method to define mock.On call\n//   - repoID int64\n//   - repo *woodpecker.RepoPatch\nfunc (_e *MockClient_Expecter) RepoPatch(repoID interface{}, repo interface{}) *MockClient_RepoPatch_Call {\n\treturn &MockClient_RepoPatch_Call{Call: _e.mock.On(\"RepoPatch\", repoID, repo)}\n}\n\nfunc (_c *MockClient_RepoPatch_Call) Run(run func(repoID int64, repo *woodpecker.RepoPatch)) *MockClient_RepoPatch_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.RepoPatch\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.RepoPatch)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoPatch_Call) Return(repo1 *woodpecker.Repo, err error) *MockClient_RepoPatch_Call {\n\t_c.Call.Return(repo1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoPatch_Call) RunAndReturn(run func(repoID int64, repo *woodpecker.RepoPatch) (*woodpecker.Repo, error)) *MockClient_RepoPatch_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoPost provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoPost(opt woodpecker.RepoPostOptions) (*woodpecker.Repo, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoPost\")\n\t}\n\n\tvar r0 *woodpecker.Repo\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RepoPostOptions) (*woodpecker.Repo, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.RepoPostOptions) *woodpecker.Repo); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Repo)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.RepoPostOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_RepoPost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoPost'\ntype MockClient_RepoPost_Call struct {\n\t*mock.Call\n}\n\n// RepoPost is a helper method to define mock.On call\n//   - opt woodpecker.RepoPostOptions\nfunc (_e *MockClient_Expecter) RepoPost(opt interface{}) *MockClient_RepoPost_Call {\n\treturn &MockClient_RepoPost_Call{Call: _e.mock.On(\"RepoPost\", opt)}\n}\n\nfunc (_c *MockClient_RepoPost_Call) Run(run func(opt woodpecker.RepoPostOptions)) *MockClient_RepoPost_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.RepoPostOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.RepoPostOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoPost_Call) Return(repo *woodpecker.Repo, err error) *MockClient_RepoPost_Call {\n\t_c.Call.Return(repo, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoPost_Call) RunAndReturn(run func(opt woodpecker.RepoPostOptions) (*woodpecker.Repo, error)) *MockClient_RepoPost_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// RepoRepair provides a mock function for the type MockClient\nfunc (_mock *MockClient) RepoRepair(repoID int64) error {\n\tret := _mock.Called(repoID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for RepoRepair\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64) error); ok {\n\t\tr0 = returnFunc(repoID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_RepoRepair_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoRepair'\ntype MockClient_RepoRepair_Call struct {\n\t*mock.Call\n}\n\n// RepoRepair is a helper method to define mock.On call\n//   - repoID int64\nfunc (_e *MockClient_Expecter) RepoRepair(repoID interface{}) *MockClient_RepoRepair_Call {\n\treturn &MockClient_RepoRepair_Call{Call: _e.mock.On(\"RepoRepair\", repoID)}\n}\n\nfunc (_c *MockClient_RepoRepair_Call) Run(run func(repoID int64)) *MockClient_RepoRepair_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoRepair_Call) Return(err error) *MockClient_RepoRepair_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_RepoRepair_Call) RunAndReturn(run func(repoID int64) error) *MockClient_RepoRepair_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Secret provides a mock function for the type MockClient\nfunc (_mock *MockClient) Secret(repoID int64, secret string) (*woodpecker.Secret, error) {\n\tret := _mock.Called(repoID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Secret\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(repoID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(repoID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, string) error); ok {\n\t\tr1 = returnFunc(repoID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Secret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Secret'\ntype MockClient_Secret_Call struct {\n\t*mock.Call\n}\n\n// Secret is a helper method to define mock.On call\n//   - repoID int64\n//   - secret string\nfunc (_e *MockClient_Expecter) Secret(repoID interface{}, secret interface{}) *MockClient_Secret_Call {\n\treturn &MockClient_Secret_Call{Call: _e.mock.On(\"Secret\", repoID, secret)}\n}\n\nfunc (_c *MockClient_Secret_Call) Run(run func(repoID int64, secret string)) *MockClient_Secret_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Secret_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_Secret_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Secret_Call) RunAndReturn(run func(repoID int64, secret string) (*woodpecker.Secret, error)) *MockClient_Secret_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretCreate provides a mock function for the type MockClient\nfunc (_mock *MockClient) SecretCreate(repoID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(repoID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretCreate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(repoID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(repoID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(repoID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_SecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretCreate'\ntype MockClient_SecretCreate_Call struct {\n\t*mock.Call\n}\n\n// SecretCreate is a helper method to define mock.On call\n//   - repoID int64\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) SecretCreate(repoID interface{}, secret interface{}) *MockClient_SecretCreate_Call {\n\treturn &MockClient_SecretCreate_Call{Call: _e.mock.On(\"SecretCreate\", repoID, secret)}\n}\n\nfunc (_c *MockClient_SecretCreate_Call) Run(run func(repoID int64, secret *woodpecker.Secret)) *MockClient_SecretCreate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretCreate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_SecretCreate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretCreate_Call) RunAndReturn(run func(repoID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_SecretCreate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretDelete provides a mock function for the type MockClient\nfunc (_mock *MockClient) SecretDelete(repoID int64, secret string) error {\n\tret := _mock.Called(repoID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretDelete\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, string) error); ok {\n\t\tr0 = returnFunc(repoID, secret)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_SecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretDelete'\ntype MockClient_SecretDelete_Call struct {\n\t*mock.Call\n}\n\n// SecretDelete is a helper method to define mock.On call\n//   - repoID int64\n//   - secret string\nfunc (_e *MockClient_Expecter) SecretDelete(repoID interface{}, secret interface{}) *MockClient_SecretDelete_Call {\n\treturn &MockClient_SecretDelete_Call{Call: _e.mock.On(\"SecretDelete\", repoID, secret)}\n}\n\nfunc (_c *MockClient_SecretDelete_Call) Run(run func(repoID int64, secret string)) *MockClient_SecretDelete_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 string\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretDelete_Call) Return(err error) *MockClient_SecretDelete_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretDelete_Call) RunAndReturn(run func(repoID int64, secret string) error) *MockClient_SecretDelete_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretList provides a mock function for the type MockClient\nfunc (_mock *MockClient) SecretList(repoID int64, opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error) {\n\tret := _mock.Called(repoID, opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretList\")\n\t}\n\n\tvar r0 []*woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(repoID, opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, woodpecker.SecretListOptions) []*woodpecker.Secret); ok {\n\t\tr0 = returnFunc(repoID, opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, woodpecker.SecretListOptions) error); ok {\n\t\tr1 = returnFunc(repoID, opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_SecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretList'\ntype MockClient_SecretList_Call struct {\n\t*mock.Call\n}\n\n// SecretList is a helper method to define mock.On call\n//   - repoID int64\n//   - opt woodpecker.SecretListOptions\nfunc (_e *MockClient_Expecter) SecretList(repoID interface{}, opt interface{}) *MockClient_SecretList_Call {\n\treturn &MockClient_SecretList_Call{Call: _e.mock.On(\"SecretList\", repoID, opt)}\n}\n\nfunc (_c *MockClient_SecretList_Call) Run(run func(repoID int64, opt woodpecker.SecretListOptions)) *MockClient_SecretList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 woodpecker.SecretListOptions\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(woodpecker.SecretListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretList_Call) Return(secrets []*woodpecker.Secret, err error) *MockClient_SecretList_Call {\n\t_c.Call.Return(secrets, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretList_Call) RunAndReturn(run func(repoID int64, opt woodpecker.SecretListOptions) ([]*woodpecker.Secret, error)) *MockClient_SecretList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SecretUpdate provides a mock function for the type MockClient\nfunc (_mock *MockClient) SecretUpdate(repoID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error) {\n\tret := _mock.Called(repoID, secret)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SecretUpdate\")\n\t}\n\n\tvar r0 *woodpecker.Secret\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) (*woodpecker.Secret, error)); ok {\n\t\treturn returnFunc(repoID, secret)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, *woodpecker.Secret) *woodpecker.Secret); ok {\n\t\tr0 = returnFunc(repoID, secret)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.Secret)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, *woodpecker.Secret) error); ok {\n\t\tr1 = returnFunc(repoID, secret)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_SecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretUpdate'\ntype MockClient_SecretUpdate_Call struct {\n\t*mock.Call\n}\n\n// SecretUpdate is a helper method to define mock.On call\n//   - repoID int64\n//   - secret *woodpecker.Secret\nfunc (_e *MockClient_Expecter) SecretUpdate(repoID interface{}, secret interface{}) *MockClient_SecretUpdate_Call {\n\treturn &MockClient_SecretUpdate_Call{Call: _e.mock.On(\"SecretUpdate\", repoID, secret)}\n}\n\nfunc (_c *MockClient_SecretUpdate_Call) Run(run func(repoID int64, secret *woodpecker.Secret)) *MockClient_SecretUpdate_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 *woodpecker.Secret\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(*woodpecker.Secret)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretUpdate_Call) Return(secret1 *woodpecker.Secret, err error) *MockClient_SecretUpdate_Call {\n\t_c.Call.Return(secret1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_SecretUpdate_Call) RunAndReturn(run func(repoID int64, secret *woodpecker.Secret) (*woodpecker.Secret, error)) *MockClient_SecretUpdate_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// Self provides a mock function for the type MockClient\nfunc (_mock *MockClient) Self() (*woodpecker.User, error) {\n\tret := _mock.Called()\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for Self\")\n\t}\n\n\tvar r0 *woodpecker.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func() (*woodpecker.User, error)); ok {\n\t\treturn returnFunc()\n\t}\n\tif returnFunc, ok := ret.Get(0).(func() *woodpecker.User); ok {\n\t\tr0 = returnFunc()\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func() error); ok {\n\t\tr1 = returnFunc()\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_Self_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Self'\ntype MockClient_Self_Call struct {\n\t*mock.Call\n}\n\n// Self is a helper method to define mock.On call\nfunc (_e *MockClient_Expecter) Self() *MockClient_Self_Call {\n\treturn &MockClient_Self_Call{Call: _e.mock.On(\"Self\")}\n}\n\nfunc (_c *MockClient_Self_Call) Run(run func()) *MockClient_Self_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\trun()\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_Self_Call) Return(user *woodpecker.User, err error) *MockClient_Self_Call {\n\t_c.Call.Return(user, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_Self_Call) RunAndReturn(run func() (*woodpecker.User, error)) *MockClient_Self_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// SetAddress provides a mock function for the type MockClient\nfunc (_mock *MockClient) SetAddress(s string) {\n\t_mock.Called(s)\n\treturn\n}\n\n// MockClient_SetAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAddress'\ntype MockClient_SetAddress_Call struct {\n\t*mock.Call\n}\n\n// SetAddress is a helper method to define mock.On call\n//   - s string\nfunc (_e *MockClient_Expecter) SetAddress(s interface{}) *MockClient_SetAddress_Call {\n\treturn &MockClient_SetAddress_Call{Call: _e.mock.On(\"SetAddress\", s)}\n}\n\nfunc (_c *MockClient_SetAddress_Call) Run(run func(s string)) *MockClient_SetAddress_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SetAddress_Call) Return() *MockClient_SetAddress_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockClient_SetAddress_Call) RunAndReturn(run func(s string)) *MockClient_SetAddress_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// SetClient provides a mock function for the type MockClient\nfunc (_mock *MockClient) SetClient(client *http.Client) {\n\t_mock.Called(client)\n\treturn\n}\n\n// MockClient_SetClient_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetClient'\ntype MockClient_SetClient_Call struct {\n\t*mock.Call\n}\n\n// SetClient is a helper method to define mock.On call\n//   - client *http.Client\nfunc (_e *MockClient_Expecter) SetClient(client interface{}) *MockClient_SetClient_Call {\n\treturn &MockClient_SetClient_Call{Call: _e.mock.On(\"SetClient\", client)}\n}\n\nfunc (_c *MockClient_SetClient_Call) Run(run func(client *http.Client)) *MockClient_SetClient_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *http.Client\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*http.Client)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SetClient_Call) Return() *MockClient_SetClient_Call {\n\t_c.Call.Return()\n\treturn _c\n}\n\nfunc (_c *MockClient_SetClient_Call) RunAndReturn(run func(client *http.Client)) *MockClient_SetClient_Call {\n\t_c.Run(run)\n\treturn _c\n}\n\n// SetLogLevel provides a mock function for the type MockClient\nfunc (_mock *MockClient) SetLogLevel(logLevel *woodpecker.LogLevel) (*woodpecker.LogLevel, error) {\n\tret := _mock.Called(logLevel)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for SetLogLevel\")\n\t}\n\n\tvar r0 *woodpecker.LogLevel\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.LogLevel) (*woodpecker.LogLevel, error)); ok {\n\t\treturn returnFunc(logLevel)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.LogLevel) *woodpecker.LogLevel); ok {\n\t\tr0 = returnFunc(logLevel)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.LogLevel)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.LogLevel) error); ok {\n\t\tr1 = returnFunc(logLevel)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_SetLogLevel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetLogLevel'\ntype MockClient_SetLogLevel_Call struct {\n\t*mock.Call\n}\n\n// SetLogLevel is a helper method to define mock.On call\n//   - logLevel *woodpecker.LogLevel\nfunc (_e *MockClient_Expecter) SetLogLevel(logLevel interface{}) *MockClient_SetLogLevel_Call {\n\treturn &MockClient_SetLogLevel_Call{Call: _e.mock.On(\"SetLogLevel\", logLevel)}\n}\n\nfunc (_c *MockClient_SetLogLevel_Call) Run(run func(logLevel *woodpecker.LogLevel)) *MockClient_SetLogLevel_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.LogLevel\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.LogLevel)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_SetLogLevel_Call) Return(logLevel1 *woodpecker.LogLevel, err error) *MockClient_SetLogLevel_Call {\n\t_c.Call.Return(logLevel1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_SetLogLevel_Call) RunAndReturn(run func(logLevel *woodpecker.LogLevel) (*woodpecker.LogLevel, error)) *MockClient_SetLogLevel_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepLogEntries provides a mock function for the type MockClient\nfunc (_mock *MockClient) StepLogEntries(repoID int64, pipeline int64, stepID int64) ([]*woodpecker.LogEntry, error) {\n\tret := _mock.Called(repoID, pipeline, stepID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepLogEntries\")\n\t}\n\n\tvar r0 []*woodpecker.LogEntry\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, int64) ([]*woodpecker.LogEntry, error)); ok {\n\t\treturn returnFunc(repoID, pipeline, stepID)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, int64) []*woodpecker.LogEntry); ok {\n\t\tr0 = returnFunc(repoID, pipeline, stepID)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.LogEntry)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(int64, int64, int64) error); ok {\n\t\tr1 = returnFunc(repoID, pipeline, stepID)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_StepLogEntries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepLogEntries'\ntype MockClient_StepLogEntries_Call struct {\n\t*mock.Call\n}\n\n// StepLogEntries is a helper method to define mock.On call\n//   - repoID int64\n//   - pipeline int64\n//   - stepID int64\nfunc (_e *MockClient_Expecter) StepLogEntries(repoID interface{}, pipeline interface{}, stepID interface{}) *MockClient_StepLogEntries_Call {\n\treturn &MockClient_StepLogEntries_Call{Call: _e.mock.On(\"StepLogEntries\", repoID, pipeline, stepID)}\n}\n\nfunc (_c *MockClient_StepLogEntries_Call) Run(run func(repoID int64, pipeline int64, stepID int64)) *MockClient_StepLogEntries_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 int64\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_StepLogEntries_Call) Return(logEntrys []*woodpecker.LogEntry, err error) *MockClient_StepLogEntries_Call {\n\t_c.Call.Return(logEntrys, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_StepLogEntries_Call) RunAndReturn(run func(repoID int64, pipeline int64, stepID int64) ([]*woodpecker.LogEntry, error)) *MockClient_StepLogEntries_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// StepLogsPurge provides a mock function for the type MockClient\nfunc (_mock *MockClient) StepLogsPurge(repoID int64, pipelineNumber int64, stepID int64) error {\n\tret := _mock.Called(repoID, pipelineNumber, stepID)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for StepLogsPurge\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(int64, int64, int64) error); ok {\n\t\tr0 = returnFunc(repoID, pipelineNumber, stepID)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_StepLogsPurge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepLogsPurge'\ntype MockClient_StepLogsPurge_Call struct {\n\t*mock.Call\n}\n\n// StepLogsPurge is a helper method to define mock.On call\n//   - repoID int64\n//   - pipelineNumber int64\n//   - stepID int64\nfunc (_e *MockClient_Expecter) StepLogsPurge(repoID interface{}, pipelineNumber interface{}, stepID interface{}) *MockClient_StepLogsPurge_Call {\n\treturn &MockClient_StepLogsPurge_Call{Call: _e.mock.On(\"StepLogsPurge\", repoID, pipelineNumber, stepID)}\n}\n\nfunc (_c *MockClient_StepLogsPurge_Call) Run(run func(repoID int64, pipelineNumber int64, stepID int64)) *MockClient_StepLogsPurge_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 int64\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(int64)\n\t\t}\n\t\tvar arg1 int64\n\t\tif args[1] != nil {\n\t\t\targ1 = args[1].(int64)\n\t\t}\n\t\tvar arg2 int64\n\t\tif args[2] != nil {\n\t\t\targ2 = args[2].(int64)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t\targ1,\n\t\t\targ2,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_StepLogsPurge_Call) Return(err error) *MockClient_StepLogsPurge_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_StepLogsPurge_Call) RunAndReturn(run func(repoID int64, pipelineNumber int64, stepID int64) error) *MockClient_StepLogsPurge_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// User provides a mock function for the type MockClient\nfunc (_mock *MockClient) User(login string, forgeID ...int64) (*woodpecker.User, error) {\n\tvar tmpRet mock.Arguments\n\tif len(forgeID) > 0 {\n\t\ttmpRet = _mock.Called(login, forgeID)\n\t} else {\n\t\ttmpRet = _mock.Called(login)\n\t}\n\tret := tmpRet\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for User\")\n\t}\n\n\tvar r0 *woodpecker.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(string, ...int64) (*woodpecker.User, error)); ok {\n\t\treturn returnFunc(login, forgeID...)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(string, ...int64) *woodpecker.User); ok {\n\t\tr0 = returnFunc(login, forgeID...)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(string, ...int64) error); ok {\n\t\tr1 = returnFunc(login, forgeID...)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_User_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'User'\ntype MockClient_User_Call struct {\n\t*mock.Call\n}\n\n// User is a helper method to define mock.On call\n//   - login string\n//   - forgeID ...int64\nfunc (_e *MockClient_Expecter) User(login interface{}, forgeID ...interface{}) *MockClient_User_Call {\n\treturn &MockClient_User_Call{Call: _e.mock.On(\"User\",\n\t\tappend([]interface{}{login}, forgeID...)...)}\n}\n\nfunc (_c *MockClient_User_Call) Run(run func(login string, forgeID ...int64)) *MockClient_User_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\tvar arg1 []int64\n\t\tvar variadicArgs []int64\n\t\tif len(args) > 1 {\n\t\t\tvariadicArgs = args[1].([]int64)\n\t\t}\n\t\targ1 = variadicArgs\n\t\trun(\n\t\t\targ0,\n\t\t\targ1...,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_User_Call) Return(user *woodpecker.User, err error) *MockClient_User_Call {\n\t_c.Call.Return(user, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_User_Call) RunAndReturn(run func(login string, forgeID ...int64) (*woodpecker.User, error)) *MockClient_User_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UserDel provides a mock function for the type MockClient\nfunc (_mock *MockClient) UserDel(login string, forgeID ...int64) error {\n\tvar tmpRet mock.Arguments\n\tif len(forgeID) > 0 {\n\t\ttmpRet = _mock.Called(login, forgeID)\n\t} else {\n\t\ttmpRet = _mock.Called(login)\n\t}\n\tret := tmpRet\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UserDel\")\n\t}\n\n\tvar r0 error\n\tif returnFunc, ok := ret.Get(0).(func(string, ...int64) error); ok {\n\t\tr0 = returnFunc(login, forgeID...)\n\t} else {\n\t\tr0 = ret.Error(0)\n\t}\n\treturn r0\n}\n\n// MockClient_UserDel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserDel'\ntype MockClient_UserDel_Call struct {\n\t*mock.Call\n}\n\n// UserDel is a helper method to define mock.On call\n//   - login string\n//   - forgeID ...int64\nfunc (_e *MockClient_Expecter) UserDel(login interface{}, forgeID ...interface{}) *MockClient_UserDel_Call {\n\treturn &MockClient_UserDel_Call{Call: _e.mock.On(\"UserDel\",\n\t\tappend([]interface{}{login}, forgeID...)...)}\n}\n\nfunc (_c *MockClient_UserDel_Call) Run(run func(login string, forgeID ...int64)) *MockClient_UserDel_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 string\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(string)\n\t\t}\n\t\tvar arg1 []int64\n\t\tvar variadicArgs []int64\n\t\tif len(args) > 1 {\n\t\t\tvariadicArgs = args[1].([]int64)\n\t\t}\n\t\targ1 = variadicArgs\n\t\trun(\n\t\t\targ0,\n\t\t\targ1...,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_UserDel_Call) Return(err error) *MockClient_UserDel_Call {\n\t_c.Call.Return(err)\n\treturn _c\n}\n\nfunc (_c *MockClient_UserDel_Call) RunAndReturn(run func(login string, forgeID ...int64) error) *MockClient_UserDel_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UserList provides a mock function for the type MockClient\nfunc (_mock *MockClient) UserList(opt woodpecker.UserListOptions) ([]*woodpecker.User, error) {\n\tret := _mock.Called(opt)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UserList\")\n\t}\n\n\tvar r0 []*woodpecker.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.UserListOptions) ([]*woodpecker.User, error)); ok {\n\t\treturn returnFunc(opt)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(woodpecker.UserListOptions) []*woodpecker.User); ok {\n\t\tr0 = returnFunc(opt)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).([]*woodpecker.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(woodpecker.UserListOptions) error); ok {\n\t\tr1 = returnFunc(opt)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_UserList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserList'\ntype MockClient_UserList_Call struct {\n\t*mock.Call\n}\n\n// UserList is a helper method to define mock.On call\n//   - opt woodpecker.UserListOptions\nfunc (_e *MockClient_Expecter) UserList(opt interface{}) *MockClient_UserList_Call {\n\treturn &MockClient_UserList_Call{Call: _e.mock.On(\"UserList\", opt)}\n}\n\nfunc (_c *MockClient_UserList_Call) Run(run func(opt woodpecker.UserListOptions)) *MockClient_UserList_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 woodpecker.UserListOptions\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(woodpecker.UserListOptions)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_UserList_Call) Return(users []*woodpecker.User, err error) *MockClient_UserList_Call {\n\t_c.Call.Return(users, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_UserList_Call) RunAndReturn(run func(opt woodpecker.UserListOptions) ([]*woodpecker.User, error)) *MockClient_UserList_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UserPatch provides a mock function for the type MockClient\nfunc (_mock *MockClient) UserPatch(user *woodpecker.User) (*woodpecker.User, error) {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UserPatch\")\n\t}\n\n\tvar r0 *woodpecker.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.User) (*woodpecker.User, error)); ok {\n\t\treturn returnFunc(user)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.User) *woodpecker.User); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.User) error); ok {\n\t\tr1 = returnFunc(user)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_UserPatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserPatch'\ntype MockClient_UserPatch_Call struct {\n\t*mock.Call\n}\n\n// UserPatch is a helper method to define mock.On call\n//   - user *woodpecker.User\nfunc (_e *MockClient_Expecter) UserPatch(user interface{}) *MockClient_UserPatch_Call {\n\treturn &MockClient_UserPatch_Call{Call: _e.mock.On(\"UserPatch\", user)}\n}\n\nfunc (_c *MockClient_UserPatch_Call) Run(run func(user *woodpecker.User)) *MockClient_UserPatch_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_UserPatch_Call) Return(user1 *woodpecker.User, err error) *MockClient_UserPatch_Call {\n\t_c.Call.Return(user1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_UserPatch_Call) RunAndReturn(run func(user *woodpecker.User) (*woodpecker.User, error)) *MockClient_UserPatch_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n\n// UserPost provides a mock function for the type MockClient\nfunc (_mock *MockClient) UserPost(user *woodpecker.User) (*woodpecker.User, error) {\n\tret := _mock.Called(user)\n\n\tif len(ret) == 0 {\n\t\tpanic(\"no return value specified for UserPost\")\n\t}\n\n\tvar r0 *woodpecker.User\n\tvar r1 error\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.User) (*woodpecker.User, error)); ok {\n\t\treturn returnFunc(user)\n\t}\n\tif returnFunc, ok := ret.Get(0).(func(*woodpecker.User) *woodpecker.User); ok {\n\t\tr0 = returnFunc(user)\n\t} else {\n\t\tif ret.Get(0) != nil {\n\t\t\tr0 = ret.Get(0).(*woodpecker.User)\n\t\t}\n\t}\n\tif returnFunc, ok := ret.Get(1).(func(*woodpecker.User) error); ok {\n\t\tr1 = returnFunc(user)\n\t} else {\n\t\tr1 = ret.Error(1)\n\t}\n\treturn r0, r1\n}\n\n// MockClient_UserPost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserPost'\ntype MockClient_UserPost_Call struct {\n\t*mock.Call\n}\n\n// UserPost is a helper method to define mock.On call\n//   - user *woodpecker.User\nfunc (_e *MockClient_Expecter) UserPost(user interface{}) *MockClient_UserPost_Call {\n\treturn &MockClient_UserPost_Call{Call: _e.mock.On(\"UserPost\", user)}\n}\n\nfunc (_c *MockClient_UserPost_Call) Run(run func(user *woodpecker.User)) *MockClient_UserPost_Call {\n\t_c.Call.Run(func(args mock.Arguments) {\n\t\tvar arg0 *woodpecker.User\n\t\tif args[0] != nil {\n\t\t\targ0 = args[0].(*woodpecker.User)\n\t\t}\n\t\trun(\n\t\t\targ0,\n\t\t)\n\t})\n\treturn _c\n}\n\nfunc (_c *MockClient_UserPost_Call) Return(user1 *woodpecker.User, err error) *MockClient_UserPost_Call {\n\t_c.Call.Return(user1, err)\n\treturn _c\n}\n\nfunc (_c *MockClient_UserPost_Call) RunAndReturn(run func(user *woodpecker.User) (*woodpecker.User, error)) *MockClient_UserPost_Call {\n\t_c.Call.Return(run)\n\treturn _c\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/org.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\nconst (\n\tpathOrg           = \"%s/api/orgs/%d\"\n\tpathOrgLookup     = \"%s/api/orgs/lookup/%s\"\n\tpathOrgList       = \"%s/api/orgs\"\n\tpathOrgSecrets    = \"%s/api/orgs/%d/secrets\"\n\tpathOrgSecret     = \"%s/api/orgs/%d/secrets/%s\"\n\tpathOrgRegistries = \"%s/api/orgs/%d/registries\"\n\tpathOrgRegistry   = \"%s/api/orgs/%d/registries/%s\"\n)\n\n// Org returns an organization by id.\nfunc (c *client) Org(orgID int64) (*Org, error) {\n\tout := new(Org)\n\turi := fmt.Sprintf(pathOrg, c.addr, orgID)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// OrgLookup returns a organization by its name.\nfunc (c *client) OrgLookup(name string) (*Org, error) {\n\tout := new(Org)\n\turi := fmt.Sprintf(pathOrgLookup, c.addr, name)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\nfunc (c *client) OrgList(opt ListOptions) ([]*Org, error) {\n\tvar out []*Org\n\turi, _ := url.Parse(fmt.Sprintf(pathOrgList, c.addr))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// OrgSecret returns an organization secret by name.\nfunc (c *client) OrgSecret(orgID int64, secret string) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// OrgSecretList returns a list of all organization secrets.\nfunc (c *client) OrgSecretList(orgID int64, opt SecretListOptions) ([]*Secret, error) {\n\tvar out []*Secret\n\turi, _ := url.Parse(fmt.Sprintf(pathOrgSecrets, c.addr, orgID))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// OrgSecretCreate creates an organization secret.\nfunc (c *client) OrgSecretCreate(orgID int64, in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathOrgSecrets, c.addr, orgID)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// OrgSecretUpdate updates an organization secret.\nfunc (c *client) OrgSecretUpdate(orgID int64, in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathOrgSecret, c.addr, orgID, in.Name)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// OrgSecretDelete deletes an organization secret.\nfunc (c *client) OrgSecretDelete(orgID int64, secret string) error {\n\turi := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)\n\treturn c.delete(uri)\n}\n\n// OrgRegistry returns an organization registry by address.\nfunc (c *client) OrgRegistry(orgID int64, registry string) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathOrgRegistry, c.addr, orgID, registry)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// OrgRegistryList returns a list of all organization registries.\nfunc (c *client) OrgRegistryList(orgID int64, opt RegistryListOptions) ([]*Registry, error) {\n\tvar out []*Registry\n\turi, _ := url.Parse(fmt.Sprintf(pathOrgRegistries, c.addr, orgID))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// OrgRegistryCreate creates an organization registry.\nfunc (c *client) OrgRegistryCreate(orgID int64, in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathOrgRegistries, c.addr, orgID)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// OrgRegistryUpdate updates an organization registry.\nfunc (c *client) OrgRegistryUpdate(orgID int64, in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathOrgRegistry, c.addr, orgID, in.Address)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// OrgRegistryDelete deletes an organization registry.\nfunc (c *client) OrgRegistryDelete(orgID int64, registry string) error {\n\turi := fmt.Sprintf(pathOrgRegistry, c.addr, orgID, registry)\n\treturn c.delete(uri)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/pipeline.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\nconst (\n\tpathPipelineQueue    = \"%s/api/pipelines\"\n\tpathPipelineMetadata = \"%s/api/repos/%d/pipelines/%d/metadata\"\n)\n\n// PipelineQueue returns a list of enqueued pipelines.\nfunc (c *client) PipelineQueue() ([]*Feed, error) {\n\tvar out []*Feed\n\turi := fmt.Sprintf(pathPipelineQueue, c.addr)\n\terr := c.get(uri, &out)\n\treturn out, err\n}\n\n// PipelineMetadata returns metadata for a pipeline, workflow name is optional.\nfunc (c *client) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) {\n\turi := fmt.Sprintf(pathPipelineMetadata, c.addr, repoID, pipelineNumber)\n\n\tbody, err := c.open(uri, http.MethodGet, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\n\treturn io.ReadAll(body)\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/queue.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport \"fmt\"\n\nconst pathQueue = \"%s/api/queue\"\n\n// QueueInfo returns queue info.\nfunc (c *client) QueueInfo() (*Info, error) {\n\tout := new(Info)\n\turi := fmt.Sprintf(pathQueue+\"/info\", c.addr)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/queue_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestClient_QueueInfo(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\texpected *Info\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\n\t\t\t\t\t\"pending\": null,\n\t\t\t\t\t\"running\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"id\": \"4696\",\n\t\t\t\t\t\t\t\t\t\"data\": \"\",\n\t\t\t\t\t\t\t\t\t\"labels\": {\n\t\t\t\t\t\t\t\t\t\t\t\"platform\": \"linux/amd64\",\n\t\t\t\t\t\t\t\t\t\t\t\"repo\": \"woodpecker-ci/woodpecker\"\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\"Dependencies\": [],\n\t\t\t\t\t\t\t\t\t\"DepStatus\": {},\n\t\t\t\t\t\t\t\t\t\"RunOn\": null\n\t\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"stats\": {\n\t\t\t\t\t\t\"worker_count\": 2,\n\t\t\t\t\t\t\"pending_count\": 0,\n\t\t\t\t\t\t\"waiting_on_deps_count\": 0,\n\t\t\t\t\t\t\"running_count\": 0,\n\t\t\t\t\t\t\"completed_count\": 0\n\t\t\t\t\t},\n\t\t\t\t\t\"Paused\": false\n\t\t\t\t}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: &Info{\n\t\t\t\tRunning: []Task{\n\t\t\t\t\t{\n\t\t\t\t\t\tID: \"4696\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"platform\": \"linux/amd64\",\n\t\t\t\t\t\t\t\"repo\":     \"woodpecker-ci/woodpecker\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tDependencies: []string{},\n\t\t\t\t\t\tDepStatus:    nil,\n\t\t\t\t\t\tRunOn:        nil,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStats: struct {\n\t\t\t\t\tWorkers       int `json:\"worker_count\"`\n\t\t\t\t\tPending       int `json:\"pending_count\"`\n\t\t\t\t\tWaitingOnDeps int `json:\"waiting_on_deps_count\"`\n\t\t\t\t\tRunning       int `json:\"running_count\"`\n\t\t\t\t\tComplete      int `json:\"completed_count\"`\n\t\t\t\t}{\n\t\t\t\t\tWorkers:       2,\n\t\t\t\t\tPending:       0,\n\t\t\t\t\tWaitingOnDeps: 0,\n\t\t\t\t\tRunning:       0,\n\t\t\t\t\tComplete:      0,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tinfo, err := client.QueueInfo()\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, info)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/repo.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tpathRepoPost       = \"%s/api/repos\"\n\tpathRepo           = \"%s/api/repos/%d\"\n\tpathRepoLookup     = \"%s/api/repos/lookup/%s\"\n\tpathRepoMove       = \"%s/api/repos/%d/move\"\n\tpathChown          = \"%s/api/repos/%d/chown\"\n\tpathRepair         = \"%s/api/repos/%d/repair\"\n\tpathPipelines      = \"%s/api/repos/%d/pipelines\"\n\tpathPipeline       = \"%s/api/repos/%d/pipelines/%v\"\n\tpathPipelineLogs   = \"%s/api/repos/%d/logs/%d\"\n\tpathStepLogs       = \"%s/api/repos/%d/logs/%d/%d\"\n\tpathApprove        = \"%s/api/repos/%d/pipelines/%d/approve\"\n\tpathDecline        = \"%s/api/repos/%d/pipelines/%d/decline\"\n\tpathStop           = \"%s/api/repos/%d/pipelines/%d/cancel\"\n\tpathRepoSecrets    = \"%s/api/repos/%d/secrets\"\n\tpathRepoSecret     = \"%s/api/repos/%d/secrets/%s\"\n\tpathRepoRegistries = \"%s/api/repos/%d/registries\"\n\tpathRepoRegistry   = \"%s/api/repos/%d/registries/%s\"\n\tpathRepoCrons      = \"%s/api/repos/%d/cron\"\n\tpathRepoCron       = \"%s/api/repos/%d/cron/%d\"\n)\n\ntype PipelineListOptions struct {\n\tListOptions\n\tBefore      time.Time\n\tAfter       time.Time\n\tBranch      string\n\tEvents      []string\n\tRefContains string\n\tStatus      string\n}\n\ntype CronListOptions struct {\n\tListOptions\n}\n\ntype RegistryListOptions struct {\n\tListOptions\n}\n\ntype SecretListOptions struct {\n\tListOptions\n}\n\ntype DeployOptions struct {\n\tDeployTo string            // override the target deploy value\n\tParams   map[string]string // custom KEY=value parameters to be injected into the step environment\n}\n\ntype PipelineStartOptions struct {\n\tParams map[string]string // custom KEY=value parameters to be injected into the step environment\n}\n\ntype PipelineLastOptions struct {\n\tBranch string // last pipeline from given branch, an empty branch will result in the default branch\n}\n\ntype RepoPostOptions struct {\n\tForgeRemoteID int64\n}\n\ntype RepoMoveOptions struct {\n\tTo string\n}\n\n// QueryEncode returns the URL query parameters for the PipelineListOptions.\nfunc (opt *PipelineListOptions) QueryEncode() string {\n\tquery := opt.getURLQuery()\n\tif !opt.Before.IsZero() {\n\t\tquery.Add(\"before\", opt.Before.Format(time.RFC3339))\n\t}\n\tif !opt.After.IsZero() {\n\t\tquery.Add(\"after\", opt.After.Format(time.RFC3339))\n\t}\n\tif opt.Branch != \"\" {\n\t\tquery.Add(\"branch\", opt.Branch)\n\t}\n\tif len(opt.Events) > 0 {\n\t\tquery.Add(\"event\", strings.Join(opt.Events, \",\"))\n\t}\n\tif opt.RefContains != \"\" {\n\t\tquery.Add(\"ref\", opt.RefContains)\n\t}\n\tif opt.Status != \"\" {\n\t\tquery.Add(\"status\", opt.Status)\n\t}\n\treturn query.Encode()\n}\n\n// QueryEncode returns the URL query parameters for the DeployOptions.\nfunc (opt *DeployOptions) QueryEncode() string {\n\tquery := mapValues(opt.Params)\n\tif opt.DeployTo != \"\" {\n\t\tquery.Add(\"deploy_to\", opt.DeployTo)\n\t}\n\tquery.Add(\"event\", EventDeploy)\n\treturn query.Encode()\n}\n\n// QueryEncode returns the URL query parameters for the PipelineStartOptions.\nfunc (opt *PipelineStartOptions) QueryEncode() string {\n\tquery := mapValues(opt.Params)\n\treturn query.Encode()\n}\n\n// QueryEncode returns the URL query parameters for the PipelineLastOptions.\nfunc (opt *PipelineLastOptions) QueryEncode() string {\n\tquery := make(url.Values)\n\tif opt.Branch != \"\" {\n\t\tquery.Add(\"branch\", opt.Branch)\n\t}\n\treturn query.Encode()\n}\n\n// QueryEncode returns the URL query parameters for the RepoPostOptions.\nfunc (opt *RepoPostOptions) QueryEncode() string {\n\tquery := make(url.Values)\n\tquery.Add(\"forge_remote_id\", strconv.FormatInt(opt.ForgeRemoteID, 10))\n\treturn query.Encode()\n}\n\n// QueryEncode returns the URL query parameters for the RepoMoveOptions.\nfunc (opt *RepoMoveOptions) QueryEncode() string {\n\tquery := make(url.Values)\n\tquery.Add(\"to\", opt.To)\n\treturn query.Encode()\n}\n\n// Repo returns a repository by id.\nfunc (c *client) Repo(repoID int64) (*Repo, error) {\n\tout := new(Repo)\n\turi := fmt.Sprintf(pathRepo, c.addr, repoID)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// RepoLookup returns a repository by name.\nfunc (c *client) RepoLookup(fullName string) (*Repo, error) {\n\tout := new(Repo)\n\turi := fmt.Sprintf(pathRepoLookup, c.addr, fullName)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// RepoPost activates a repository.\nfunc (c *client) RepoPost(opt RepoPostOptions) (*Repo, error) {\n\tout := new(Repo)\n\turi, _ := url.Parse(fmt.Sprintf(pathRepoPost, c.addr))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.post(uri.String(), nil, out)\n\treturn out, err\n}\n\n// RepoChown updates a repository owner.\nfunc (c *client) RepoChown(repoID int64) (*Repo, error) {\n\tout := new(Repo)\n\turi := fmt.Sprintf(pathChown, c.addr, repoID)\n\terr := c.post(uri, nil, out)\n\treturn out, err\n}\n\n// RepoRepair repairs the repository hooks.\nfunc (c *client) RepoRepair(repoID int64) error {\n\turi := fmt.Sprintf(pathRepair, c.addr, repoID)\n\treturn c.post(uri, nil, nil)\n}\n\n// RepoPatch updates a repository.\nfunc (c *client) RepoPatch(repoID int64, in *RepoPatch) (*Repo, error) {\n\tout := new(Repo)\n\turi := fmt.Sprintf(pathRepo, c.addr, repoID)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// RepoDel deletes a repository.\nfunc (c *client) RepoDel(repoID int64) error {\n\turi := fmt.Sprintf(pathRepo, c.addr, repoID)\n\terr := c.delete(uri)\n\treturn err\n}\n\n// RepoMove moves a repository.\nfunc (c *client) RepoMove(repoID int64, opt RepoMoveOptions) error {\n\turi, _ := url.Parse(fmt.Sprintf(pathRepoMove, c.addr, repoID))\n\turi.RawQuery = opt.QueryEncode()\n\treturn c.post(uri.String(), nil, nil)\n}\n\n// Registry returns a registry by hostname.\nfunc (c *client) Registry(repoID int64, hostname string) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathRepoRegistry, c.addr, repoID, hostname)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// RegistryList returns a list of all repository registries.\nfunc (c *client) RegistryList(repoID int64, opt RegistryListOptions) ([]*Registry, error) {\n\tvar out []*Registry\n\turi, _ := url.Parse(fmt.Sprintf(pathRepoRegistries, c.addr, repoID))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// RegistryCreate creates a registry.\nfunc (c *client) RegistryCreate(repoID int64, in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathRepoRegistries, c.addr, repoID)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// RegistryUpdate updates a registry.\nfunc (c *client) RegistryUpdate(repoID int64, in *Registry) (*Registry, error) {\n\tout := new(Registry)\n\turi := fmt.Sprintf(pathRepoRegistry, c.addr, repoID, in.Address)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// RegistryDelete deletes a registry.\nfunc (c *client) RegistryDelete(repoID int64, hostname string) error {\n\turi := fmt.Sprintf(pathRepoRegistry, c.addr, repoID, hostname)\n\treturn c.delete(uri)\n}\n\n// Secret returns a secret by name.\nfunc (c *client) Secret(repoID int64, secret string) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathRepoSecret, c.addr, repoID, secret)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// SecretList returns a list of all repository secrets.\nfunc (c *client) SecretList(repoID int64, opt SecretListOptions) ([]*Secret, error) {\n\tvar out []*Secret\n\turi, _ := url.Parse(fmt.Sprintf(pathRepoSecrets, c.addr, repoID))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// SecretCreate creates a secret.\nfunc (c *client) SecretCreate(repoID int64, in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathRepoSecrets, c.addr, repoID)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// SecretUpdate updates a secret.\nfunc (c *client) SecretUpdate(repoID int64, in *Secret) (*Secret, error) {\n\tout := new(Secret)\n\turi := fmt.Sprintf(pathRepoSecret, c.addr, repoID, in.Name)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// SecretDelete deletes a secret.\nfunc (c *client) SecretDelete(repoID int64, secret string) error {\n\turi := fmt.Sprintf(pathRepoSecret, c.addr, repoID, secret)\n\treturn c.delete(uri)\n}\n\n// CronList returns a list of cronjobs for the specified repository.\nfunc (c *client) CronList(repoID int64, opt CronListOptions) ([]*Cron, error) {\n\tout := make([]*Cron, 0, 5)\n\turi, _ := url.Parse(fmt.Sprintf(pathRepoCrons, c.addr, repoID))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\treturn out, c.get(uri.String(), &out)\n}\n\n// CronCreate creates a new cron job for the specified repository.\nfunc (c *client) CronCreate(repoID int64, in *Cron) (*Cron, error) {\n\tout := new(Cron)\n\turi := fmt.Sprintf(pathRepoCrons, c.addr, repoID)\n\treturn out, c.post(uri, in, out)\n}\n\n// CronUpdate updates an existing cron job for the specified repository.\nfunc (c *client) CronUpdate(repoID int64, in *Cron) (*Cron, error) {\n\tout := new(Cron)\n\turi := fmt.Sprintf(pathRepoCron, c.addr, repoID, in.ID)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// CronDelete deletes a cron job by cron-id for the specified repository.\nfunc (c *client) CronDelete(repoID, cronID int64) error {\n\turi := fmt.Sprintf(pathRepoCron, c.addr, repoID, cronID)\n\treturn c.delete(uri)\n}\n\n// CronGet returns a cron job by cron-id for the specified repository.\nfunc (c *client) CronGet(repoID, cronID int64) (*Cron, error) {\n\tout := new(Cron)\n\turi := fmt.Sprintf(pathRepoCron, c.addr, repoID, cronID)\n\treturn out, c.get(uri, out)\n}\n\n// Pipeline returns a repository pipeline by pipeline-id.\nfunc (c *client) Pipeline(repoID, pipeline int64) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi := fmt.Sprintf(pathPipeline, c.addr, repoID, pipeline)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// PipelineLast returns the latest repository pipeline.\nfunc (c *client) PipelineLast(repoID int64, opt PipelineLastOptions) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi, _ := url.Parse(fmt.Sprintf(pathPipeline, c.addr, repoID, \"latest\"))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.get(uri.String(), out)\n\treturn out, err\n}\n\n// PipelineList returns a list of recent pipelines for the\n// the specified repository.\nfunc (c *client) PipelineList(repoID int64, opt PipelineListOptions) ([]*Pipeline, error) {\n\tvar out []*Pipeline\n\turi, _ := url.Parse(fmt.Sprintf(pathPipelines, c.addr, repoID))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// PipelineDelete deletes a pipeline by the specified repository ID and pipeline ID.\nfunc (c *client) PipelineDelete(repoID, pipeline int64) error {\n\turi := fmt.Sprintf(pathPipeline, c.addr, repoID, pipeline)\n\terr := c.delete(uri)\n\treturn err\n}\n\n// PipelineCreate creates a new pipeline for the specified repository.\nfunc (c *client) PipelineCreate(repoID int64, options *PipelineOptions) (*Pipeline, error) {\n\tvar out *Pipeline\n\turi := fmt.Sprintf(pathPipelines, c.addr, repoID)\n\terr := c.post(uri, options, &out)\n\treturn out, err\n}\n\n// PipelineStart re-starts a stopped pipeline.\nfunc (c *client) PipelineStart(repoID, pipeline int64, opt PipelineStartOptions) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi, _ := url.Parse(fmt.Sprintf(pathPipeline, c.addr, repoID, pipeline))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.post(uri.String(), nil, out)\n\treturn out, err\n}\n\n// PipelineStop cancels the running step.\nfunc (c *client) PipelineStop(repoID, pipeline int64) error {\n\turi := fmt.Sprintf(pathStop, c.addr, repoID, pipeline)\n\terr := c.post(uri, nil, nil)\n\treturn err\n}\n\n// PipelineApprove approves a blocked pipeline.\nfunc (c *client) PipelineApprove(repoID, pipeline int64) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi := fmt.Sprintf(pathApprove, c.addr, repoID, pipeline)\n\terr := c.post(uri, nil, out)\n\treturn out, err\n}\n\n// PipelineDecline declines a blocked pipeline.\nfunc (c *client) PipelineDecline(repoID, pipeline int64) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi := fmt.Sprintf(pathDecline, c.addr, repoID, pipeline)\n\terr := c.post(uri, nil, out)\n\treturn out, err\n}\n\n// LogsPurge purges the pipeline all steps logs for the specified pipeline.\nfunc (c *client) LogsPurge(repoID, pipeline int64) error {\n\turi := fmt.Sprintf(pathPipelineLogs, c.addr, repoID, pipeline)\n\terr := c.delete(uri)\n\treturn err\n}\n\n// Deploy triggers a deployment for an existing pipeline using the\n// specified target environment.\nfunc (c *client) Deploy(repoID, pipeline int64, opt DeployOptions) (*Pipeline, error) {\n\tout := new(Pipeline)\n\turi, _ := url.Parse(fmt.Sprintf(pathPipeline, c.addr, repoID, pipeline))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.post(uri.String(), nil, out)\n\treturn out, err\n}\n\n// StepLogEntries returns the pipeline logs for the specified step.\nfunc (c *client) StepLogEntries(repoID, num, step int64) ([]*LogEntry, error) {\n\turi := fmt.Sprintf(pathStepLogs, c.addr, repoID, num, step)\n\tvar out []*LogEntry\n\terr := c.get(uri, &out)\n\treturn out, err\n}\n\n// StepLogsPurge purges the pipeline logs for the specified step.\nfunc (c *client) StepLogsPurge(repoID, pipelineNumber, stepID int64) error {\n\turi := fmt.Sprintf(pathStepLogs, c.addr, repoID, pipelineNumber, stepID)\n\terr := c.delete(uri)\n\treturn err\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/repo_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestPipelineList(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\thandler        http.HandlerFunc\n\t\topts           PipelineListOptions\n\t\twantErr        bool\n\t\texpectedLength int\n\t\texpectedIDs    []int64\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodGet, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines?after=2023-01-15T00%3A00%3A00Z&before=2023-01-16T00%3A00%3A00Z&page=2&perPage=10\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[{\"id\":1},{\"id\":2}]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topts: PipelineListOptions{\n\t\t\t\tListOptions: ListOptions{\n\t\t\t\t\tPage:    2,\n\t\t\t\t\tPerPage: 10,\n\t\t\t\t},\n\t\t\t\tBefore: time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC),\n\t\t\t\tAfter:  time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),\n\t\t\t},\n\t\t\texpectedLength: 2,\n\t\t\texpectedIDs:    []int64{1, 2},\n\t\t},\n\t\t{\n\t\t\tname: \"empty ListOptions\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodGet, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[{\"id\":1},{\"id\":2}]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topts:           PipelineListOptions{},\n\t\t\texpectedLength: 2,\n\t\t\texpectedIDs:    []int64{1, 2},\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\topts:    PipelineListOptions{},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\n\t\t\tpipelines, err := client.PipelineList(123, tt.opts)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Nil(t, pipelines)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Len(t, pipelines, tt.expectedLength)\n\t\t\tfor i, id := range tt.expectedIDs {\n\t\t\t\tassert.Equal(t, id, pipelines[i].ID)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClientDeploy(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\thandler          http.HandlerFunc\n\t\trepoID           int64\n\t\tpipelineID       int64\n\t\topts             DeployOptions\n\t\twantErr          bool\n\t\texpectedPipeline *Pipeline\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines/456?event=deployment\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":789}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts:       DeployOptions{},\n\t\t\texpectedPipeline: &Pipeline{\n\t\t\t\tID: 789,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts:       DeployOptions{},\n\t\t\twantErr:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"with options\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines/456?deploy_to=production&event=deployment\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":789}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts: DeployOptions{\n\t\t\t\tDeployTo: \"production\",\n\t\t\t},\n\t\t\texpectedPipeline: &Pipeline{\n\t\t\t\tID: 789,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\n\t\t\tpipeline, err := client.Deploy(tt.repoID, tt.pipelineID, tt.opts)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expectedPipeline, pipeline)\n\t\t})\n\t}\n}\n\nfunc TestClientPipelineStart(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\thandler          http.HandlerFunc\n\t\trepoID           int64\n\t\tpipelineID       int64\n\t\topts             PipelineStartOptions\n\t\twantErr          bool\n\t\texpectedPipeline *Pipeline\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines/456\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":789}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts:       PipelineStartOptions{},\n\t\t\texpectedPipeline: &Pipeline{\n\t\t\t\tID: 789,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts:       PipelineStartOptions{},\n\t\t\twantErr:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"with options\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/pipelines/456?foo=bar\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":789}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID:     123,\n\t\t\tpipelineID: 456,\n\t\t\topts: PipelineStartOptions{\n\t\t\t\tParams: map[string]string{\"foo\": \"bar\"},\n\t\t\t},\n\t\t\texpectedPipeline: &Pipeline{\n\t\t\t\tID: 789,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\n\t\t\tpipeline, err := client.PipelineStart(tt.repoID, tt.pipelineID, tt.opts)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expectedPipeline, pipeline)\n\t\t})\n\t}\n}\n\nfunc TestClient_PipelineLast(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\trepoID   int64\n\t\topts     PipelineLastOptions\n\t\texpected *Pipeline\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, \"/api/repos/1/pipelines/latest?branch=main\", r.URL.Path+\"?\"+r.URL.RawQuery)\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"number\":1,\"status\":\"success\",\"event\":\"push\",\"branch\":\"main\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID: 1,\n\t\t\topts:   PipelineLastOptions{Branch: \"main\"},\n\t\t\texpected: &Pipeline{\n\t\t\t\tID:     1,\n\t\t\t\tNumber: 1,\n\t\t\t\tStatus: \"success\",\n\t\t\t\tEvent:  \"push\",\n\t\t\t\tBranch: \"main\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\trepoID:   1,\n\t\t\topts:     PipelineLastOptions{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\trepoID:   1,\n\t\t\topts:     PipelineLastOptions{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tpipeline, err := client.PipelineLast(tt.repoID, tt.opts)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, pipeline)\n\t\t})\n\t}\n}\n\nfunc TestClientRepoPost(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\topts     RepoPostOptions\n\t\texpected *Repo\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos?forge_remote_id=10\", r.URL.RequestURI())\n\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"name\":\"test\",\"owner\":\"owner\",\"full_name\":\"owner/test\",\"forge_remote_id\":\"10\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topts: RepoPostOptions{\n\t\t\t\tForgeRemoteID: 10,\n\t\t\t},\n\t\t\texpected: &Repo{\n\t\t\t\tID:            1,\n\t\t\t\tForgeRemoteID: \"10\",\n\t\t\t\tName:          \"test\",\n\t\t\t\tOwner:         \"owner\",\n\t\t\t\tFullName:      \"owner/test\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\topts:     RepoPostOptions{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `invalid json`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topts:     RepoPostOptions{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\trepo, err := client.RepoPost(tt.opts)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, repo)\n\t\t})\n\t}\n}\n\nfunc TestClientRepoMove(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\thandler http.HandlerFunc\n\t\trepoID  int64\n\t\topts    RepoMoveOptions\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tassert.Equal(t, \"/api/repos/123/move?to=new_owner\", r.URL.RequestURI())\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t\trepoID: 123,\n\t\t\topts: RepoMoveOptions{\n\t\t\t\tTo: \"new_owner\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\trepoID:  123,\n\t\t\topts:    RepoMoveOptions{},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid options\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, http.MethodPost, r.Method)\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\trepoID:  123,\n\t\t\topts:    RepoMoveOptions{},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\terr := client.RepoMove(tt.repoID, tt.opts)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/types.go",
    "content": "// Copyright 2022 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\ntype ApprovalMode string\n\nvar (\n\tRequireApprovalNone         ApprovalMode = \"none\"          // require approval for no events\n\tRequireApprovalForks        ApprovalMode = \"forks\"         // require approval for PRs from forks\n\tRequireApprovalPullRequests ApprovalMode = \"pull_requests\" // require approval for all PRs (default)\n\tRequireApprovalAllEvents    ApprovalMode = \"all_events\"    // require approval for all events\n)\n\nfunc (mode ApprovalMode) Valid() bool {\n\tswitch mode {\n\tcase RequireApprovalNone,\n\t\tRequireApprovalForks,\n\t\tRequireApprovalPullRequests,\n\t\tRequireApprovalAllEvents:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\ntype (\n\t// User represents a user account.\n\tUser struct {\n\t\tID            int64  `json:\"id\"`\n\t\tForgeID       int64  `json:\"forge_id\"`\n\t\tForgeRemoteID string `json:\"forge_remote_id\"`\n\t\tLogin         string `json:\"login\"`\n\t\tEmail         string `json:\"email\"`\n\t\tAvatar        string `json:\"avatar_url\"`\n\t\tActive        bool   `json:\"active\"`\n\t\tAdmin         bool   `json:\"admin\"`\n\t}\n\n\tTrustedConfiguration struct {\n\t\tNetwork  bool `json:\"network\"`\n\t\tVolumes  bool `json:\"volumes\"`\n\t\tSecurity bool `json:\"security\"`\n\t}\n\n\t// Repo represents a repository.\n\tRepo struct {\n\t\tID                           int64                `json:\"id,omitempty\"`\n\t\tForgeRemoteID                string               `json:\"forge_remote_id\"`\n\t\tOwner                        string               `json:\"owner\"`\n\t\tName                         string               `json:\"name\"`\n\t\tFullName                     string               `json:\"full_name\"`\n\t\tAvatar                       string               `json:\"avatar_url,omitempty\"`\n\t\tForgeURL                     string               `json:\"forge_url,omitempty\"`\n\t\tClone                        string               `json:\"clone_url,omitempty\"`\n\t\tBranch                       string               `json:\"default_branch,omitempty\"`\n\t\tSCMKind                      string               `json:\"scm,omitempty\"`\n\t\tTimeout                      int64                `json:\"timeout,omitempty\"`\n\t\tVisibility                   string               `json:\"visibility\"`\n\t\tIsSCMPrivate                 bool                 `json:\"private\"`\n\t\tTrusted                      TrustedConfiguration `json:\"trusted\"`\n\t\tRequireApproval              ApprovalMode         `json:\"require_approval\"`\n\t\tIsActive                     bool                 `json:\"active\"`\n\t\tAllowPull                    bool                 `json:\"allow_pr\"`\n\t\tConfig                       string               `json:\"config_file\"`\n\t\tCancelPreviousPipelineEvents []string             `json:\"cancel_previous_pipeline_events\"`\n\t\tNetrcTrustedPlugins          []string             `json:\"netrc_trusted\"`\n\t}\n\n\tTrustedConfigurationPatch struct {\n\t\tNetwork  *bool `json:\"network\"`\n\t\tVolumes  *bool `json:\"volumes\"`\n\t\tSecurity *bool `json:\"security\"`\n\t}\n\n\t// RepoPatch defines a repository patch request.\n\tRepoPatch struct {\n\t\tConfig *string `json:\"config_file,omitempty\"`\n\t\t// Deprecated: use Trusted (broken - only exists for backwards compatibility)\n\t\tIsTrusted       *bool                      `json:\"-\"`\n\t\tTrusted         *TrustedConfigurationPatch `json:\"trusted,omitempty\"`\n\t\tRequireApproval *ApprovalMode              `json:\"require_approval,omitempty\"`\n\t\tTimeout         *int64                     `json:\"timeout,omitempty\"`\n\t\tVisibility      *string                    `json:\"visibility\"`\n\t\tAllowPull       *bool                      `json:\"allow_pr,omitempty\"`\n\t\tPipelineCounter *int                       `json:\"pipeline_counter,omitempty\"`\n\t}\n\n\tPipelineError struct {\n\t\tType      string `json:\"type\"`\n\t\tMessage   string `json:\"message\"`\n\t\tIsWarning bool   `json:\"is_warning\"`\n\t\tData      any    `json:\"data\"`\n\t}\n\n\t// Pipeline defines a pipeline object.\n\tPipeline struct {\n\t\tID          int64            `json:\"id\"`\n\t\tNumber      int64            `json:\"number\"`\n\t\tParent      int64            `json:\"parent\"`\n\t\tEvent       string           `json:\"event\"`\n\t\tEventReason []string         `json:\"event_reason\"`\n\t\tStatus      string           `json:\"status\"`\n\t\tErrors      []*PipelineError `json:\"errors\"`\n\t\tCreated     int64            `json:\"created\"`\n\t\tUpdated     int64            `json:\"updated\"`\n\t\tStarted     int64            `json:\"started\"`\n\t\tFinished    int64            `json:\"finished\"`\n\t\tDeploy      string           `json:\"deploy_to\"`\n\t\tCommit      string           `json:\"commit\"`\n\t\tBranch      string           `json:\"branch\"`\n\t\tRef         string           `json:\"ref\"`\n\t\tRefspec     string           `json:\"refspec\"`\n\t\tTitle       string           `json:\"title\"`\n\t\tMessage     string           `json:\"message\"`\n\t\tTimestamp   int64            `json:\"timestamp\"`\n\t\tSender      string           `json:\"sender\"`\n\t\tAuthor      string           `json:\"author\"`\n\t\tAvatar      string           `json:\"author_avatar\"`\n\t\tEmail       string           `json:\"author_email\"`\n\t\tForgeURL    string           `json:\"forge_url\"`\n\t\tReviewer    string           `json:\"reviewed_by\"`\n\t\tReviewed    int64            `json:\"reviewed\"`\n\t\tWorkflows   []*Workflow      `json:\"workflows,omitempty\"`\n\t}\n\n\t// Workflow represents a workflow in the pipeline.\n\tWorkflow struct {\n\t\tID       int64             `json:\"id\"`\n\t\tPID      int               `json:\"pid\"`\n\t\tName     string            `json:\"name\"`\n\t\tState    string            `json:\"state\"`\n\t\tError    string            `json:\"error,omitempty\"`\n\t\tStarted  int64             `json:\"started,omitempty\"`\n\t\tStopped  int64             `json:\"finished,omitempty\"`\n\t\tAgentID  int64             `json:\"agent_id,omitempty\"`\n\t\tPlatform string            `json:\"platform,omitempty\"`\n\t\tEnviron  map[string]string `json:\"environ,omitempty\"`\n\t\tChildren []*Step           `json:\"children,omitempty\"`\n\t}\n\n\t// Step represents a process in the pipeline.\n\tStep struct {\n\t\tID       int64    `json:\"id\"`\n\t\tPID      int      `json:\"pid\"`\n\t\tPPID     int      `json:\"ppid\"`\n\t\tName     string   `json:\"name\"`\n\t\tState    string   `json:\"state\"`\n\t\tError    string   `json:\"error,omitempty\"`\n\t\tExitCode int      `json:\"exit_code\"`\n\t\tStarted  int64    `json:\"started,omitempty\"`\n\t\tStopped  int64    `json:\"finished,omitempty\"`\n\t\tType     StepType `json:\"type,omitempty\"`\n\t}\n\n\t// Registry represents a docker registry with credentials.\n\tRegistry struct {\n\t\tID       int64  `json:\"id\"`\n\t\tOrgID    int64  `json:\"org_id\"`\n\t\tRepoID   int64  `json:\"repo_id\"`\n\t\tAddress  string `json:\"address\"`\n\t\tUsername string `json:\"username\"`\n\t\tPassword string `json:\"password,omitempty\"`\n\t}\n\n\t// Secret represents a secret variable, such as a password or token.\n\tSecret struct {\n\t\tID     int64    `json:\"id\"`\n\t\tOrgID  int64    `json:\"org_id\"`\n\t\tRepoID int64    `json:\"repo_id\"`\n\t\tName   string   `json:\"name\"`\n\t\tValue  string   `json:\"value,omitempty\"`\n\t\tImages []string `json:\"images\"`\n\t\tEvents []string `json:\"events\"`\n\t\tNote   string   `json:\"note\"`\n\t}\n\n\t// Feed represents an item in the user's feed or timeline.\n\tFeed struct {\n\t\tRepoID   int64  `json:\"repo_id\"`\n\t\tID       int64  `json:\"id,omitempty\"`\n\t\tNumber   int64  `json:\"number,omitempty\"`\n\t\tEvent    string `json:\"event,omitempty\"`\n\t\tStatus   string `json:\"status,omitempty\"`\n\t\tCreated  int64  `json:\"created,omitempty\"`\n\t\tStarted  int64  `json:\"started,omitempty\"`\n\t\tFinished int64  `json:\"finished,omitempty\"`\n\t\tCommit   string `json:\"commit,omitempty\"`\n\t\tBranch   string `json:\"branch,omitempty\"`\n\t\tRef      string `json:\"ref,omitempty\"`\n\t\tRefspec  string `json:\"refspec,omitempty\"`\n\t\tRemote   string `json:\"remote,omitempty\"`\n\t\tTitle    string `json:\"title,omitempty\"`\n\t\tMessage  string `json:\"message,omitempty\"`\n\t\tAuthor   string `json:\"author,omitempty\"`\n\t\tAvatar   string `json:\"author_avatar,omitempty\"`\n\t\tEmail    string `json:\"author_email,omitempty\"`\n\t}\n\n\t// Version provides system version details.\n\tVersion struct {\n\t\tSource  string `json:\"source,omitempty\"`\n\t\tVersion string `json:\"version,omitempty\"`\n\t\tCommit  string `json:\"commit,omitempty\"`\n\t}\n\n\tQueueStats struct {\n\t\tWorkers       int `json:\"worker_count\"`\n\t\tPending       int `json:\"pending_count\"`\n\t\tWaitingOnDeps int `json:\"waiting_on_deps_count\"`\n\t\tRunning       int `json:\"running_count\"`\n\t\tComplete      int `json:\"completed_count\"`\n\t}\n\n\t// Info provides queue stats.\n\tInfo struct {\n\t\tPending       []Task     `json:\"pending\"`\n\t\tWaitingOnDeps []Task     `json:\"waiting_on_deps\"`\n\t\tRunning       []Task     `json:\"running\"`\n\t\tStats         QueueStats `json:\"stats\"`\n\t\tPaused        bool       `json:\"paused,omitempty\"`\n\t}\n\n\t// LogLevel is for checking/setting logging level.\n\tLogLevel struct {\n\t\tLevel string `json:\"log-level\"`\n\t}\n\n\t// LogEntry is a single log entry.\n\tLogEntry struct {\n\t\tID     int64        `json:\"id\"`\n\t\tStepID int64        `json:\"step_id\"`\n\t\tTime   int64        `json:\"time\"`\n\t\tLine   int          `json:\"line\"`\n\t\tData   []byte       `json:\"data\"`\n\t\tType   LogEntryType `json:\"type\"`\n\t}\n\n\t// Cron is the JSON data of a cron job.\n\tCron struct {\n\t\tID        int64  `json:\"id\"`\n\t\tName      string `json:\"name\"`\n\t\tRepoID    int64  `json:\"repo_id\"`\n\t\tCreatorID int64  `json:\"creator_id\"`\n\t\tNextExec  int64  `json:\"next_exec\"`\n\t\tSchedule  string `json:\"schedule\"`\n\t\tCreated   int64  `json:\"created\"`\n\t\tBranch    string `json:\"branch\"`\n\t\tEnabled   bool   `json:\"enabled\"`\n\t}\n\n\t// PipelineOptions is the JSON data for creating a new pipeline.\n\tPipelineOptions struct {\n\t\tBranch    string            `json:\"branch\"`\n\t\tVariables map[string]string `json:\"variables\"`\n\t}\n\n\t// Agent is the JSON data for an agent.\n\tAgent struct {\n\t\tID           int64             `json:\"id\"`\n\t\tCreated      int64             `json:\"created\"`\n\t\tUpdated      int64             `json:\"updated\"`\n\t\tName         string            `json:\"name\"`\n\t\tOwnerID      int64             `json:\"owner_id\"`\n\t\tOrgID        int64             `json:\"org_id\"`\n\t\tToken        string            `json:\"token\"`\n\t\tLastContact  int64             `json:\"last_contact\"`\n\t\tLastWork     int64             `json:\"last_work\"`\n\t\tPlatform     string            `json:\"platform\"`\n\t\tBackend      string            `json:\"backend\"`\n\t\tCapacity     int32             `json:\"capacity\"`\n\t\tVersion      string            `json:\"version\"`\n\t\tNoSchedule   bool              `json:\"no_schedule\"`\n\t\tCustomLabels map[string]string `json:\"custom_labels\"`\n\t}\n\n\t// Task is the JSON data for a task.\n\tTask struct {\n\t\tID           string            `json:\"id\"`\n\t\tLabels       map[string]string `json:\"labels\"`\n\t\tDependencies []string          `json:\"dependencies\"`\n\t\tRunOn        []string          `json:\"run_on\"`\n\t\tDepStatus    map[string]string `json:\"dep_status\"`\n\t\tAgentID      int64             `json:\"agent_id\"`\n\t}\n\n\t// Org is the JSON data for an organization.\n\tOrg struct {\n\t\tID     int64  `json:\"id\"`\n\t\tName   string `json:\"name\"`\n\t\tIsUser bool   `json:\"is_user\"`\n\t}\n)\n"
  },
  {
    "path": "woodpecker-go/woodpecker/user.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\nconst (\n\tpathSelf  = \"%s/api/user\"\n\tpathRepos = \"%s/api/user/repos\"\n\tpathUsers = \"%s/api/users\"\n\tpathUser  = \"%s/api/users/%s?forge_id=%d\"\n)\n\ntype RepoListOptions struct {\n\tAll bool // query all repos, including inactive ones\n}\n\ntype UserListOptions struct {\n\tListOptions\n}\n\n// QueryEncode returns the URL query parameters for the RepoListOptions.\nfunc (opt *RepoListOptions) QueryEncode() string {\n\tquery := make(url.Values)\n\tif opt.All {\n\t\tquery.Add(\"all\", \"true\")\n\t}\n\treturn query.Encode()\n}\n\n// Self returns the currently authenticated user.\nfunc (c *client) Self() (*User, error) {\n\tout := new(User)\n\turi := fmt.Sprintf(pathSelf, c.addr)\n\terr := c.get(uri, out)\n\treturn out, err\n}\n\n// User returns a user by login.\n// It is recommended to specify forgeID (default is 1).\nfunc (c *client) User(login string, forgeID ...int64) (*User, error) {\n\tout := new(User)\n\tif len(forgeID) == 0 {\n\t\tforgeID = []int64{defaultForgeID}\n\t}\n\terr := c.get(fmt.Sprintf(pathUser, c.addr, login, forgeID[0]), out)\n\treturn out, err\n}\n\n// UserList returns a list of all registered users.\nfunc (c *client) UserList(opt UserListOptions) ([]*User, error) {\n\tvar out []*User\n\turi, _ := url.Parse(fmt.Sprintf(pathUsers, c.addr))\n\turi.RawQuery = opt.getURLQuery().Encode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n\n// UserPost creates a new user account.\nfunc (c *client) UserPost(in *User) (*User, error) {\n\tout := new(User)\n\turi := fmt.Sprintf(pathUsers, c.addr)\n\terr := c.post(uri, in, out)\n\treturn out, err\n}\n\n// UserPatch updates a user account.\nfunc (c *client) UserPatch(in *User) (*User, error) {\n\tif in.ForgeID < defaultForgeID {\n\t\tin.ForgeID = defaultForgeID\n\t}\n\tout := new(User)\n\turi := fmt.Sprintf(pathUser, c.addr, in.Login, in.ForgeID)\n\terr := c.patch(uri, in, out)\n\treturn out, err\n}\n\n// UserDel deletes a user account.\n// It is recommended to specify forgeID (default is 1).\nfunc (c *client) UserDel(login string, forgeID ...int64) error {\n\tif len(forgeID) == 0 {\n\t\tforgeID = []int64{defaultForgeID}\n\t}\n\treturn c.delete(fmt.Sprintf(pathUser, c.addr, login, forgeID[0]))\n}\n\n// RepoList returns a list of all repositories to which\n// the user has explicit access in the host system.\nfunc (c *client) RepoList(opt RepoListOptions) ([]*Repo, error) {\n\tvar out []*Repo\n\turi, _ := url.Parse(fmt.Sprintf(pathRepos, c.addr))\n\turi.RawQuery = opt.QueryEncode()\n\terr := c.get(uri.String(), &out)\n\treturn out, err\n}\n"
  },
  {
    "path": "woodpecker-go/woodpecker/user_test.go",
    "content": "// Copyright 2024 Woodpecker Authors\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage woodpecker\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestClient_UserList(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\texpected []*User\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[{\"id\":1,\"login\":\"user1\"},{\"id\":2,\"login\":\"user2\"}]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: []*User{{ID: 1, Login: \"user1\"}, {ID: 2, Login: \"user2\"}},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"empty response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\texpected: []*User{},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tusers, err := client.UserList(UserListOptions{})\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, users, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestClient_UserPost(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tinput    *User\n\t\texpected *User\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusCreated)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"login\":\"new_user\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tinput:    &User{Login: \"new_user\"},\n\t\t\texpected: &User{ID: 1, Login: \"new_user\"},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid input\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\tinput:    &User{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tinput:    &User{Login: \"new_user\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tuser, err := client.UserPost(tt.input)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, user, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestClient_UserPatch(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\tinput    *User\n\t\texpected *User\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `{\"id\":1,\"login\":\"updated_user\"}`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\tinput:    &User{ID: 1, Login: \"existing_user\"},\n\t\t\texpected: &User{ID: 1, Login: \"updated_user\"},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tinput:    &User{ID: 999, Login: \"nonexistent_user\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid input\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t},\n\t\t\tinput:    &User{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPatch {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tinput:    &User{ID: 1, Login: \"existing_user\"},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\tuser, err := client.UserPatch(tt.input)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, user, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestClient_UserDel(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\thandler http.HandlerFunc\n\t\tlogin   string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t},\n\t\t\tlogin:   \"existing_user\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\t},\n\t\t\tlogin:   \"nonexistent_user\",\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodDelete {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\tlogin:   \"existing_user\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\terr := client.UserDel(tt.login)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t}\n}\n\nfunc TestClient_RepoList(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\thandler  http.HandlerFunc\n\t\topt      RepoListOptions\n\t\texpected []*Repo\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[{\"id\":1,\"name\":\"repo1\"},{\"id\":2,\"name\":\"repo2\"}]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topt:      RepoListOptions{},\n\t\t\texpected: []*Repo{{ID: 1, Name: \"repo1\"}, {ID: 2, Name: \"repo2\"}},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"empty response\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topt:      RepoListOptions{},\n\t\t\texpected: []*Repo{},\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"server error\",\n\t\t\thandler: func(w http.ResponseWriter, _ *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t},\n\t\t\topt:      RepoListOptions{},\n\t\t\texpected: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"with options\",\n\t\t\thandler: func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tassert.Equal(t, \"/api/user/repos?all=true\", r.URL.RequestURI())\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t_, err := fmt.Fprint(w, `[]`)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t},\n\t\t\topt:      RepoListOptions{All: true},\n\t\t\texpected: []*Repo{},\n\t\t\twantErr:  false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tts := httptest.NewServer(tt.handler)\n\t\t\tdefer ts.Close()\n\n\t\t\tclient := NewClient(ts.URL, http.DefaultClient)\n\t\t\trepos, err := client.RepoList(tt.opt)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, repos)\n\t\t})\n\t}\n}\n"
  }
]